diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 5e757564c..a9a56c69f 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -17,4 +17,14 @@ GITHUB_WEBHOOK_SECRET="" GITHUB_APP_PRIVATE_KEY="" # Key from code.storage for syncing -CODE_STORAGE_SYNC_PRIVATE_KEY="" \ No newline at end of file +CODE_STORAGE_SYNC_PRIVATE_KEY="" + +# Mistral API Key for the edit prediction demo +MISTRAL_API_KEY="" + +# GitHub OAuth App for the edit prediction demo +# Set its authorization callback URL to /edit/auth?callback +GITHUB_OAUTH_CLIENT_ID="" + +# GitHub OAuth Client Secret +GITHUB_OAUTH_CLIENT_SECRET="" diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index d2a722628..6726cb3e0 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -31,15 +31,21 @@ import { CUSTOM_HUNK_SEPARATORS_SWITCHER, } from '../docs/CustomHunkSeparators/constants'; import { + EDIT_AUTOFOCUS_REACT_EXAMPLE, + EDIT_AUTOFOCUS_VANILLA_EXAMPLE, EDIT_DEMO_FILE_EXAMPLE, + EDIT_FOCUS_POSITION_EXAMPLE, EDIT_LAZY_FILE_EXAMPLE, EDIT_MARKER_EXAMPLE, EDIT_MARKER_TYPE, EDIT_ON_CHANGE_EXAMPLE, + EDIT_PERSIST_STATE_EXAMPLE, + EDIT_PREDICTION_EXAMPLE, EDIT_REACT_CODE_VIEW_EXAMPLE, EDIT_REACT_EXAMPLE, EDIT_REACT_FILE_DIFF_EXAMPLE, EDIT_REACT_MULTI_FILE_DIFF_EXAMPLE, + EDIT_REACT_PROVIDER_EXAMPLE, EDIT_SELECTION_ACTION_CONTEXT_TYPE, EDIT_SELECTION_ACTION_EXAMPLE, EDIT_UNDO_REDO_EXAMPLE, @@ -424,12 +430,17 @@ async function CodeViewSection() { async function EditSection() { const [ + editAutofocusReactExample, + editAutofocusVanillaExample, editDemoFile, + editFocusPositionExample, keymapFile, editVanillaFileExample, editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPersistStateExample, + editPredictionExample, editorOptionsType, editOnChangeExample, editorPublicApi, @@ -441,16 +452,22 @@ async function EditSection() { editReactExample, editReactFileDiffExample, editReactMultiFileDiffExample, + editReactProviderExample, editUndoRedoExample, editWorkerPoolReactExample, editWorkerPoolVanillaExample, ] = await Promise.all([ + preloadFile(EDIT_AUTOFOCUS_REACT_EXAMPLE), + preloadFile(EDIT_AUTOFOCUS_VANILLA_EXAMPLE), preloadFile(EDIT_DEMO_FILE_EXAMPLE), + preloadFile(EDIT_FOCUS_POSITION_EXAMPLE), preloadFile(DEFAULT_KEYMAP_FILE_EXAMPLE), preloadFile(EDIT_VANILLA_FILE_EXAMPLE), preloadFile(EDIT_VANILLA_FILE_DIFF_EXAMPLE), preloadFile(EDIT_VANILLA_CODE_VIEW_EXAMPLE), preloadFile(EDIT_LAZY_FILE_EXAMPLE), + preloadFile(EDIT_PERSIST_STATE_EXAMPLE), + preloadFile(EDIT_PREDICTION_EXAMPLE), preloadFile(EDITOR_OPTIONS_TYPE), preloadFile(EDIT_ON_CHANGE_EXAMPLE), preloadFile(EDITOR_PUBLIC_API), @@ -462,6 +479,7 @@ async function EditSection() { preloadFile(EDIT_REACT_EXAMPLE), preloadFile(EDIT_REACT_FILE_DIFF_EXAMPLE), preloadFile(EDIT_REACT_MULTI_FILE_DIFF_EXAMPLE), + preloadFile(EDIT_REACT_PROVIDER_EXAMPLE), preloadFile(EDIT_UNDO_REDO_EXAMPLE), preloadFile(EDIT_WORKER_POOL_REACT_EXAMPLE), preloadFile(EDIT_WORKER_POOL_VANILLA_EXAMPLE), @@ -469,12 +487,17 @@ async function EditSection() { const content = await renderMDX({ filePath: '(diffs)/docs/Edit/content.mdx', scope: { + editAutofocusReactExample, + editAutofocusVanillaExample, editDemoFile, + editFocusPositionExample, keymapFile, editVanillaFileExample, editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPersistStateExample, + editPredictionExample, editorOptionsType, editOnChangeExample, editorPublicApi, @@ -486,6 +509,7 @@ async function EditSection() { editReactExample, editReactFileDiffExample, editReactMultiFileDiffExample, + editReactProviderExample, editUndoRedoExample, editWorkerPoolReactExample, editWorkerPoolVanillaExample, diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 2671ee225..985fa37b1 100644 --- a/apps/docs/app/(diffs)/_edit/EditPage.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -6,6 +6,7 @@ import type { import { WorkerPoolContext } from '../_components/WorkerPoolContext'; import { LiveEditing } from '../_examples/LiveEditing/LiveEditing'; import { EditHero } from './EditHero'; +import { EditPredictionDemo } from './EditPredictionDemo'; import { EditReference } from './EditReference'; import { FindDemo } from './FindDemo'; import { HistoryDemo } from './HistoryDemo'; @@ -21,6 +22,8 @@ import { PierreCompanySection } from '@/components/PierreCompanySection'; interface EditPageProps { liveEditingFile: PreloadedFileResult; liveEditingDiff: PreloadFileDiffResult; + editPredictionFile: PreloadedFileResult; + editPredictionDiff: PreloadFileDiffResult; markerFile: PreloadedFileResult; findFile: PreloadedFileResult; historyFile: PreloadedFileResult; @@ -31,6 +34,8 @@ interface EditPageProps { export function EditPage({ liveEditingFile, liveEditingDiff, + editPredictionFile, + editPredictionDiff, markerFile, findFile, historyFile, @@ -50,6 +55,28 @@ export function EditPage({ prerenderedDiff={liveEditingDiff} /> +
+ + Pause after typing or moving the cursor to preview an edit + prediction, then press Tab to accept the + suggestion—or hold Alt while pressing{' '} + Tab in subtle mode. This demo connects the + service-agnostic predict() API to Codestral built + by Mistral AI. Switch between File and{' '} + FileDiff. + + } + /> + +
+
; + prerenderedDiff: PreloadFileDiffResult; +} + +type Surface = 'file' | 'diff'; +type PredictionMode = 'eager' | 'subtle'; +type PredictionStatus = + | 'idle' + | 'waiting' + | 'predicting' + | 'ready' + | 'empty' + | 'error'; + +const INCLUDE = ['**/*.ts'] as const; +const EXCLUDE = ['**/*.test.ts'] as const; +const statusTextMap = { + idle: 'Idle', + waiting: 'Waiting...', + predicting: 'Predicting...', + empty: 'No suggestion returned. Keep editing to try again.', + error: 'Prediction unavailable. Check the demo service and try again.', +}; +const readyStatusText = { + eager: ( + <> + Prediction ready — press Tab to accept. + + ), + subtle: ( + <> + Prediction ready — hold Alt and press Tab to accept. + + ), +}; + +export function EditPredictionDemo({ + prerenderedFile, + prerenderedDiff, +}: EditPredictionDemoProps) { + const editorRef = useRef | null>(null); + const predictionEnabledRef = useRef(false); + const [attached, setAttached] = useState(false); + const [authenticating, setAuthenticating] = useState(false); + const [hasEdits, setHasEdits] = useState(false); + const [mode, setMode] = useState('eager'); + const [predictionEnabled, setPredictionEnabled] = useState(false); + const [resetKey, setResetKey] = useState(0); + const [status, setStatus] = useState('idle'); + const [surface, setSurface] = useState('file'); + + const provider = useMemo( + () => ({ + async predict(request, { signal }) { + if (!predictionEnabledRef.current) { + const prefix = request.excerptText.slice( + 0, + request.cursorOffsetInExcerpt + ); + const lines = prefix.split(request.eol); + return { + edits: [], + newCursor: { + line: request.excerptStartLine + lines.length - 1, + character: lines.at(-1)?.length ?? 0, + }, + }; + } + + setStatus('predicting'); + try { + const response = await fetch('/edit/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + if (response.status === 401) { + window.location.assign('/edit/auth'); + throw new Error('GitHub sign-in required'); + } + if (!response.ok) { + throw new Error('Edit prediction request failed'); + } + const prediction = (await response.json()) as EditPredictResponse; + if (!signal.aborted) { + setStatus(prediction.edits.length === 0 ? 'empty' : 'ready'); + } + return prediction; + } catch (error) { + if (!signal.aborted) { + setStatus('error'); + } + throw error; + } + }, + }), + [] + ); + + const editPrediction = useMemo( + () => ({ provider, mode, include: INCLUDE, exclude: EXCLUDE }), + [mode, provider] + ); + const editorOptions = useMemo>( + () => ({ + editPrediction, + onAttach(editor) { + editorRef.current = editor; + setAttached(true); + }, + onChange(file) { + setHasEdits(file.contents !== EDIT_PREDICTION_NEW_FILE.contents); + if (predictionEnabledRef.current) { + setStatus('waiting'); + } + }, + }), + [editPrediction] + ); + + const pristineFileDiff = useMemo( + () => cloneFileDiffMetadata(prerenderedDiff.fileDiff), + [prerenderedDiff.fileDiff] + ); + const liveFileDiff = useMemo( + () => ({ + ...cloneFileDiffMetadata(pristineFileDiff), + cacheKey: `${pristineFileDiff.name}-${String(resetKey)}`, + }), + [pristineFileDiff, resetKey] + ); + + const reset = useCallback(() => { + predictionEnabledRef.current = false; + editorRef.current = null; + setAttached(false); + setHasEdits(false); + setPredictionEnabled(false); + setStatus('idle'); + setResetKey((key) => key + 1); + }, []); + + const placeCursor = useCallback(() => { + const editor = editorRef.current; + if (editor == null) { + return; + } + const anchor = 'return items.'; + const lines = editor.getText().split(/\r\n|\r|\n/); + const line = lines.findIndex((text) => text.includes(anchor)); + if (line < 0) { + return; + } + const character = lines[line].indexOf(anchor) + anchor.length; + predictionEnabledRef.current = true; + setPredictionEnabled(true); + setStatus('waiting'); + editor.setOptions({ editPrediction: { ...editPrediction } }); + editor.setSelections([ + { + start: { line, character }, + end: { line, character }, + direction: 'none', + }, + ]); + editor.focus({ preventScroll: true }); + }, [editPrediction]); + + const tryCodestral = useCallback(async () => { + setAuthenticating(true); + try { + const response = await fetch('/edit/auth', { + method: 'HEAD', + cache: 'no-store', + }); + if (response.status === 401) { + window.location.assign('/edit/auth'); + return; + } + if (!response.ok) { + setStatus('error'); + return; + } + placeCursor(); + } catch { + setStatus('error'); + } finally { + setAuthenticating(false); + } + }, [placeCursor]); + + const handleModeChange = useCallback( + (value: PredictionMode) => { + setMode(value); + editorRef.current?.setOptions({ + editPrediction: { ...editPrediction, mode: value }, + }); + if (predictionEnabledRef.current) { + setStatus('waiting'); + } + }, + [editPrediction] + ); + + const handleSurfaceChange = useCallback( + (value: Surface) => { + setSurface(value); + reset(); + }, + [reset] + ); + + const statusText = authenticating + ? 'Checking GitHub sign-in…' + : status === 'error' + ? statusTextMap.error + : !predictionEnabled + ? null + : status === 'ready' + ? readyStatusText[mode] + : statusTextMap[status]; + + return ( +
+
+ handleSurfaceChange(value as Surface)} + aria-label="Surface" + > + File + FileDiff + + + handleModeChange(value as PredictionMode)} + aria-label="Prediction mode" + > + Eager + Subtle + + + + +
+ {statusText !== null && ( + + {statusText} + + )} + {!predictionEnabled && ( + + )} +
+
+ + {surface === 'file' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index ebf2805ad..83aa3d878 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -1,15 +1,63 @@ -import { DEFAULT_THEMES, type FileContents } from '@pierre/diffs'; +import { + DEFAULT_THEMES, + type FileContents, + parseDiffFromFile, +} from '@pierre/diffs'; import type { EditorCommand, EditorKeymap } from '@pierre/diffs/edit'; import type { FileOptions } from '@pierre/diffs/react'; -import type { PreloadFileOptions } from '@pierre/diffs/ssr'; +import type { + PreloadFileDiffOptions, + 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. // Mirrors LiveEditing/constants.ts. -const EDITABLE_FILE_OPTIONS: FileOptions = { +const EDITABLE_OPTIONS = { theme: DEFAULT_THEMES, themeType: 'dark', useTokenTransformer: true, +} as const; +const EDITABLE_FILE_OPTIONS: FileOptions = EDITABLE_OPTIONS; + +const EDIT_PREDICTION_OLD_FILE: FileContents = { + name: 'cart.ts', + contents: `// cart calculator + +export type CartItem = { + id: string + name: string + price: number + quantity: number +} + +export function cartTotal(items: CartItem[]): number { + let total = 0 + + for (const item of items) { + total += item.price * item.quantity + } + + return total +} +`, +}; + +export const EDIT_PREDICTION_NEW_FILE: FileContents = { + name: 'cart.ts', + contents: `// cart calculator + +export type CartItem = { + id: string + name: string + price: number + quantity: number +} + +export function cartTotal(items: CartItem[]): number { + return items. +} +`, }; // Lint-marker demo source. Marker positions below are tied to these exact @@ -328,6 +376,23 @@ const DEFAULT_KEYMAP_FILE: FileContents = { // Server-side preload inputs. Spreading the resolved results into ships // pre-rendered, already-highlighted shadow DOM so each demo paints instantly // instead of flashing in after client highlighting. +export const EDIT_PREDICTION_FILE_EXAMPLE: PreloadFileOptions = { + file: EDIT_PREDICTION_NEW_FILE, + options: EDITABLE_FILE_OPTIONS, +}; + +export const EDIT_PREDICTION_FILE_DIFF_EXAMPLE: PreloadFileDiffOptions = + { + fileDiff: parseDiffFromFile( + EDIT_PREDICTION_OLD_FILE, + EDIT_PREDICTION_NEW_FILE + ), + options: { + ...EDITABLE_OPTIONS, + diffStyle: 'unified', + }, + }; + export const MARKER_DEMO_FILE_EXAMPLE: PreloadFileOptions = { file: MARKER_DEMO_FILE, options: EDITABLE_FILE_OPTIONS, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index a2f38c549..2be5d9ab3 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -344,6 +344,44 @@ button.addEventListener('click', () => { options, }; +export const EDIT_PREDICTION_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_edit_prediction.ts', + contents: `import type { + EditorOptions, + EditPredictProvider, + EditPredictResponse, +} from '@pierre/diffs/edit'; + +const provider: EditPredictProvider = { + async predict(request, { signal }) { + // This server endpoint belongs to your application. + const response = await fetch('/edit/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + throw new Error('Edit prediction failed'); + } + return (await response.json()) as EditPredictResponse; + }, +}; + +export const editorOptions = { + editPrediction: { + provider, + mode: 'eager', + include: ['**/*.ts', '**/*.tsx'], + exclude: ['**/*.test.ts', '**/generated/**'], + }, +} satisfies EditorOptions;`, + }, + options, +}; + export const EDIT_SELECTION_ACTION_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_selection_action.ts', @@ -943,6 +981,7 @@ export const EDITOR_OPTIONS_TYPE: PreloadFileOptions = { } from '@pierre/diffs'; import { Editor, + type EditPredictProvider, type EditorKeymap, type IStateStorage, } from '@pierre/diffs/edit'; @@ -979,6 +1018,18 @@ interface EditorOptions { // Programmatic setSelections/setState calls do not open it (default: false). enabledSelectionAction?: boolean; + // Inline edit prediction. The app supplies the model/service implementation. + editPrediction?: { + // 'eager' displays predictions immediately; 'subtle' reveals them on Alt. + // Default: 'eager'. + mode?: 'eager' | 'subtle'; + provider: EditPredictProvider; + // String globs or regular expressions matched against the file path. + include?: readonly (string | RegExp)[]; + // Exclusions take precedence over inclusions. + exclude?: readonly (string | RegExp)[]; + }; + // Custom clipboard provider. // Highly recommended to use native clipboard API if you are building an electron app. // see https://www.electronjs.org/docs/latest/api/clipboard @@ -1018,6 +1069,125 @@ interface EditorOptions { options, }; +export const EDIT_PERSIST_STATE_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_persist_state.ts', + contents: `import type { EditorState, FileContents } from '@pierre/diffs'; +import { Editor, type IStateStorage } from '@pierre/diffs/edit'; + +const editor = new Editor({ + persistState: true, + persistStateStorage: 'inMemory', +}); + +const file: FileContents = { + name: 'src/example.ts', + contents: 'export const value = 1;', + cacheKey: 'workspace-file-1', +}; + +// Custom storage may be synchronous or asynchronous. +const states = new Map(); +const customStorage: IStateStorage = { + get(cacheKey) { + return states.get(cacheKey); + }, + set(cacheKey, state) { + states.set(cacheKey, state); + }, +}; + +const editorWithCustomStorage = new Editor({ + persistState: true, + persistStateStorage: customStorage, +});`, + }, + options, +}; + +export const EDIT_REACT_PROVIDER_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_react_provider.tsx', + contents: `import { useCallback, useMemo } from 'react'; +import { Editor, type EditorOptions } from '@pierre/diffs/edit'; +import { + EditProvider, + File, + type CreateEditor, +} from '@pierre/diffs/react'; + +const createEditor = useCallback>( + (surfaceOptions) => + new Editor({ + ...sharedEditorDefaults, + ...surfaceOptions, + }), + [] +); + +const editorOptions = useMemo>( + () => ({ + onChange: handleChange, + onAttach(editor) { + editorRef.current = editor; + }, + }), + [handleChange] +); + +// This example is self-contained. Apps should usually mount EditProvider near +// the root so its factory is available to every editable File, diff, and +// CodeView. +return ( + + + +);`, + }, + options, +}; + +export const EDIT_AUTOFOCUS_REACT_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_autofocus_react.tsx', + contents: `import { useMemo } from 'react'; +import type { EditorOptions } from '@pierre/diffs/edit'; +import { CodeView } from '@pierre/diffs/react'; + +const editorOptions = useMemo>( + () => ({ + onAttach(editor) { + editor.focus({ lineNumber: 'first-visible', preventScroll: true }); + }, + }), + [] +); + +return ;`, + }, + options, +}; + +export const EDIT_AUTOFOCUS_VANILLA_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_autofocus_vanilla.ts', + contents: `import { CodeView } from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/edit'; + +const viewer = new CodeView({ + createEditor(options) { + return new Editor({ + ...options, + onAttach(editor) { + editor.focus({ lineNumber: 'first-visible', preventScroll: true }); + }, + }); + }, +});`, + }, + options, +}; + export const EDIT_ON_CHANGE_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_on_change.ts', @@ -1039,6 +1209,14 @@ new Editor({ options, }; +export const EDIT_FOCUS_POSITION_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_focus_position.ts', + contents: `editor.focus({ lineNumber: 13, character: 4 });`, + }, + options, +}; + export const EDITOR_PUBLIC_API: PreloadFileOptions = { file: { name: 'editor_public_api.ts', diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index b1f71bcc7..07f130ef6 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -16,6 +16,7 @@ Edit mode features include: - Automatic indentation - History (undo and redo) - Find-in-file search and replace +- [Edit Prediction](#edit-mode-edit-prediction) (opt-in, custom provider) - [Selection Action](#edit-mode-selection-action) (opt-in, custom UI) - SSR support - Mobile-friendly @@ -35,8 +36,6 @@ highlighting, SSR, and virtualization while adding editing, multiple selections, history, search and replace, and markers. This makes it most ideal for “review and correct” flows with generative code changes. -### Demo - To enable edit mode on a `File` instance, import `Editor` from `@pierre/diffs/edit`. It is intentionally separate from the core bundle so you can lazy-load it when editing is optional. @@ -76,80 +75,6 @@ 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. -### Editing with line annotations - -Applications own the annotation collection passed to a `File`, `FileDiff`, or -other editable surface. When an edit affects annotation coordinates, the editor -remaps them and passes the complete current collection as `onChange`'s second -argument alongside the edited file. This collection is authoritative when -present; it is not a delta. Replace your application-owned collection with it -before a later render can reapply stale coordinates. - -The editor preserves the existing array reference for ordinary same-line edits -and other edits that do not affect any annotation. When a structural edit -touches, moves, or removes an annotation, it returns a new array. Check that -identity before publishing state so ordinary typing does not cause unnecessary -annotation renders. - -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. -- Deleting only an annotated line's text leaves an annotated blank line. To - remove a non-final line and its annotation, delete from its start through the - start of the next line, consuming its trailing line break. To remove a final - line that has a preceding line, delete from the end of the preceding line - through EOF, consuming the preceding line break. A one-line document always - retains one blank line. -- 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 use the same `onChange` path, restoring or removing 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 shared `onChange` callback accepts 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. - -In React, synchronously publish a changed annotation array with `flushSync` so -the annotation placement updates with the edited content before paint. Do not -wrap every `onChange` update in `flushSync`: bail out when the emitted array is -the same object. - -}> - React may recreate annotation content when annotations are removed, restored, - or reordered, or when a `CodeView` item leaves the virtualized window. - `flushSync` keeps the placement update visually atomic, but it does not - preserve component-local state. 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. - - -In vanilla JS, replace the annotation collection in your external variable or -store before updating the surface. When the emitted array changes, schedule the -same render function used for the initial surface after `onChange` returns, -passing the edited file and replacement annotations. For `CodeView`, pass the -collection to `updateItem` with a `version` increment. In every case, updating -the source of truth prevents a later render from restoring stale line numbers. - -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. - ### React Give a permanently mounted `EditProvider` a stable `createEditor` factory, then @@ -166,35 +91,7 @@ values at module scope, or use `useCallback` for factories and callbacks and `useMemo` for `options` and `editorOptions` when they depend on component values. -```tsx -const createEditor = useCallback>( - (surfaceOptions) => - new Editor({ - ...sharedEditorDefaults, - ...surfaceOptions, - }), - [] -); - -const editorOptions = useMemo>( - () => ({ - onChange: handleChange, - onAttach(editor) { - editorRef.current = editor; - }, - }), - [handleChange] -); - -// This example is self-contained. Apps should usually mount EditProvider near -// the root so its factory is available to every editable File, diff, and -// CodeView. -return ( - - - -); -``` + Changes to `createEditor` or `editorOptions` do not disturb an active edit session. Their latest values apply the next time `edit` transitions from false @@ -223,94 +120,6 @@ removed. The `FileDiff` tab below includes this synchronization. fileDiffExample={editVanillaFileDiffExample} /> -### 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 instead of `editorOptions.onChange`. -`onItemEditChange` receives the owning item with each live change: replace that -item's annotations whenever the emitted array changes, and increment its -`version`. This live update keeps annotation coordinates synchronized throughout -the edit session. `onItemEditComplete` receives the item and latest contents -when a changed session ends because editing is disabled or the item is collapsed -or removed. Sessions with no changes do not emit it. Resetting, cleaning up, or -unmounting the viewer is silent teardown. - -Persist edited contents to the item's `file`, or rebuild its `fileDiff`. Assign -a fresh `cacheKey` and increment `version`; `CodeView` does not update item data -for you. - - - -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. - -### Autofocus on Attach - -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`: - -```tsx -const editorOptions = useMemo>( - () => ({ - onAttach(editor) { - editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - }, - }), - [] -); - -return ; -``` - -Vanilla `CodeView` can add the same behavior in its editor factory: - -```ts -const viewer = new CodeView({ - createEditor(options) { - return new Editor({ - ...options, - onAttach(editor) { - editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - }, - }); - }, -}); -``` - -`'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. - -You can also target a specific document position: - -```ts -editor.focus({ lineNumber: 13, character: 4 }); -``` - -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. - ### Editor Options Pass these when constructing `new Editor({ ... })`, or update them later with @@ -348,37 +157,7 @@ within the persistence storage namespace. Reuse the same key when a rename or move should resume the same editing session; change it when new incoming contents should start a fresh document. -```ts -import type { EditorState, FileContents } from '@pierre/diffs'; -import { Editor, type IStateStorage } from '@pierre/diffs/edit'; - -const editor = new Editor({ - persistState: true, - persistStateStorage: 'inMemory', -}); - -const file: FileContents = { - name: 'src/example.ts', - contents: 'export const value = 1;', - cacheKey: 'workspace-file-1', -}; - -// Custom storage may be synchronous or asynchronous. -const states = new Map(); -const customStorage: IStateStorage = { - get(cacheKey) { - return states.get(cacheKey); - }, - set(cacheKey, state) { - states.set(cacheKey, state); - }, -}; - -const editorWithCustomStorage = new Editor({ - persistState: true, - persistStateStorage: customStorage, -}); -``` + `persistStateStorage` accepts `'inMemory'`, `'indexedDB'`, or an `IStateStorage` implementation. It defaults to `'inMemory'`. Text documents and undo history @@ -434,6 +213,220 @@ Limit the stack size with `historyMaxEntries` in +### 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. + +Edit prediction also uses Tab (eager) or + +Alt+Tab (subtle) to accept a visible suggestion; otherwise +Tab indents as usual. See [Edit Prediction](#edit-mode-edit-prediction). + + + +### Autofocus on Attach + +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`: + + + +Vanilla `CodeView` can add the same behavior in its editor factory: + + + +`'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. + +You can also target a specific document position: + + + +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. + +### 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 instead of `editorOptions.onChange`. +`onItemEditChange` receives the owning item with each live change: replace that +item's annotations whenever the emitted array changes, and increment its +`version`. This live update keeps annotation coordinates synchronized throughout +the edit session. `onItemEditComplete` receives the item and latest contents +when a changed session ends because editing is disabled or the item is collapsed +or removed. Sessions with no changes do not emit it. Resetting, cleaning up, or +unmounting the viewer is silent teardown. + +Persist edited contents to the item's `file`, or rebuild its `fileDiff`. Assign +a fresh `cacheKey` and increment `version`; `CodeView` does not update item data +for you. + + + +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. + +### Editing with line annotations + +Applications own the annotation collection passed to a `File`, `FileDiff`, or +other editable surface. When an edit affects annotation coordinates, the editor +remaps them and passes the complete current collection as `onChange`'s second +argument alongside the edited file. This collection is authoritative when +present; it is not a delta. Replace your application-owned collection with it +before a later render can reapply stale coordinates. + +The editor preserves the existing array reference for ordinary same-line edits +and other edits that do not affect any annotation. When a structural edit +touches, moves, or removes an annotation, it returns a new array. Check that +identity before publishing state so ordinary typing does not cause unnecessary +annotation renders. + +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. +- Deleting only an annotated line's text leaves an annotated blank line. To + remove a non-final line and its annotation, delete from its start through the + start of the next line, consuming its trailing line break. To remove a final + line that has a preceding line, delete from the end of the preceding line + through EOF, consuming the preceding line break. A one-line document always + retains one blank line. +- 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 use the same `onChange` path, restoring or removing 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 shared `onChange` callback accepts 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. + +In React, synchronously publish a changed annotation array with `flushSync` so +the annotation placement updates with the edited content before paint. Do not +wrap every `onChange` update in `flushSync`: bail out when the emitted array is +the same object. + +}> + React may recreate annotation content when annotations are removed, restored, + or reordered, or when a `CodeView` item leaves the virtualized window. + `flushSync` keeps the placement update visually atomic, but it does not + preserve component-local state. 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. + + +In vanilla JS, replace the annotation collection in your external variable or +store before updating the surface. When the emitted array changes, schedule the +same render function used for the initial surface after `onChange` returns, +passing the edited file and replacement annotations. For `CodeView`, pass the +collection to `updateItem` with a `version` increment. In every case, updating +the source of truth prevents a later render from restoring stale line numbers. + +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. + +### Edit Prediction + +Edit prediction is opt-in and model agnostic. Provide a `predict()` function +through `editorOptions.editPrediction`; the editor builds a bounded request, +validates the response, renders it as ghost text, and applies it when the user +presses Tab. Your application owns the prediction logic and may call +any local or remote service. Keep model credentials in that service rather than +shipping them to the browser. + +The same options work with editable `File` and `FileDiff` surfaces, including +their virtualized variants. In React, pass them through `editorOptions`. In +vanilla JS, pass them to `new Editor(editorOptions)`. + + + +[Try the live demo](/edit#tab-tab-tab). + +`predict()` runs 300 ms after typing or moving to a single caret. Document or +cursor changes clear the current prediction, cancel the debounce, and abort +in-flight work via `context.signal`—pass that to `fetch()` or any cancellable +call. Stale responses are ignored. + +`mode` defaults to `'eager'` (show ghost text immediately). `'subtle'` waits for + +Alt before revealing it. Tab accepts a visible prediction +and moves the caret to `newCursor`; otherwise Tab indents as usual. Multiline +predictions render as numberless ghost rows so real line numbers stay intact. + +Filter paths with `include` and `exclude` (strings or `RegExp`). Strings match +the full slash-normalized path and support `?`, `*`, and `**`. Omit `include` to +allow every path; use `include: []` to disable all. Exclusions win. + +`EditPredictRequest` includes `path`, `version`, `eol`, a bounded `excerptText`, +and chronological `editHistory`. `excerptStartLine` is zero-based; +`cursorOffsetInExcerpt` and `editableRange` are UTF-16 offsets into the excerpt. +History entries have a unified `diff` and a `source` of `'user'` or +`'prediction'`. + +Return an `EditPredictResponse` with non-overlapping `edits` in absolute, +zero-based document positions inside the editable window. `newCursor` is +absolute in the post-edit document. Collapsed range = insert, empty `newText` = +delete, both = replace. Return `edits: []` with a valid `newCursor` when there +is no suggestion. + +### Selection Action + +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. + + + +`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: + + + ### Using Worker Pool Edit mode works with a worker pool out of the box: a file being edited renders @@ -465,30 +458,3 @@ smaller and can improve LCP (Largest Contentful Paint) on pages where editing is rare. - -### Selection Action - -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. - - - -`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: - - - -### 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. - - diff --git a/apps/docs/app/(diffs)/edit/_auth/github.ts b/apps/docs/app/(diffs)/edit/_auth/github.ts new file mode 100644 index 000000000..217f45715 --- /dev/null +++ b/apps/docs/app/(diffs)/edit/_auth/github.ts @@ -0,0 +1,104 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const GITHUB_AUTH_FALLBACK = '/edit#tab-tab-tab'; +export const GITHUB_OAUTH_STATE_COOKIE = 'pierre_github_oauth_state'; + +const GITHUB_SESSION_COOKIE = 'pierre_github_session'; +const SESSION_MAX_AGE = 60 * 60 * 24 * 7; + +export function getGithubOAuthConfig(): + | { clientId: string; clientSecret: string } + | undefined { + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID?.trim(); + const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) { + return undefined; + } + return { clientId, clientSecret }; +} + +export function getAuthCookie( + request: Request, + name: string +): string | undefined { + for (const cookie of request.headers.get('cookie')?.split(';') ?? []) { + const [cookieName, ...value] = cookie.trim().split('='); + if (cookieName === name) { + try { + return decodeURIComponent(value.join('=')); + } catch { + return undefined; + } + } + } + return undefined; +} + +export function serializeAuthCookie( + request: Request, + name: string, + value: string, + maxAge: number, + path: string +): string { + return `${name}=${encodeURIComponent(value)}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${String(maxAge)}${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`; +} + +export function authValuesMatch(value: string, expected: string): boolean { + const valueBytes = Buffer.from(value); + const expectedBytes = Buffer.from(expected); + return ( + valueBytes.length === expectedBytes.length && + timingSafeEqual(valueBytes, expectedBytes) + ); +} + +export function createGithubSessionCookie( + request: Request, + userId: number +): string | undefined { + const config = getGithubOAuthConfig(); + if (config === undefined) { + return undefined; + } + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE; + const payload = `${String(userId)}.${String(expiresAt)}`; + const signature = createHmac('sha256', config.clientSecret) + .update(payload) + .digest('base64url'); + return serializeAuthCookie( + request, + GITHUB_SESSION_COOKIE, + `${payload}.${signature}`, + SESSION_MAX_AGE, + '/edit' + ); +} + +export function getAuthenticatedGithubUserId( + request: Request +): string | undefined { + const config = getGithubOAuthConfig(); + const session = getAuthCookie(request, GITHUB_SESSION_COOKIE); + if (config === undefined || session === undefined) { + return; + } + const [userId, expiresAt, signature, ...extra] = session.split('.'); + if ( + extra.length > 0 || + !/^[1-9]\d*$/.test(userId ?? '') || + !/^\d+$/.test(expiresAt ?? '') || + signature === undefined || + Number(expiresAt) <= Math.floor(Date.now() / 1000) + ) { + return; + } + const expected = createHmac('sha256', config.clientSecret) + .update(`${userId}.${expiresAt}`) + .digest('base64url'); + return authValuesMatch(signature, expected) ? userId : undefined; +} + +export function isGithubAuthenticated(request: Request): boolean { + return getAuthenticatedGithubUserId(request) !== undefined; +} diff --git a/apps/docs/app/(diffs)/edit/auth/route.ts b/apps/docs/app/(diffs)/edit/auth/route.ts new file mode 100644 index 000000000..05c275a31 --- /dev/null +++ b/apps/docs/app/(diffs)/edit/auth/route.ts @@ -0,0 +1,244 @@ +import { randomBytes } from 'node:crypto'; + +import { + authValuesMatch, + createGithubSessionCookie, + getAuthCookie, + getGithubOAuthConfig, + GITHUB_AUTH_FALLBACK, + GITHUB_OAUTH_STATE_COOKIE, + isGithubAuthenticated, + serializeAuthCookie, +} from '../_auth/github'; + +const AUTH_PATH = '/edit/auth'; +const CACHE_CONTROL = 'no-store'; +const CALLBACK_URL = '/edit/auth?callback'; +const GITHUB_API_VERSION = '2026-03-10'; +const GITHUB_HEADERS = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Pierre-Diffs', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, +}; +const IS_DIFFS_SITE = (process.env.NEXT_PUBLIC_SITE ?? 'diffs') === 'diffs'; +const STATE_MAX_AGE = 60 * 10; +const TOKEN_REVOCATION_TIMEOUT_MS = 5_000; + +export const runtime = 'nodejs'; + +export function HEAD(request: Request): Response { + const headers = { 'Cache-Control': CACHE_CONTROL }; + if (!IS_DIFFS_SITE) { + return new Response(null, { status: 404, headers }); + } + if (getGithubOAuthConfig() === undefined) { + return new Response(null, { status: 503, headers }); + } + return new Response(null, { + status: isGithubAuthenticated(request) ? 204 : 401, + headers, + }); +} + +export async function GET(request: Request): Promise { + if (!IS_DIFFS_SITE) { + return new Response('Not found.', { status: 404 }); + } + + if (new URL(request.url).searchParams.has('callback')) { + return finishGithubOAuth(request); + } + + const config = getGithubOAuthConfig(); + if (config === undefined) { + return new Response('GitHub sign-in is not configured.', { status: 503 }); + } + + if (isGithubAuthenticated(request)) { + return Response.redirect(new URL(GITHUB_AUTH_FALLBACK, request.url), 302); + } + + const state = randomBytes(32).toString('base64url'); + const callbackUrl = new URL(CALLBACK_URL, request.url); + const authorizeUrl = new URL('https://github.com/login/oauth/authorize'); + authorizeUrl.searchParams.set('client_id', config.clientId); + authorizeUrl.searchParams.set('redirect_uri', callbackUrl.toString()); + authorizeUrl.searchParams.set('state', state); + + return new Response(null, { + status: 302, + headers: { + 'Cache-Control': CACHE_CONTROL, + Location: authorizeUrl.toString(), + 'Set-Cookie': serializeAuthCookie( + request, + GITHUB_OAUTH_STATE_COOKIE, + state, + STATE_MAX_AGE, + AUTH_PATH + ), + }, + }); +} + +async function finishGithubOAuth(request: Request): Promise { + const config = getGithubOAuthConfig(); + if (config === undefined) { + return authError(request, 'GitHub sign-in is not configured.', 503); + } + + const requestUrl = new URL(request.url); + const state = requestUrl.searchParams.get('state'); + const expectedState = getAuthCookie(request, GITHUB_OAUTH_STATE_COOKIE); + if ( + state === null || + expectedState === undefined || + !authValuesMatch(state, expectedState) + ) { + return authError(request, 'Invalid GitHub OAuth state.', 400); + } + + if (requestUrl.searchParams.has('error')) { + return authError(request, 'GitHub sign-in was cancelled.', 400); + } + + const code = requestUrl.searchParams.get('code'); + if (code === null || code.length === 0 || code.length > 1024) { + return authError( + request, + 'GitHub did not return an authorization code.', + 400 + ); + } + + const callbackUrl = new URL(CALLBACK_URL, request.url); + let tokenResponse: Response; + try { + tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: callbackUrl.toString(), + }), + signal: request.signal, + }); + } catch { + return authError(request, 'GitHub sign-in is unavailable.', 502); + } + + let tokenJSON: unknown; + try { + tokenJSON = await tokenResponse.json(); + } catch { + return authError(request, 'GitHub returned an invalid access token.', 502); + } + const accessToken = + tokenJSON !== null && typeof tokenJSON === 'object' + ? (tokenJSON as { access_token?: unknown }).access_token + : undefined; + if (!tokenResponse.ok || typeof accessToken !== 'string') { + return authError(request, 'GitHub rejected the authorization code.', 502); + } + + try { + let userResponse: Response; + try { + userResponse = await fetch('https://api.github.com/user', { + cache: 'no-store', + headers: { + ...GITHUB_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + signal: request.signal, + }); + } catch { + return authError(request, 'Could not validate the GitHub user.', 502); + } + + let userJSON: unknown; + try { + userJSON = await userResponse.json(); + } catch { + return authError(request, 'GitHub returned an invalid user.', 502); + } + const user = + userJSON !== null && typeof userJSON === 'object' + ? (userJSON as { id?: unknown }) + : undefined; + const userId = user?.id; + if ( + !userResponse.ok || + typeof userId !== 'number' || + !Number.isSafeInteger(userId) || + userId <= 0 + ) { + return authError(request, 'Could not validate the GitHub user.', 502); + } + + const sessionCookie = createGithubSessionCookie(request, userId); + if (sessionCookie === undefined) { + return authError(request, 'GitHub sign-in is not configured.', 503); + } + const headers = new Headers({ + 'Cache-Control': CACHE_CONTROL, + Location: new URL(GITHUB_AUTH_FALLBACK, request.url).toString(), + }); + headers.append( + 'Set-Cookie', + serializeAuthCookie(request, GITHUB_OAUTH_STATE_COOKIE, '', 0, AUTH_PATH) + ); + headers.append('Set-Cookie', sessionCookie); + return new Response(null, { status: 302, headers }); + } finally { + try { + const revokeResponse = await fetch( + `https://api.github.com/applications/${encodeURIComponent(config.clientId)}/token`, + { + method: 'DELETE', + cache: 'no-store', + headers: { + ...GITHUB_HEADERS, + Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ access_token: accessToken }), + signal: AbortSignal.timeout(TOKEN_REVOCATION_TIMEOUT_MS), + } + ); + if (!revokeResponse.ok) { + console.warn( + `GitHub OAuth token revocation failed with status ${String(revokeResponse.status)}.` + ); + } + } catch { + console.warn('GitHub OAuth token revocation failed.'); + } + } +} + +function authError( + request: Request, + message: string, + status: number +): Response { + return new Response(message, { + status, + headers: { + 'Cache-Control': CACHE_CONTROL, + 'Set-Cookie': serializeAuthCookie( + request, + GITHUB_OAUTH_STATE_COOKIE, + '', + 0, + AUTH_PATH + ), + }, + }); +} diff --git a/apps/docs/app/(diffs)/edit/page.tsx b/apps/docs/app/(diffs)/edit/page.tsx index 4f7ee4bad..591d7d42e 100644 --- a/apps/docs/app/(diffs)/edit/page.tsx +++ b/apps/docs/app/(diffs)/edit/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next'; import { DEFAULT_KEYMAP_FILE_EXAMPLE, + EDIT_PREDICTION_FILE_DIFF_EXAMPLE, + EDIT_PREDICTION_FILE_EXAMPLE, FIND_DEMO_FILE_EXAMPLE, HISTORY_DEMO_FILE_EXAMPLE, MARKER_DEMO_FILE_EXAMPLE, @@ -16,7 +18,7 @@ import { const editTitle = 'Pierre Diffs — now with edit'; const editDescription = - 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with selection management, multiple cursors, undo history, find/replace, and lint markers.'; + 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with inline edit predictions, selection management, multiple cursors, undo history, find/replace, and lint markers.'; export const metadata: Metadata = { title: editTitle, @@ -34,11 +36,14 @@ export const metadata: Metadata = { // 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 -// lint-marker, find-in-file, undo-history, shortcuts, and selection files. +// edit-prediction, lint-marker, find-in-file, undo-history, shortcuts, and +// selection surfaces. export default async function EditRoute() { const [ liveEditingFile, liveEditingDiff, + editPredictionFile, + editPredictionDiff, markerFile, findFile, historyFile, @@ -47,6 +52,8 @@ export default async function EditRoute() { ] = await Promise.all([ preloadFile(LIVE_EDITING_FILE_EXAMPLE), preloadFileDiff(LIVE_EDITING_FILE_DIFF_EXAMPLE), + preloadFile(EDIT_PREDICTION_FILE_EXAMPLE), + preloadFileDiff(EDIT_PREDICTION_FILE_DIFF_EXAMPLE), preloadFile(MARKER_DEMO_FILE_EXAMPLE), preloadFile(FIND_DEMO_FILE_EXAMPLE), preloadFile(HISTORY_DEMO_FILE_EXAMPLE), @@ -58,6 +65,8 @@ export default async function EditRoute() { !/[\r\n]/.test(path)), + version: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + eol: z.enum(['\n', '\r\n', '\r']), + excerptText: z.string().max(MAX_REQUEST_BYTES), + excerptStartLine: z.number().int().nonnegative().max(10_000_000), + cursorOffsetInExcerpt: z.number().int().nonnegative(), + editableRange: z + .object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }) + .strict(), + editHistory: z + .array( + z + .object({ + diff: z.string().min(1), + source: z.enum(['user', 'prediction']), + }) + .strict() + ) + .max(10), + }) + .strict(); + +export const runtime = 'nodejs'; + +export async function POST(request: Request): Promise { + if ( + process.env.NEXT_PUBLIC_SITE !== undefined && + process.env.NEXT_PUBLIC_SITE !== 'diffs' + ) { + return createErrorResponse('Not found.', 404); + } + + const githubUserId = getAuthenticatedGithubUserId(request); + if (githubUserId === undefined) { + return createErrorResponse('GitHub sign-in required.', 401); + } + + const apiKey = process.env.MISTRAL_API_KEY?.trim(); + if (apiKey === undefined || apiKey === '') { + return createErrorResponse('Edit prediction is not configured.', 503); + } + + if ( + request.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() !== 'application/json' + ) { + return createErrorResponse('Expected an application/json request.', 415); + } + + let requestText: string | undefined; + try { + requestText = await readTextWithinLimit(request.body, MAX_REQUEST_BYTES); + } catch { + return createErrorResponse('Could not read the request body.', 400); + } + if (requestText === undefined) { + return createErrorResponse('Request body is too large.', 413); + } + + let json: unknown; + try { + json = JSON.parse(requestText); + } catch { + return createErrorResponse('Request body must be valid JSON.', 400); + } + + const parsed = requestSchema.safeParse(json); + if (!parsed.success) { + return createErrorResponse('Invalid edit prediction request.', 400); + } + const input: EditPredictRequest = parsed.data; + const { cursorOffsetInExcerpt, editableRange, excerptText } = input; + if ( + editableRange.start > cursorOffsetInExcerpt || + cursorOffsetInExcerpt > editableRange.end || + editableRange.end > excerptText.length || + splitsTextUnit(excerptText, editableRange.start) || + splitsTextUnit(excerptText, cursorOffsetInExcerpt) || + splitsTextUnit(excerptText, editableRange.end) || + input.editHistory.some( + ({ diff }) => + textEncoder.encode(diff).byteLength > MAX_HISTORY_ENTRY_BYTES + ) + ) { + return createErrorResponse('Invalid edit prediction request.', 400); + } + + if (process.env.NODE_ENV !== 'development') { + try { + const { error, rateLimited } = await checkRateLimit( + EDIT_PREDICT_RATE_LIMIT_ID, + { + request, + rateLimitKey: githubUserId, + } + ); + if (rateLimited) { + return createErrorResponse('Edit prediction rate limit exceeded.', 429); + } + if (error !== undefined) { + return createErrorResponse( + 'Edit prediction rate limiter is unavailable.', + 503 + ); + } + } catch { + return createErrorResponse( + 'Edit prediction rate limiter is unavailable.', + 503 + ); + } + } + + const upstreamSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(MISTRAL_TIMEOUT_MS), + ]); + let upstream: Response; + try { + upstream = await fetch(CODESTRAL_FIM_URL, { + method: 'POST', + cache: 'no-store', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'codestral-latest', + prompt: excerptText.slice(0, cursorOffsetInExcerpt), + suffix: excerptText.slice(cursorOffsetInExcerpt), + max_tokens: 128, + temperature: 0, + stream: false, + }), + signal: upstreamSignal, + }); + } catch { + if (request.signal.aborted) { + return createErrorResponse('Edit prediction was cancelled.', 499); + } + return createErrorResponse( + upstreamSignal.aborted + ? 'Edit prediction service timed out.' + : 'Edit prediction service is unavailable.', + upstreamSignal.aborted ? 504 : 502 + ); + } + + if (!upstream.ok) { + return createErrorResponse( + upstream.status === 429 + ? 'Edit prediction rate limit exceeded.' + : 'Edit prediction service returned an error.', + upstream.status === 429 ? 429 : 502 + ); + } + + let upstreamText: string | undefined; + try { + upstreamText = await readTextWithinLimit(upstream.body, MAX_UPSTREAM_BYTES); + } catch { + if (request.signal.aborted) { + return createErrorResponse('Edit prediction was cancelled.', 499); + } + return createErrorResponse( + upstreamSignal.aborted + ? 'Edit prediction service timed out.' + : 'Invalid edit prediction response.', + upstreamSignal.aborted ? 504 : 502 + ); + } + if (upstreamText === undefined) { + return createErrorResponse('Edit prediction response is too large.', 502); + } + + let upstreamJSON: unknown; + try { + upstreamJSON = JSON.parse(upstreamText); + } catch { + return createErrorResponse('Invalid edit prediction response.', 502); + } + if ( + upstreamJSON === null || + typeof upstreamJSON !== 'object' || + !Array.isArray((upstreamJSON as { choices?: unknown }).choices) || + (upstreamJSON as { choices: unknown[] }).choices.length !== 1 + ) { + return createErrorResponse('Invalid edit prediction response.', 502); + } + + const choice = (upstreamJSON as { choices: unknown[] }).choices[0]; + if ( + choice === null || + typeof choice !== 'object' || + (choice as { finish_reason?: unknown }).finish_reason !== 'stop' + ) { + return createErrorResponse( + (choice as { finish_reason?: unknown } | null)?.finish_reason === 'length' + ? 'Edit prediction was truncated.' + : 'Invalid edit prediction response.', + 502 + ); + } + + const message = (choice as { message?: unknown }).message; + const completion = + message !== null && typeof message === 'object' + ? (message as { content?: unknown }).content + : undefined; + if ( + typeof completion !== 'string' || + textEncoder.encode(completion).byteLength > MAX_OUTPUT_BYTES + ) { + return createErrorResponse('Invalid edit prediction response.', 502); + } + + const newText = completion.replace(/\r\n|\r|\n/g, input.eol); + if (textEncoder.encode(newText).byteLength > MAX_OUTPUT_BYTES) { + return createErrorResponse('Edit prediction response is too large.', 502); + } + + const relativeCursor = positionAt(excerptText, cursorOffsetInExcerpt); + const cursor = { + line: input.excerptStartLine + relativeCursor.line, + character: relativeCursor.character, + }; + const insertedCursor = positionAt(newText, newText.length); + const response: EditPredictResponse = { + edits: + newText === '' + ? [] + : [ + { + range: { start: cursor, end: cursor }, + newText, + }, + ], + newCursor: + insertedCursor.line === 0 + ? { + line: cursor.line, + character: cursor.character + insertedCursor.character, + } + : { + line: cursor.line + insertedCursor.line, + character: insertedCursor.character, + }, + }; + return Response.json(response, { + headers: { 'Cache-Control': CACHE_CONTROL }, + }); +} + +async function readTextWithinLimit( + body: ReadableStream | null, + limit: number +): Promise { + if (body === null) { + return ''; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + return; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +function splitsTextUnit(text: string, offset: number): boolean { + const current = text.charCodeAt(offset); + const previous = text.charCodeAt(offset - 1); + return ( + (previous === 13 && current === 10) || + (previous >= 0xd800 && + previous <= 0xdbff && + current >= 0xdc00 && + current <= 0xdfff) + ); +} + +function positionAt( + text: string, + offset: number +): { line: number; character: number } { + let line = 0; + let lineStart = 0; + for (let index = 0; index < offset; index++) { + const character = text.charCodeAt(index); + if (character === 13 && text.charCodeAt(index + 1) === 10) { + index++; + } + if (character === 10 || character === 13) { + line++; + lineStart = index + 1; + } + } + return { line, character: offset - lineStart }; +} + +function createErrorResponse(error: string, status: number): Response { + return Response.json( + { error }, + { + status, + headers: { 'Cache-Control': CACHE_CONTROL }, + } + ); +} diff --git a/apps/docs/package.json b/apps/docs/package.json index e042732a8..3439fd015 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-tooltip": "catalog:", "@radix-ui/react-use-controllable-state": "catalog:", "@shikijs/transformers": "catalog:", + "@vercel/firewall": "catalog:", "@vscode/web-custom-data": "catalog:", "babel-plugin-react-compiler": "catalog:", "class-variance-authority": "catalog:", diff --git a/packages/diffs/src/editor/editPrediction.ts b/packages/diffs/src/editor/editPrediction.ts new file mode 100644 index 000000000..faa512db9 --- /dev/null +++ b/packages/diffs/src/editor/editPrediction.ts @@ -0,0 +1,737 @@ +import type { Position, ResolvedTextEdit, TextEdit } from '../types'; +import type { TextDocumentChangeTransaction } from './textDocumentChangeTransaction'; + +export interface EditPredictRequest { + /** Current file name/path as supplied to the File or FileDiff component. */ + readonly path: string; + /** Document version. */ + readonly version: number; + /** Document end-of-line sequence. */ + readonly eol: '\n' | '\r\n' | '\r'; + /** Bounded slice of the file around the cursor (not the whole file). */ + readonly excerptText: string; + /** Zero-based document line where the excerpt starts. */ + readonly excerptStartLine: number; + /** UTF-16 cursor offset relative to `excerptText`. */ + readonly cursorOffsetInExcerpt: number; + /** Half-open UTF-16 range within `excerptText` that may be edited. */ + readonly editableRange: { readonly start: number; readonly end: number }; + /** Chronological, bounded edit history for this document. */ + readonly editHistory: ReadonlyArray<{ + /** Edit in unified-diff format. */ + readonly diff: string; + /** Edit source. */ + readonly source: 'user' | 'prediction'; + }>; +} + +export interface EditPredictContext { + /** Aborted when the document or cursor changes. */ + readonly signal: AbortSignal; +} + +export interface EditPredictResponse { + /** Non-overlapping edits using absolute document positions. */ + readonly edits: readonly TextEdit[]; + /** Absolute post-edit cursor position. */ + readonly newCursor: Position; +} + +export interface EditPredictProvider { + /** Predicts the next edit for the given request. */ + predict: ( + request: EditPredictRequest, + context: EditPredictContext + ) => Promise; +} + +export interface EditPredictionHistoryRecord { + readonly path: string; + readonly hunk: string; + readonly start: number; + readonly end: number; + readonly at: number; + readonly source: 'user' | 'prediction'; + readonly fragment?: EditPredictionHistoryFragment; +} + +interface EditPredictionDocument { + readonly version: number; + readonly eol: '\n' | '\r\n' | '\r'; + readonly lineCount: number; + positionAt(offset: number): Position; + positionsAt(offsets: readonly number[]): Position[]; + offsetAt(position: Position): number; + getLineText(line: number): string; + getLineLength(line: number): number; + getTextSlice(start: number, end: number): string; + charAt(offset: number): string; +} + +interface EditPredictionHistoryFragment { + readonly baseText: string; + readonly currentText: string; + readonly currentStart: number; + readonly currentEnd: number; + readonly startLine: number; +} + +interface EditPredictionTransactionFragment { + readonly beforeText: string; + readonly afterText: string; + readonly startOffset: number; + readonly startLine: number; + readonly bounds: LineDiffBounds; +} + +interface LineDiffBounds { + readonly oldLineCount: number; + readonly newLineCount: number; + readonly prefixLines: number; + readonly suffixLines: number; +} + +const EDITABLE_TOKENS = 350; +const CONTEXT_TOKENS = 150; +const MAX_EDITABLE_TOKENS = 512; +const MAX_CONTEXT_TOKENS = 662; +const MAX_REQUEST_BYTES = 128 * 1024; +const MAX_HISTORY_ENTRIES = 10; +const MAX_CAPTURE_BYTES = 6144; +const COALESCE_MS = 1000; +const COALESCE_LINES = 8; +const DIFF_CONTEXT_LINES = 3; +const CAPTURE_CONTEXT_LINES = DIFF_CONTEXT_LINES + COALESCE_LINES; +const CAPTURE_CONTEXT_OPTIONS = [ + CAPTURE_CONTEXT_LINES, + DIFF_CONTEXT_LINES, +] as const; +const textEncoder = new TextEncoder(); + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) === 13 && text.charCodeAt(index + 1) === 10) { + index++; + } + if (text.charCodeAt(index) === 10 || text.charCodeAt(index) === 13) { + starts.push(index + 1); + } + } + return starts; +} + +function lineEnd( + text: string, + starts: readonly number[], + line: number +): number { + const next = starts[line + 1]; + return next === undefined + ? text.length + : next - + (text.charCodeAt(next - 1) === 10 && text.charCodeAt(next - 2) === 13 + ? 2 + : 1); +} + +function linesEqual( + left: string, + leftStarts: readonly number[], + leftLine: number, + right: string, + rightStarts: readonly number[], + rightLine: number +): boolean { + const leftStart = leftStarts[leftLine]; + const rightStart = rightStarts[rightLine]; + const length = lineEnd(left, leftStarts, leftLine) - leftStart; + if (length !== lineEnd(right, rightStarts, rightLine) - rightStart) { + return false; + } + for (let index = 0; index < length; index++) { + if ( + left.charCodeAt(leftStart + index) !== + right.charCodeAt(rightStart + index) + ) { + return false; + } + } + return true; +} + +function lineDiffBounds( + oldText: string, + oldStarts: readonly number[], + newText: string, + newStarts: readonly number[] +): LineDiffBounds { + const oldLineCount = oldText.length === 0 ? 0 : oldStarts.length; + const newLineCount = newText.length === 0 ? 0 : newStarts.length; + let prefixLines = 0; + while (prefixLines < oldLineCount && prefixLines < newLineCount) { + if ( + !linesEqual( + oldText, + oldStarts, + prefixLines, + newText, + newStarts, + prefixLines + ) + ) { + break; + } + prefixLines++; + } + + let suffixLines = 0; + while ( + suffixLines < oldLineCount - prefixLines && + suffixLines < newLineCount - prefixLines + ) { + const oldLine = oldLineCount - 1 - suffixLines; + const newLine = newLineCount - 1 - suffixLines; + if (!linesEqual(oldText, oldStarts, oldLine, newText, newStarts, newLine)) { + break; + } + suffixLines++; + } + return { oldLineCount, newLineCount, prefixLines, suffixLines }; +} + +function formatEditHunk( + path: string, + oldText: string, + newText: string, + lineOffset = 0 +): { readonly hunk: string; readonly bounds: LineDiffBounds } | undefined { + if (oldText === newText) { + return; + } + const oldStarts = lineStarts(oldText); + const newStarts = lineStarts(newText); + const bounds = lineDiffBounds(oldText, oldStarts, newText, newStarts); + if ( + bounds.prefixLines === bounds.oldLineCount && + bounds.prefixLines === bounds.newLineCount + ) { + return; + } + const oldChangedEnd = bounds.oldLineCount - bounds.suffixLines; + const newChangedEnd = bounds.newLineCount - bounds.suffixLines; + const oldChanged = oldText.slice( + oldStarts[bounds.prefixLines] ?? oldText.length, + oldStarts[oldChangedEnd] ?? oldText.length + ); + const newChanged = newText.slice( + newStarts[bounds.prefixLines] ?? newText.length, + newStarts[newChangedEnd] ?? newText.length + ); + if ( + textEncoder.encode(oldChanged).byteLength > MAX_CAPTURE_BYTES || + textEncoder.encode(newChanged).byteLength > MAX_CAPTURE_BYTES + ) { + return; + } + + const start = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); + const oldEnd = Math.min( + bounds.oldLineCount, + oldChangedEnd + DIFF_CONTEXT_LINES + ); + const newEnd = Math.min( + bounds.newLineCount, + newChangedEnd + DIFF_CONTEXT_LINES + ); + const oldCount = oldEnd - start; + const newCount = newEnd - start; + const line = start + lineOffset; + const output = [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -${oldCount === 0 ? line : line + 1},${oldCount} +${ + newCount === 0 ? line : line + 1 + },${newCount} @@`, + ]; + for (let line = start; line < bounds.prefixLines; line++) { + output.push( + ` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + for (let line = bounds.prefixLines; line < oldChangedEnd; line++) { + output.push( + `-${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + for (let line = bounds.prefixLines; line < newChangedEnd; line++) { + output.push( + `+${newText.slice(newStarts[line], lineEnd(newText, newStarts, line))}` + ); + } + for (let line = oldChangedEnd; line < oldEnd; line++) { + output.push( + ` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + const hunk = output.join('\n'); + return textEncoder.encode(hunk).byteLength <= MAX_CAPTURE_BYTES + ? { hunk, bounds } + : undefined; +} + +// Applies offset edits to a bounded document slice without materializing the +// surrounding file. Returns undefined if the slice does not contain the edits. +function applyEditsToSlice( + text: string, + sliceStart: number, + edits: readonly ResolvedTextEdit[] +): string | undefined { + const chunks: string[] = []; + let offset = 0; + for (const edit of edits) { + const start = edit.start - sliceStart; + const end = edit.end - sliceStart; + if (start < offset || end < start || end > text.length) { + return; + } + chunks.push(text.slice(offset, start), edit.text); + offset = end; + } + chunks.push(text.slice(offset)); + return chunks.join(''); +} + +// Captures a small post-edit window and reconstructs its pre-edit contents +// from the inverse edits already stored by the undo transaction. +function captureEditPredictionTransaction( + document: EditPredictionDocument, + transaction: TextDocumentChangeTransaction +): EditPredictionTransactionFragment | undefined { + const inverseEdits = transaction.inverseEdits; + if (inverseEdits.length === 0) { + return; + } + let changedStart = inverseEdits[0].start; + let changedEnd = inverseEdits[0].end; + for (let index = 1; index < inverseEdits.length; index++) { + changedStart = Math.min(changedStart, inverseEdits[index].start); + changedEnd = Math.max(changedEnd, inverseEdits[index].end); + } + const [startPosition, endPosition] = document.positionsAt([ + changedStart, + changedEnd, + ]); + for (const contextLines of CAPTURE_CONTEXT_OPTIONS) { + const startLine = Math.max(0, startPosition.line - contextLines); + const endLine = Math.min( + document.lineCount - 1, + endPosition.line + contextLines + ); + const afterStart = document.offsetAt({ + line: startLine, + character: 0, + }); + const afterEnd = + endLine + 1 < document.lineCount + ? document.offsetAt({ line: endLine + 1, character: 0 }) + : document.offsetAt({ + line: endLine, + character: document.getLineLength(endLine), + }); + if (afterEnd - afterStart > MAX_CAPTURE_BYTES) { + continue; + } + const afterText = document.getTextSlice(afterStart, afterEnd); + if (textEncoder.encode(afterText).byteLength > MAX_CAPTURE_BYTES) { + continue; + } + const beforeText = applyEditsToSlice(afterText, afterStart, inverseEdits); + if ( + beforeText === undefined || + beforeText.length > MAX_CAPTURE_BYTES || + textEncoder.encode(beforeText).byteLength > MAX_CAPTURE_BYTES + ) { + continue; + } + const bounds = lineDiffBounds( + beforeText, + lineStarts(beforeText), + afterText, + lineStarts(afterText) + ); + return { + beforeText, + afterText, + startOffset: afterStart, + startLine, + bounds, + }; + } + return undefined; +} + +export function recordEditPrediction( + history: readonly EditPredictionHistoryRecord[], + path: string, + document: EditPredictionDocument, + transaction: TextDocumentChangeTransaction, + source: 'user' | 'prediction', + at: number = Date.now() +): EditPredictionHistoryRecord[] { + const kept = history.slice(-MAX_HISTORY_ENTRIES); + const fragment = captureEditPredictionTransaction(document, transaction); + if (fragment === undefined) { + const previous = kept.at(-1); + if (previous?.fragment !== undefined) { + kept[kept.length - 1] = { ...previous, fragment: undefined }; + } + return kept; + } + if (fragment.beforeText === fragment.afterText) { + return kept; + } + const changedStartLine = fragment.startLine + fragment.bounds.prefixLines; + const beforeChangedEndLine = + fragment.startLine + + fragment.bounds.oldLineCount - + fragment.bounds.suffixLines; + const last = kept.at(-1); + const gap = + last !== undefined && changedStartLine > last.end + ? changedStartLine - last.end + : last !== undefined && last.start > beforeChangedEndLine + ? last.start - beforeChangedEndLine + : 0; + const canMerge = + last !== undefined && + last.fragment !== undefined && + last.path === path && + last.source === source && + at - last.at < COALESCE_MS && + gap <= COALESCE_LINES; + + if (canMerge) { + const previous = last.fragment; + const beforeEnd = fragment.startOffset + fragment.beforeText.length; + const overlapStart = Math.max(previous.currentStart, fragment.startOffset); + const overlapEnd = Math.min(previous.currentEnd, beforeEnd); + if ( + overlapStart <= overlapEnd && + previous.currentText.slice( + overlapStart - previous.currentStart, + overlapEnd - previous.currentStart + ) === + fragment.beforeText.slice( + overlapStart - fragment.startOffset, + overlapEnd - fragment.startOffset + ) + ) { + const unionStart = Math.min(previous.currentStart, fragment.startOffset); + const currentText = + previous.currentStart <= fragment.startOffset + ? previous.currentText + + fragment.beforeText.slice( + Math.max(0, previous.currentEnd - fragment.startOffset) + ) + : fragment.beforeText + + previous.currentText.slice( + Math.max(0, beforeEnd - previous.currentStart) + ); + const prefix = currentText.slice(0, previous.currentStart - unionStart); + const suffix = currentText.slice(previous.currentEnd - unionStart); + const baseText = prefix + previous.baseText + suffix; + const nextText = applyEditsToSlice( + currentText, + unionStart, + transaction.appliedEdits + ); + const startLine = + previous.currentStart <= fragment.startOffset + ? previous.startLine + : fragment.startLine; + if ( + nextText !== undefined && + baseText.length <= MAX_CAPTURE_BYTES && + nextText.length <= MAX_CAPTURE_BYTES && + textEncoder.encode(baseText).byteLength <= MAX_CAPTURE_BYTES && + textEncoder.encode(nextText).byteLength <= MAX_CAPTURE_BYTES + ) { + if (baseText === nextText) { + kept.pop(); + return kept; + } + const formatted = formatEditHunk(path, baseText, nextText, startLine); + if (formatted !== undefined) { + kept[kept.length - 1] = { + path, + hunk: formatted.hunk, + start: startLine + formatted.bounds.prefixLines, + end: + startLine + + formatted.bounds.newLineCount - + formatted.bounds.suffixLines, + at, + source, + fragment: { + baseText, + currentText: nextText, + currentStart: unionStart, + currentEnd: unionStart + nextText.length, + startLine, + }, + }; + return kept; + } + } + } + } + + const previous = kept.at(-1); + if (previous?.fragment !== undefined) { + kept[kept.length - 1] = { ...previous, fragment: undefined }; + } + const formatted = formatEditHunk( + path, + fragment.beforeText, + fragment.afterText, + fragment.startLine + ); + if (formatted === undefined) { + return kept; + } + kept.push({ + path, + hunk: formatted.hunk, + start: changedStartLine, + end: + fragment.startLine + + fragment.bounds.newLineCount - + fragment.bounds.suffixLines, + at, + source, + fragment: { + baseText: fragment.beforeText, + currentText: fragment.afterText, + currentStart: fragment.startOffset, + currentEnd: fragment.startOffset + fragment.afterText.length, + startLine: fragment.startLine, + }, + }); + return kept.slice(-MAX_HISTORY_ENTRIES); +} + +function expandLinewise( + lineCount: number, + costForLine: (line: number) => number, + first: number, + last: number, + remaining: number +): { first: number; last: number } { + while (remaining > 0 && (first > 0 || last < lineCount - 1)) { + let expanded = false; + if (first > 0) { + const cost = costForLine(first - 1); + if (cost <= remaining) { + first--; + remaining -= cost; + expanded = true; + } + } + if (last < lineCount - 1) { + const cost = costForLine(last + 1); + if (cost <= remaining) { + last++; + remaining -= cost; + expanded = true; + } + } + if (!expanded) { + break; + } + } + return { first, last }; +} + +export function buildEditPredictionRequest( + path: string, + document: EditPredictionDocument, + cursorOffset: number, + history: readonly EditPredictionHistoryRecord[] +): EditPredictRequest | undefined { + if (document.lineCount <= 0) { + return; + } + const lastLine = document.lineCount - 1; + const documentLength = document.offsetAt({ + line: lastLine, + character: document.getLineLength(lastLine), + }); + const normalizedCursor = Number.isFinite(cursorOffset) + ? Math.trunc(cursorOffset) + : 0; + let cursor = Math.max(0, Math.min(normalizedCursor, documentLength)); + const previous = document.charAt(cursor - 1).charCodeAt(0); + const next = document.charAt(cursor).charCodeAt(0); + if ( + cursor > 0 && + cursor < documentLength && + ((previous === 13 && next === 10) || + (previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff)) + ) { + cursor--; + } + const cursorLine = document.positionAt(cursor).line; + const tokenCosts = new Map(); + const costForLine = (line: number): number => { + const cached = tokenCosts.get(line); + if (cached !== undefined) { + return cached; + } + const lineLength = document.getLineLength(line); + if (Math.floor(lineLength / 3) > MAX_CONTEXT_TOKENS) { + const cost = MAX_CONTEXT_TOKENS + 1; + tokenCosts.set(line, cost); + return cost; + } + const cost = Math.max( + 1, + Math.floor(textEncoder.encode(document.getLineText(line)).byteLength / 3) + ); + tokenCosts.set(line, cost); + return cost; + }; + + let editableFirst = cursorLine; + let editableLast = cursorLine; + const initialBudget = Math.floor((EDITABLE_TOKENS * 3) / 4); + let remaining = Math.max(0, initialBudget - costForLine(cursorLine)); + while ( + remaining > 0 && + (editableFirst > 0 || editableLast < document.lineCount - 1) + ) { + if (editableLast < document.lineCount - 1) { + const cost = costForLine(editableLast + 1); + if (cost > remaining) { + break; + } + editableLast++; + remaining -= cost; + } + if (editableFirst > 0 && remaining > 0) { + const cost = costForLine(editableFirst - 1); + if (cost > remaining) { + break; + } + editableFirst--; + remaining -= cost; + } + } + remaining += EDITABLE_TOKENS - initialBudget; + ({ first: editableFirst, last: editableLast } = expandLinewise( + document.lineCount, + costForLine, + editableFirst, + editableLast, + remaining + )); + + let contextFirst = editableFirst; + let contextLast = editableLast; + ({ first: contextFirst, last: contextLast } = expandLinewise( + document.lineCount, + costForLine, + contextFirst, + contextLast, + CONTEXT_TOKENS + )); + let editableTokens = 0; + for (let line = editableFirst; line <= editableLast; line++) { + editableTokens += costForLine(line); + } + let contextTokens = 0; + for (let line = contextFirst; line <= contextLast; line++) { + contextTokens += costForLine(line); + } + if ( + editableTokens > MAX_EDITABLE_TOKENS || + contextTokens > MAX_CONTEXT_TOKENS + ) { + return; + } + + const contextStart = document.offsetAt({ + line: contextFirst, + character: 0, + }); + const contextEnd = document.offsetAt({ + line: contextLast, + character: document.getLineLength(contextLast), + }); + const excerptText = document.getTextSlice(contextStart, contextEnd); + const request: EditPredictRequest = { + path, + version: document.version, + eol: document.eol, + excerptText, + excerptStartLine: contextFirst, + cursorOffsetInExcerpt: cursor - contextStart, + editableRange: { + start: + document.offsetAt({ line: editableFirst, character: 0 }) - contextStart, + end: + document.offsetAt({ + line: editableLast, + character: document.getLineLength(editableLast), + }) - contextStart, + }, + editHistory: history + .slice(-MAX_HISTORY_ENTRIES) + .map(({ hunk, source }) => ({ + diff: hunk, + source, + })), + }; + return textEncoder.encode(JSON.stringify(request)).byteLength <= + MAX_REQUEST_BYTES + ? request + : undefined; +} + +export function matchesEditPredictionPattern( + path: string, + pattern: string | RegExp +): boolean { + if (typeof pattern !== 'string') { + return new RegExp(pattern.source, pattern.flags).test(path); + } + + pattern = pattern.replaceAll('\\', '/'); + let source = '^'; + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + if (character === '*') { + if (pattern[index + 1] === '*') { + index++; + if (pattern[index + 1] === '/') { + index++; + source += '(?:.*/)?'; + } else { + source += '.*'; + } + } else { + source += '[^/]*'; + } + } else if (character === '?') { + source += '[^/]'; + } else { + source += /[\\^$.*+?()[\]{}|]/.test(character) + ? `\\${character}` + : character; + } + } + return new RegExp(`${source}$`).test(path); +} diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index a65a8bf78..b580b11a0 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -69,9 +69,13 @@ display: contents; } [data-caret], +[data-edit-prediction], [data-selection-range], [data-match-range], [data-bracket-match-range], +[data-edit-prediction-deletion-range], +[data-edit-prediction-insertion-range], +[data-edit-prediction-replacement-range], [data-marker-range] { position: absolute; top: 0; @@ -80,7 +84,66 @@ line-height: var(--diffs-line-height); pointer-events: none; } +[data-edit-prediction] { + z-index: 1; + height: auto; + overflow: visible; + width: max-content; + user-select: none; + color: var( + --diffs-editor-edit-prediction-fg, + color-mix(in lab, var(--diffs-fg) 45%, transparent) + ); +} +[data-edit-prediction-line] { + box-sizing: border-box; + display: block; + min-height: 1lh; + width: max-content; + white-space: pre; +} +[data-edit-prediction][data-replacement] [data-edit-prediction-line], +[data-edit-prediction-insertion-range], +[data-edit-prediction-replacement-range] { + background-color: var(--diffs-edit-prediction-bg, var(--diffs-bg)); +} +[data-edit-prediction-suffix] { + color: var(--diffs-fg); +} +[data-edit-prediction][data-wrap] [data-edit-prediction-line] { + max-width: 100%; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +[data-edit-prediction-line][data-empty]::after { + content: '↵'; +} +[data-edit-prediction-spacer] { + margin-block-end: var(--diffs-edit-prediction-spacer-height); +} +[data-edit-prediction-deletion-range] { + --diffs-edit-prediction-deletion-color: var( + --diffs-editor-edit-prediction-deletion-fg, + color-mix(in lab, var(--diffs-deletion-base) 70%, transparent) + ); + + z-index: 1; + background: linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + var(--diffs-edit-prediction-deletion-color) calc(50% - 0.5px), + var(--diffs-edit-prediction-deletion-color) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +[data-edit-prediction-insertion-range] { + z-index: 0; +} +[data-edit-prediction-replacement-range] { + z-index: 1; +} [data-caret] { + z-index: 2; width: 2px; background-color: var( --diffs-bg-caret-override, diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c703dd2e5..df00ea824 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -22,6 +22,7 @@ import type { SelectionSide, TextEdit, } from '../types'; +import { countLineBreaks } from '../utils/computeFileOffsets'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; import { @@ -31,6 +32,14 @@ import { resolveFindAgainShortcut, } from './command'; import editorCSS from './editor.css?inline'; +import { + buildEditPredictionRequest, + type EditPredictionHistoryRecord, + type EditPredictProvider, + type EditPredictResponse, + matchesEditPredictionPattern, + recordEditPrediction, +} from './editPrediction'; import { EditStack } from './editStack'; import { type LanguageConfigMap, @@ -114,6 +123,7 @@ import { type PersistStateStorage, } from './stateStorage'; import { TextDocument, type TextDocumentChange } from './textDocument'; +import { getTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; import { getExpandedAsciiTextColumns, getUnicodeMeasurementOffsets, @@ -127,60 +137,16 @@ import { extend, getLineNumberAttr, h, + isPromise, round, } from './utils'; -// ShadowRoot.getSelection is a non-standard Blink/WebKit method (predates the -// spec'd Selection.getComposedRanges) and is missing from the DOM lib types. -type ShadowRootWithSelection = ShadowRoot & { - getSelection?: () => Selection | null; -}; - -// Fallback for browsers without Selection.getComposedRanges: read the first -// range from the shadow root's own selection so the editor can still map a -// caret placed by a click inside its shadow tree. Normalized to a StaticRange -// so it matches the getComposedRanges return shape the callers expect. Returns -// undefined when the API or a live range is unavailable. -function getShadowRootRange(shadowRoot: ShadowRoot): StaticRange | undefined { - const selection = (shadowRoot as ShadowRootWithSelection).getSelection?.(); - if (selection == null || selection.rangeCount === 0) { - return undefined; - } - const range = selection.getRangeAt(0); - return { - collapsed: range.collapsed, - startContainer: range.startContainer, - startOffset: range.startOffset, - endContainer: range.endContainer, - endOffset: range.endOffset, - }; -} - -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; -} - -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; -} +export type { + EditPredictContext, + EditPredictProvider, + EditPredictRequest, + EditPredictResponse, +} from './editPrediction'; export interface EditorOptions { /** The maximum number of entries to keep in the undo stack. */ @@ -197,9 +163,9 @@ export interface EditorOptions { * in-memory cache. Defaults to `"inMemory"`. */ persistStateStorage?: PersistStateStorage; - /** Render rounded corners for selection ranges, default is true. */ + /** Render rounded corners for selection ranges. Defaults to true. */ roundedSelection?: boolean; - /** Highlight matching brackets near the caret, default is true. */ + /** Highlight matching brackets near the caret. Defaults to true. */ matchBrackets?: boolean; /** * Controls auto-surround when typing quotes or brackets over a selection. @@ -209,10 +175,38 @@ export interface EditorOptions { /** Per-language comment tokens used by the comment commands. */ languageCommentConfig?: LanguageConfigMap; /** - * Show a floating selection action popover after a user-created selection, - * default is disabled. Programmatic selection updates do not open it. + * Show a floating selection action popover after a user-created selection. + * Defaults to disabled. Programmatic selection updates do not open it. */ enabledSelectionAction?: boolean; + /** + * Configuration for inline edit prediction. + */ + editPrediction?: { + /** + * The edit prediction mode. + * - 'eager': predictions appear inline when the user types. + * - 'subtle': predictions only appear inline when holding the `Alt` key. + * @default 'eager' + */ + mode?: 'eager' | 'subtle'; + /** + * The edit prediction provider. + */ + provider: EditPredictProvider; + /** + * Glob or regular-expression patterns for files to include in prediction. + * String patterns support `?`, segment-local `*`, and cross-segment `**`. + * An empty array matches no files. + */ + include?: readonly (string | RegExp)[]; + /** + * Glob or regular-expression patterns for files to exclude from prediction. + * String patterns support `?`, segment-local `*`, and cross-segment `**`. + * Exclusions take precedence over inclusions. + */ + exclude?: readonly (string | RegExp)[]; + }; /** * Custom clipboard provider. * Highly recommended to use native clipboard API if you are building an electron app. @@ -268,10 +262,23 @@ const MAX_EDIT_WIDEN_WINDOW_MULTIPLE = 2; // line. Past this many lines the cache resets and refills lazily for whatever // is measured next. A memory bound, not a correctness-critical value. const MAX_WRAP_OFFSETS_CACHE_LINES = 10_000; +const EDIT_PREDICTION_DEBOUNCE_MS = 300; +const MAX_EDIT_PREDICTION_RESPONSE_EDITS = 256; +const MAX_EDIT_PREDICTION_RESPONSE_BYTES = 128 * 1024; +const editPredictionTextEncoder = new TextEncoder(); const SELECTION_ACTION_POPOVER_PLACEMENT_KEY = 'selection-action'; const MULTI_SELECTION_CLIPBOARD_TYPE = 'application/vnd.pierre.diffs-selections+json'; +type OverlayRangeType = + | 'selection' + | 'match' + | 'marker' + | 'bracketMatch' + | 'editPredictionDeletion' + | 'editPredictionInsertion' + | 'editPredictionReplacement'; + export class Editor implements DiffsEditor { #options: EditorOptions; #metrics = new Metrics(); @@ -297,13 +304,6 @@ export class Editor implements DiffsEditor { #globalEventDisposes?: (() => void)[]; #selectEventDisposes?: (() => void)[]; #detach?: (recycle?: boolean) => 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 = { - generation: 0, - callback: undefined, - delivered: false, - }; // cache #contentOffset?: { left: number; top: number }; @@ -338,12 +338,6 @@ export class Editor implements DiffsEditor { #lineAnnotations?: DiffLineAnnotation[]; #textDocument?: TextDocument; #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 - // large insert can't materialize an unbounded number of rows. Captured at sync - // time so consecutive edits that grow #renderRange can't ratchet the cap up. - // undefined until the first sync; Infinity for non-virtualized (whole-file) - // windows, where no cap is needed. #viewportWindowLines?: number; #markerRenderer?: MarkerRenderer; #searchPanel?: SearchPanelWidget; @@ -390,6 +384,27 @@ export class Editor implements DiffsEditor { #retainSearchPanelFocus = false; #fontRemeasureScheduled = false; #themeSelectionRefreshFrame?: number; + #editPredictionTimer?: ReturnType; + #editPredictionAbortController?: AbortController; + #editPredictionGeneration = 0; + #editPredictionAltPressed = false; + #editPrediction?: { + document: TextDocument; + version: number; + cursorOffset: number; + rendered: boolean; + response: EditPredictResponse; + renderEdits: readonly TextEdit[]; + }; + #editPredictionHistory: EditPredictionHistoryRecord[] = []; + #editPredictionSpacers = new Map(); + // 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 = { + generation: 0, + callback: undefined as (() => void) | undefined, + delivered: false, + }; #onDeferTokenize = ( lines: Map>, @@ -402,6 +417,15 @@ export class Editor implements DiffsEditor { this.#renderRange !== undefined && this.#renderRange.totalLines !== Infinity ) { + const predictionLines = + this.#editPrediction === undefined + ? undefined + : new Set( + this.#editPrediction.renderEdits.map( + (edit) => edit.range.start.line + ) + ); + let refreshPrediction = false; const { startingLine, totalLines } = this.#renderRange; const endLine = Math.min( startingLine + totalLines, @@ -412,9 +436,13 @@ export class Editor implements DiffsEditor { const lineElement = this.#getLineElement(line); if (lineElement !== undefined) { lineElement.replaceChildren(...renderLineTokens(tokens)); + refreshPrediction ||= predictionLines?.has(line) === true; } } } + if (refreshPrediction && this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } } }; @@ -425,6 +453,7 @@ export class Editor implements DiffsEditor { setOptions(options: EditorOptions): void { const previousStorageOption = this.#options.persistStateStorage ?? 'inMemory'; + const previousEditPrediction = this.#options.editPrediction; const nextOptions = { ...this.#options, ...options, @@ -435,7 +464,7 @@ export class Editor implements DiffsEditor { ) { const file = this.#fileInstance.__getCurrentFile?.() ?? this.#fileInfo; if (file !== undefined) { - requirePersistedCacheKey(file); + assertCacheKey(file); } } this.#options = nextOptions; @@ -453,6 +482,11 @@ export class Editor implements DiffsEditor { this.#stateStorageOption = undefined; this.#pendingStateWrites.clear(); } + if (this.#options.editPrediction !== previousEditPrediction) { + this.#cancelEditPrediction(true); + this.#editPredictionHistory = []; + this.#scheduleEditPrediction(); + } } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -463,7 +497,7 @@ export class Editor implements DiffsEditor { if (this.#options.persistState === true && fileInstance.type === 'file') { const file = fileInstance.__getCurrentFile?.(); if (file !== undefined) { - requirePersistedCacheKey(file); + assertCacheKey(file); } } this.#invalidateOnAttach(); @@ -726,6 +760,11 @@ export class Editor implements DiffsEditor { cleanUp(recycle = false): void { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); + this.#editPredictionAltPressed = false; + if (!recycle) { + this.#editPredictionHistory = []; + } if (!recycle) { this.#attachState.delivered = false; } @@ -812,12 +851,12 @@ export class Editor implements DiffsEditor { return file; } - const cacheKey = requirePersistedCacheKey(file); + const cacheKey = assertCacheKey(file); const fileInfo = this.#fileInfo; const languageId = file.lang ?? getFiletypeFromFileName(file.name); if ( fileInfo !== undefined && - (requirePersistedCacheKey(fileInfo) !== cacheKey || + (assertCacheKey(fileInfo) !== cacheKey || fileInfo.name !== file.name || this.#textDocument?.languageId !== languageId) ) { @@ -934,7 +973,7 @@ export class Editor implements DiffsEditor { this.#fileInfo.lang !== fileOrDiff.lang || this.#fileInfo.cacheKey !== fileOrDiff.cacheKey; const persistedCacheKey = this.#isStatePersistenceEnabled - ? requirePersistedCacheKey(fileOrDiff) + ? assertCacheKey(fileOrDiff) : undefined; let persistedStateTarget: @@ -943,6 +982,7 @@ export class Editor implements DiffsEditor { if (shouldRebuildDocument) { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); let contents = ''; if ('contents' in fileOrDiff) { contents = fileOrDiff.contents; @@ -963,6 +1003,7 @@ export class Editor implements DiffsEditor { new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; + this.#editPredictionHistory = []; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -1019,6 +1060,7 @@ export class Editor implements DiffsEditor { } if (this.#contentElement !== contentEl) { + this.#contentElement?.style.removeProperty('padding-block-end'); this.#gutterElement = gutterEl; this.#contentElement = extend(contentEl, { contentEditable: 'true', @@ -1195,7 +1237,7 @@ export class Editor implements DiffsEditor { return; } - const cacheKey = requirePersistedCacheKey(fileInfo); + const cacheKey = assertCacheKey(fileInfo); this.#textDocumentCache.set(cacheKey, textDocument); let storage: IStateStorage; @@ -1255,7 +1297,7 @@ export class Editor implements DiffsEditor { return; } - this.#trackStateWrite(cacheKey, result); + this.#trackStateWrite(cacheKey, Promise.resolve(result)); } #trackStateWrite(cacheKey: string, result: Promise): void { @@ -1286,7 +1328,7 @@ export class Editor implements DiffsEditor { this.#selections !== selections || currentView?.scrollLeft !== view?.scrollLeft || this.#fileInfo === undefined || - requirePersistedCacheKey(this.#fileInfo) !== cacheKey + assertCacheKey(this.#fileInfo) !== cacheKey ) { return; } @@ -1300,7 +1342,9 @@ export class Editor implements DiffsEditor { return; } if (isPromise(result)) { - return result.then(applyState).catch(() => {}); + return Promise.resolve(result) + .then(applyState) + .catch(() => {}); } else { try { applyState(result); @@ -1312,13 +1356,14 @@ export class Editor implements DiffsEditor { const result = pendingWrite === undefined ? readState() : pendingWrite.then(readState); if (isPromise(result)) { + const completion = Promise.resolve(result).catch(() => {}); const pendingRestore = { cacheKey, textDocument, documentVersion, selections, view, - completion: result.catch(() => {}), + completion, }; this.#pendingStateRestore = pendingRestore; void pendingRestore.completion.finally(() => { @@ -1575,15 +1620,32 @@ export class Editor implements DiffsEditor { // available in newer browsers. When it is missing (older browsers, // embedded WebViews, and the pinned CI Chromium), fall back to the // older Blink/WebKit-specific ShadowRoot.getSelection(), which still - // reports the range inside the shadow tree. Only bail when neither API - // yields a range, so a click can still seed the caret rather than - // leaving the surface unusable. - const composedRange = - typeof selectionRaw.getComposedRanges === 'function' - ? selectionRaw.getComposedRanges({ - shadowRoots: [shadowRoot], - })?.[0] - : getShadowRootRange(shadowRoot); + // reports the range inside the shadow tree. Normalize that live Range + // to a StaticRange so it matches the getComposedRanges return shape. + // Only bail when neither API yields a range, so a click can still seed + // the caret rather than leaving the surface unusable. + let composedRange: StaticRange | undefined; + if (typeof selectionRaw.getComposedRanges === 'function') { + composedRange = selectionRaw.getComposedRanges({ + shadowRoots: [shadowRoot], + })?.[0]; + } else { + const selection = ( + shadowRoot as ShadowRoot & { + getSelection?: () => Selection | null; + } + ).getSelection?.(); + if (selection != null && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + composedRange = { + collapsed: range.collapsed, + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + }; + } + } if ( composedRange === undefined || !this.#rangeBelongsToEditor(composedRange) @@ -1696,6 +1758,13 @@ export class Editor implements DiffsEditor { (e) => { if (e.key === 'Shift') { this.#selectionStart = this.#selections?.at(-1); + } else if ( + e.key === 'Alt' && + this.#contentHasFocus && + !this.#editPredictionAltPressed + ) { + this.#editPredictionAltPressed = true; + this.#updateSelections(this.#selections ?? []); } }, { passive: true } @@ -1707,6 +1776,21 @@ export class Editor implements DiffsEditor { (e) => { if (e.key === 'Shift') { this.#selectionStart = undefined; + } else if (e.key === 'Alt' && this.#editPredictionAltPressed) { + this.#editPredictionAltPressed = false; + this.#updateSelections(this.#selections ?? []); + } + }, + { passive: true } + ), + + addEventListener( + window, + 'blur', + () => { + if (this.#editPredictionAltPressed) { + this.#editPredictionAltPressed = false; + this.#updateSelections(this.#selections ?? []); } }, { passive: true } @@ -1878,6 +1962,24 @@ export class Editor implements DiffsEditor { // typing, moving); let selectionchange sync #selections again. this.#suppressNativeSelectionSync = false; + if ( + e.key === 'Tab' && + !e.shiftKey && + !e.ctrlKey && + !e.metaKey && + !e.isComposing && + !this.#isComposing && + (!e.altKey || this.#options.editPrediction?.mode === 'subtle') && + this.#acceptEditPrediction( + this.#options.editPrediction?.mode !== 'subtle' || + this.#editPredictionAltPressed || + e.altKey + ) + ) { + e.preventDefault(); + return; + } + const command = resolveEditorCommandFromKeyboardEvent( e, this.#options.keymap @@ -1885,6 +1987,13 @@ export class Editor implements DiffsEditor { if (command !== undefined) { e.preventDefault(); if (command === 'simplifySelection') { + if ( + this.#editPredictionTimer !== undefined || + this.#editPredictionAbortController !== undefined || + this.#editPrediction !== undefined + ) { + this.#cancelEditPrediction(true); + } this.#searchPanel?.close(); this.#searchPanel = undefined; this.#retainSearchPanelFocus = false; @@ -2051,6 +2160,7 @@ export class Editor implements DiffsEditor { return; } if (e.inputType === 'insertCompositionText') { + this.#cancelEditPrediction(true); return; } e.preventDefault(); @@ -2072,6 +2182,7 @@ export class Editor implements DiffsEditor { if (!targetIsContentElement(e)) { return; } + this.#cancelEditPrediction(true); this.#isComposing = true; this.#shouldIgnoreSelectionChange = true; }, @@ -4015,9 +4126,702 @@ export class Editor implements DiffsEditor { }); } - #updateSelections(selections: EditorSelection[]) { + #removeRenderedEditPrediction(): void { + for (const [key, element] of this.#overlayElements ?? []) { + if (key.startsWith('editPrediction')) { + element.remove(); + this.#overlayElements?.delete(key); + } + } + } + + // Reserve numberless grid space for ghost continuation lines without adding + // rows that could be mistaken for document content by the editor. + #syncEditPredictionSpacers(): void { + const nextSpacers = new Map(); + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const contentElement = this.#contentElement; + if ( + prediction !== undefined && + prediction.document === textDocument && + prediction.version === textDocument?.version && + contentElement !== undefined && + (this.#options.editPrediction?.mode !== 'subtle' || + this.#editPredictionAltPressed) + ) { + const continuationLines = new Map(); + for (const edit of prediction.renderEdits) { + if ( + edit.newText.length === 0 || + !this.#isLineVisible(edit.range.start.line) + ) { + continue; + } + const count = countLineBreaks(edit.newText); + if (count > (continuationLines.get(edit.range.start.line) ?? 0)) { + continuationLines.set(edit.range.start.line, count); + } + } + + if (continuationLines.size > 0) { + let rowIndexes: Map | undefined; + const startingLine = this.#renderRange?.startingLine ?? 0; + for (const [line, count] of continuationLines) { + const lineElement = this.#getLineElement(line); + if (lineElement === undefined) { + continue; + } + let rowIndex = line - startingLine; + if (contentElement.children[rowIndex] !== lineElement) { + if (rowIndexes === undefined) { + rowIndexes = new Map(); + for ( + let index = 0; + index < contentElement.children.length; + index++ + ) { + rowIndexes.set(contentElement.children[index], index); + } + } + rowIndex = rowIndexes.get(lineElement) ?? -1; + } + if (rowIndex < 0) { + continue; + } + nextSpacers.set(lineElement, count); + const gutterRow = this.#gutterElement?.children[rowIndex]; + if (gutterRow instanceof HTMLElement) { + nextSpacers.set(gutterRow, count); + } + } + } + } + + let changed = false; + for (const [element, count] of this.#editPredictionSpacers) { + if (nextSpacers.get(element) === count) { + continue; + } + delete element.dataset.editPredictionSpacer; + element.style.removeProperty('--diffs-edit-prediction-spacer-height'); + changed = true; + } + for (const [element, count] of nextSpacers) { + if (this.#editPredictionSpacers.get(element) === count) { + continue; + } + element.dataset.editPredictionSpacer = ''; + element.style.setProperty( + '--diffs-edit-prediction-spacer-height', + `${count}lh` + ); + changed = true; + } + this.#editPredictionSpacers = nextSpacers; + if (changed) { + this.#resetCache(); + } + } + + #cancelEditPrediction(removeRendered: boolean): void { + if (this.#editPredictionTimer !== undefined) { + clearTimeout(this.#editPredictionTimer); + this.#editPredictionTimer = undefined; + } + this.#editPredictionAbortController?.abort(); + this.#editPredictionAbortController = undefined; + this.#editPredictionGeneration++; + this.#editPrediction = undefined; + this.#contentElement?.style.removeProperty('padding-block-end'); + this.#syncEditPredictionSpacers(); + if (removeRendered) { + this.#removeRenderedEditPrediction(); + } + } + + #includesEditPredictionPath(path: string): boolean { + const options = this.#options.editPrediction; + if (options === undefined) { + return false; + } + const normalizedPath = path.replaceAll('\\', '/'); + return ( + (options.include === undefined || + options.include.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + )) && + options.exclude?.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + ) !== true + ); + } + + #scheduleEditPrediction(alreadyCancelled = false): void { + if ( + !alreadyCancelled || + this.#editPredictionTimer !== undefined || + this.#editPredictionAbortController !== undefined || + this.#editPrediction !== undefined + ) { + this.#cancelEditPrediction(true); + } + const selection = this.#selections?.[0]; + if ( + this.#options.editPrediction === undefined || + this.#textDocument === undefined || + this.#fileInfo === undefined || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) + ) { + return; + } + + const document = this.#textDocument; + const cursorOffset = document.offsetAt(getCaretPosition(selection)); + this.#editPredictionTimer = setTimeout(() => { + this.#editPredictionTimer = undefined; + const options = this.#options.editPrediction; + const currentSelection = this.#selections?.[0]; + const path = this.#fileInfo?.name; + if ( + options === undefined || + path === undefined || + this.#textDocument !== document || + this.#selections?.length !== 1 || + currentSelection === undefined || + !isCollapsedSelection(currentSelection) || + document.offsetAt(getCaretPosition(currentSelection)) !== cursorOffset + ) { + return; + } + + if (!this.#includesEditPredictionPath(path)) { + return; + } + + const request = buildEditPredictionRequest( + path, + document, + cursorOffset, + this.#editPredictionHistory + ); + if (request === undefined) { + return; + } + const excerptStartOffset = document.offsetAt({ + line: request.excerptStartLine, + character: 0, + }); + const editableStart = excerptStartOffset + request.editableRange.start; + const editableEnd = excerptStartOffset + request.editableRange.end; + const controller = new AbortController(); + const generation = ++this.#editPredictionGeneration; + this.#editPredictionAbortController = controller; + + let prediction: Promise; + try { + prediction = options.provider.predict(request, { + signal: controller.signal, + }); + } catch { + this.#editPredictionAbortController = undefined; + return; + } + + void Promise.resolve(prediction) + .then((response) => { + const selection = this.#selections?.[0]; + if ( + controller.signal.aborted || + generation !== this.#editPredictionGeneration || + this.#editPredictionAbortController !== controller || + this.#textDocument !== document || + document.version !== request.version || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) || + document.offsetAt(getCaretPosition(selection)) !== cursorOffset + ) { + return; + } + + if ( + response == null || + !Array.isArray(response.edits) || + response.edits.length === 0 || + response.edits.length > MAX_EDIT_PREDICTION_RESPONSE_EDITS || + response.newCursor == null + ) { + return; + } + const resolvedEdits: ResolvedTextEdit[] = []; + let responseBytes = 0; + for (const edit of response.edits) { + if ( + edit == null || + typeof edit.newText !== 'string' || + !isValidEditPredictionPosition(document, edit.range?.start) || + !isValidEditPredictionPosition(document, edit.range?.end) || + comparePosition(edit.range.start, edit.range.end) > 0 + ) { + return; + } + responseBytes += editPredictionTextEncoder.encode( + edit.newText + ).byteLength; + if (responseBytes > MAX_EDIT_PREDICTION_RESPONSE_BYTES) { + return; + } + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + if ( + splitsSurrogatePair( + document.charAt(start - 1) + document.charAt(start), + 1 + ) || + splitsSurrogatePair( + document.charAt(end - 1) + document.charAt(end), + 1 + ) + ) { + return; + } + resolvedEdits.push({ start, end, text: edit.newText }); + } + resolvedEdits.sort((left, right) => { + const startDelta = left.start - right.start; + return startDelta === 0 ? left.end - right.end : startDelta; + }); + for (let index = 0; index < resolvedEdits.length; index++) { + const edit = resolvedEdits[index]; + if ( + edit.start < editableStart || + edit.end > editableEnd || + (index > 0 && resolvedEdits[index - 1].end > edit.start) + ) { + return; + } + } + const edits = resolvedEdits.filter( + (edit) => edit.text !== document.getTextSlice(edit.start, edit.end) + ); + if (edits.length === 0) { + return; + } + + const firstEditPosition = document.positionAt(edits[0].start); + const lastEditPosition = document.positionAt(edits.at(-1)!.end); + const affectedStart = document.offsetAt({ + line: firstEditPosition.line, + character: 0, + }); + const affectedEnd = document.offsetAt({ + line: lastEditPosition.line, + character: document.getLineLength(lastEditPosition.line), + }); + const predictedParts: string[] = []; + let consumed = affectedStart; + for (const edit of edits) { + predictedParts.push( + document.getTextSlice(consumed, edit.start), + edit.text + ); + consumed = edit.end; + } + predictedParts.push(document.getTextSlice(consumed, affectedEnd)); + const predictedLines = predictedParts.join('').split(/\r\n|\r|\n/); + const affectedEndLine = + firstEditPosition.line + predictedLines.length - 1; + const lineDelta = + predictedLines.length - + (lastEditPosition.line - firstEditPosition.line + 1); + const newCursor = response.newCursor; + if ( + !Number.isInteger(newCursor.line) || + !Number.isInteger(newCursor.character) || + newCursor.line < 0 || + newCursor.character < 0 + ) { + return; + } + if ( + newCursor.line >= firstEditPosition.line && + newCursor.line <= affectedEndLine + ) { + const line = + predictedLines[newCursor.line - firstEditPosition.line]; + if ( + newCursor.character > line.length || + splitsSurrogatePair(line, newCursor.character) + ) { + return; + } + } else { + const originalLine = + newCursor.line < firstEditPosition.line + ? newCursor.line + : newCursor.line - lineDelta; + if ( + originalLine < 0 || + originalLine >= document.lineCount || + newCursor.character > document.getLineLength(originalLine) + ) { + return; + } + const originalOffset = document.offsetAt({ + line: originalLine, + character: newCursor.character, + }); + if ( + splitsSurrogatePair( + document.charAt(originalOffset - 1) + + document.charAt(originalOffset), + 1 + ) + ) { + return; + } + } + + const responseEdits = edits.map((edit) => ({ + range: { + start: document.positionAt(edit.start), + end: document.positionAt(edit.end), + }, + newText: edit.text, + })); + // One overlay owns each source line so masked suffixes are redrawn as + // the exact post-edit text. + const renderEdits: TextEdit[] = []; + for (let index = 0; index < responseEdits.length; index++) { + const edit = responseEdits[index]; + let groupEnd = index; + let groupEndLine = edit.range.end.line; + while ( + responseEdits[groupEnd + 1]?.range.start.line === groupEndLine + ) { + groupEnd++; + groupEndLine = responseEdits[groupEnd].range.end.line; + } + if (groupEnd === index) { + renderEdits.push(edit); + continue; + } + + const groupEndCharacter = document.getLineLength(groupEndLine); + const renderEnd = document.offsetAt({ + line: groupEndLine, + character: groupEndCharacter, + }); + const newText: string[] = []; + let consumed = edits[index].start; + for (let groupIndex = index; groupIndex <= groupEnd; groupIndex++) { + const groupEdit = edits[groupIndex]; + newText.push( + document.getTextSlice(consumed, groupEdit.start), + groupEdit.text + ); + consumed = groupEdit.end; + } + newText.push(document.getTextSlice(consumed, renderEnd)); + renderEdits.push({ + range: { + start: { ...edit.range.start }, + end: { + line: groupEndLine, + character: groupEndCharacter, + }, + }, + newText: newText.join(''), + }); + index = groupEnd; + } + this.#editPrediction = { + document, + version: request.version, + cursorOffset, + rendered: false, + response: { + edits: responseEdits, + newCursor: { ...newCursor }, + }, + renderEdits, + }; + this.#updateSelections(this.#selections); + }) + .catch(() => {}) + .finally(() => { + if (this.#editPredictionAbortController === controller) { + this.#editPredictionAbortController = undefined; + } + }); + }, EDIT_PREDICTION_DEBOUNCE_MS); + } + + #recordEditPredictionHistory( + change: TextDocumentChange, + source: 'user' | 'prediction' + ): void { + const textDocument = this.#textDocument; + const path = this.#fileInfo?.name; + const transaction = getTextDocumentChangeTransaction(change); + if ( + textDocument === undefined || + path === undefined || + transaction === undefined || + !this.#includesEditPredictionPath(path) + ) { + return; + } + this.#editPredictionHistory = recordEditPrediction( + this.#editPredictionHistory, + path, + textDocument, + transaction, + source + ); + } + + #acceptEditPrediction(visible: boolean): boolean { + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const selection = this.#selections?.[0]; + if ( + !visible || + prediction === undefined || + !prediction.rendered || + textDocument === undefined || + prediction.document !== textDocument || + prediction.version !== textDocument.version || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) || + textDocument.offsetAt(getCaretPosition(selection)) !== + prediction.cursorOffset + ) { + return false; + } + + const { edits, newCursor } = prediction.response; + this.#cancelEditPrediction(true); + const change = textDocument.applyEdits( + edits.map((edit) => ({ + range: { + start: { ...edit.range.start }, + end: { ...edit.range.end }, + }, + newText: edit.newText, + })), + true, + this.#selections, + undefined, + true + ); + if (change === undefined) { + this.#scheduleEditPrediction(); + return false; + } + + const cursor = textDocument.normalizePosition(newCursor); + const nextSelections: EditorSelection[] = [ + { start: cursor, end: cursor, direction: DirectionNone }, + ]; + textDocument.setLastUndoSelectionsAfter(nextSelections); + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change), + { editSource: 'prediction' } + ); + return true; + } + + #renderEditPrediction(renderCtx: { + fragment: DocumentFragment; + elements: Map; + }): void { + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const contentElement = this.#contentElement; + contentElement?.style.removeProperty('padding-block-end'); + if (prediction !== undefined) { + prediction.rendered = false; + } + if ( + prediction === undefined || + prediction.document !== textDocument || + prediction.version !== textDocument?.version || + (this.#options.editPrediction?.mode === 'subtle' && + !this.#editPredictionAltPressed) + ) { + return; + } + + const isWrap = this.#isWrap; + for ( + let editIndex = 0; + editIndex < prediction.renderEdits.length; + editIndex++ + ) { + const edit = prediction.renderEdits[editIndex]; + const { start, end } = edit.range; + const isDeletion = edit.newText.length === 0; + const isReplacement = comparePosition(start, end) !== 0; + const lineLength = textDocument.getLineLength(start.line); + const isMidLineInsertion = !isReplacement && start.character < lineLength; + if (isReplacement) { + const elementCount = renderCtx.elements.size; + this.#renderSelection( + renderCtx, + isDeletion ? 'editPredictionDeletion' : 'editPredictionReplacement', + { start, end } + ); + prediction.rendered ||= renderCtx.elements.size > elementCount; + } else if (isMidLineInsertion) { + // Hide the in-flow suffix so ghost text never collides with it. + this.#renderSelection(renderCtx, 'editPredictionInsertion', { + start, + end: { line: start.line, character: lineLength }, + }); + } + + if (isDeletion || !this.#isLineVisible(start.line)) { + continue; + } + + const [anchorLeft, anchorWrapLine] = this.#getCharX( + start.line, + start.character + ); + const lineLeft = this.#getCharX(start.line, 0)[0]; + const anchorTop = + this.#getLineY(start.line) + anchorWrapLine * this.#metrics.lineHeight; + const key = `editPrediction-${editIndex}`; + let element = this.#overlayElements?.get(key); + if (element !== undefined) { + this.#overlayElements?.delete(key); + element.replaceChildren(); + } else { + element = h( + 'span', + { + ariaHidden: 'true', + contentEditable: 'false', + dataset: 'editPrediction', + }, + renderCtx.fragment + ); + } + if (isReplacement) { + element.dataset.replacement = ''; + const lineElement = this.#getLineElement(start.line); + if (lineElement !== undefined) { + element.style.setProperty( + '--diffs-edit-prediction-bg', + getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg') + ); + } + } else { + delete element.dataset.replacement; + element.style.removeProperty('--diffs-edit-prediction-bg'); + } + if (isWrap) { + element.dataset.wrap = ''; + element.style.width = `calc(100cqw - ${lineLeft}px)`; + } else { + delete element.dataset.wrap; + element.style.width = 'max-content'; + } + const lines = edit.newText.split(/\r\n|\r|\n/); + // Redraw the suffix when wrapping cannot relocate it. + let insertionSuffix: Node | undefined; + if (isMidLineInsertion && !isWrap) { + const sourceLine = this.#getLineElement(start.line); + if (sourceLine === undefined) { + insertionSuffix = document.createTextNode( + textDocument.getLineText(start.line).slice(start.character) + ); + } else { + const [suffixNode, suffixOffset] = getSelectionAnchor( + sourceLine, + start.character + ); + const suffixRange = document.createRange(); + suffixRange.selectNodeContents(sourceLine); + suffixRange.setStart( + suffixNode, + clampDomOffset(suffixNode, suffixOffset) + ); + insertionSuffix = suffixRange.cloneContents(); + if (insertionSuffix.firstChild?.textContent === '') { + insertionSuffix.firstChild.remove(); + } + } + } + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const lineText = lines[lineIndex]; + const suffix = + lineIndex === lines.length - 1 ? insertionSuffix : undefined; + const isEmpty = lineText.length === 0 && suffix === undefined; + const line = h( + 'span', + { + dataset: isEmpty + ? ['editPredictionLine', 'empty'] + : 'editPredictionLine', + textContent: isEmpty ? '\u200b' : lineText, + }, + element + ); + if (suffix !== undefined) { + const suffixElement = h( + 'span', + { + dataset: 'editPredictionSuffix', + }, + line + ); + suffixElement.append(suffix); + } + if (lineIndex === 0 && anchorLeft !== lineLeft) { + line.style.paddingInlineStart = `${anchorLeft - lineLeft}px`; + } + } + element.style.transform = `translateX(${lineLeft}px) translateY(${anchorTop}px)`; + renderCtx.elements.set(key, element); + prediction.rendered = true; + } + } + + #updateSelections( + selections: EditorSelection[], + updateEditPrediction = true + ) { this.__postponeBgTokenizeToNextFrame(); + const previousSelections = this.#selections; + let selectionsChanged = previousSelections?.length !== selections.length; + if (!selectionsChanged && previousSelections !== undefined) { + for (let i = 0; i < selections.length; i++) { + const previous = previousSelections[i]; + const next = selections[i]; + if ( + previous.direction !== next.direction || + comparePosition(previous.start, next.start) !== 0 || + comparePosition(previous.end, next.end) !== 0 + ) { + selectionsChanged = true; + break; + } + } + } + if (selectionsChanged && updateEditPrediction) { + this.#cancelEditPrediction(true); + } + this.#syncEditPredictionSpacers(); + this.#primaryCaretElement = undefined; this.#setEditorActiveLineSafe(null); @@ -4031,6 +4835,9 @@ export class Editor implements DiffsEditor { this.#overlayElements?.clear(); this.#selectionAction?.cleanup(); this.#selectionAction = undefined; + if (selectionsChanged && updateEditPrediction) { + this.#scheduleEditPrediction(); + } return; } @@ -4167,12 +4974,42 @@ export class Editor implements DiffsEditor { } } + this.#renderEditPrediction(renderCtx); + this.#overlayElement?.appendChild(fragment); this.#overlayElements?.forEach((el) => el.remove()); this.#overlayElements?.clear(); this.#overlayElements = renderCtx.elements; + let predictionBottom: number | undefined; + for (const element of renderCtx.elements.values()) { + if (element.dataset.editPrediction !== undefined) { + const bottom = element.getBoundingClientRect().bottom; + predictionBottom = + predictionBottom === undefined + ? bottom + : Math.max(predictionBottom, bottom); + } + } + const contentElement = + predictionBottom === undefined ? undefined : this.#contentElement; + const codeElement = contentElement?.parentElement; + if ( + predictionBottom !== undefined && + contentElement !== undefined && + codeElement != null + ) { + const codeRect = codeElement.getBoundingClientRect(); + if (codeRect.height > 0 && predictionBottom > codeRect.bottom) { + contentElement.style.paddingBlockEnd = `${Math.ceil( + predictionBottom - codeRect.bottom + )}px`; + } + } this.#updateSelectionActionPopover(); + if (selectionsChanged && updateEditPrediction) { + this.#scheduleEditPrediction(); + } } #renderSelection( @@ -4180,7 +5017,7 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, range: Range, extraDataset?: string ) { @@ -4277,7 +5114,7 @@ export class Editor implements DiffsEditor { startChar: number, endChar: number, isLastLine: boolean, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, extraDataset?: string ) { const wrapOffsets = this.#wrapLineTextOrWholeLine(line); @@ -4373,7 +5210,7 @@ export class Editor implements DiffsEditor { width: number; }; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, line: number, wrapLine: number, left: number, @@ -4531,6 +5368,20 @@ export class Editor implements DiffsEditor { rangeEl.style.width = `${width}px`; rangeEl.style.transform = `translateX(${left}px) translateY(${y}px)`; + if ( + type === 'editPredictionInsertion' || + type === 'editPredictionReplacement' + ) { + const lineElement = this.#getLineElement(line); + if (lineElement !== undefined) { + rangeEl.style.setProperty( + '--diffs-edit-prediction-bg', + getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg') + ); + } + } else { + rangeEl.style.removeProperty('--diffs-edit-prediction-bg'); + } if (rounded) { addRadiusStyle(rangeEl); } @@ -5151,8 +6002,18 @@ export class Editor implements DiffsEditor { change: TextDocumentChange, newSelections?: EditorSelection[], newLineAnnotations?: DiffLineAnnotation[], - options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } + options?: { + skipSearchRefresh?: boolean; + skipFocus?: boolean; + editSource?: 'user' | 'prediction'; + } ) { + const editPredictionWasEnabled = this.#options.editPrediction !== undefined; + if (editPredictionWasEnabled) { + this.#cancelEditPrediction(true); + this.#recordEditPredictionHistory(change, options?.editSource ?? 'user'); + } + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { @@ -5299,7 +6160,7 @@ export class Editor implements DiffsEditor { // 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); + this.#updateSelections(newSelections, false); // focus to update the native window selection, and scroll to the caret // to mock the 'contenteditable' behavior @@ -5323,6 +6184,9 @@ export class Editor implements DiffsEditor { this.focus({ preventScroll: true }); } } + if (this.#options.editPrediction !== undefined) { + this.#scheduleEditPrediction(editPredictionWasEnabled); + } } #applyChangeToLineAnnotations( @@ -5751,3 +6615,40 @@ export class Editor implements DiffsEditor { return this.#getLineElement(line) !== undefined; } } + +function assertCacheKey(file: Partial): 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; +} + +function isValidEditPredictionPosition( + document: TextDocument, + position: Position | undefined +): position is Position { + return ( + position !== undefined && + Number.isInteger(position.line) && + Number.isInteger(position.character) && + position.line >= 0 && + position.line < document.lineCount && + position.character >= 0 && + position.character <= document.getLineLength(position.line) + ); +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + const previous = text.charCodeAt(offset - 1); + const next = text.charCodeAt(offset); + return ( + offset > 0 && + offset < text.length && + previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ); +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 0738a6253..30a3ed44b 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 { setTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; export type { Position, Range, TextEdit } from '../types'; @@ -69,7 +70,7 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack: EditStack; - #eol: string; + #eol: '\n' | '\r\n' | '\r'; constructor( uri: string, @@ -114,7 +115,7 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get eol(): string { + get eol(): '\n' | '\r\n' | '\r' { return this.#eol; } @@ -285,6 +286,10 @@ export class TextDocument { } else { this.#editStack.push(entry); } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.forwardEdits, + inverseEdits: entry.inverseEdits, + }); return change; } @@ -311,6 +316,10 @@ export class TextDocument { if (change === undefined) { return undefined; } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.inverseEdits, + inverseEdits: entry.forwardEdits, + }); this.#version = entry.versionBefore; const selections = entry.selectionsBefore?.slice(); return [ @@ -332,6 +341,10 @@ export class TextDocument { if (change === undefined) { return undefined; } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.forwardEdits, + inverseEdits: entry.inverseEdits, + }); this.#version = entry.versionAfter; const selections = entry.selectionsAfter?.slice(); return [ diff --git a/packages/diffs/src/editor/textDocumentChangeTransaction.ts b/packages/diffs/src/editor/textDocumentChangeTransaction.ts new file mode 100644 index 000000000..116ff6705 --- /dev/null +++ b/packages/diffs/src/editor/textDocumentChangeTransaction.ts @@ -0,0 +1,28 @@ +import type { ResolvedTextEdit } from '../types'; +import type { TextDocumentChange } from './textDocument'; + +// Keeps prediction-only edit metadata off the public TextDocumentChange shape. +export interface TextDocumentChangeTransaction { + /** Edits applied to the document state before this change. */ + readonly appliedEdits: readonly ResolvedTextEdit[]; + /** Edits that restore the document state before this change. */ + readonly inverseEdits: readonly ResolvedTextEdit[]; +} + +const transactions = new WeakMap< + TextDocumentChange, + TextDocumentChangeTransaction +>(); + +export function getTextDocumentChangeTransaction( + change: TextDocumentChange +): TextDocumentChangeTransaction | undefined { + return transactions.get(change); +} + +export function setTextDocumentChangeTransaction( + change: TextDocumentChange, + transaction: TextDocumentChangeTransaction +): void { + transactions.set(change, transaction); +} diff --git a/packages/diffs/src/editor/utils.ts b/packages/diffs/src/editor/utils.ts index 26ca9e920..3b9c4a4ef 100644 --- a/packages/diffs/src/editor/utils.ts +++ b/packages/diffs/src/editor/utils.ts @@ -37,6 +37,15 @@ export function h( return el; } +export function isPromise(value: T | Promise): value is Promise { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ); +} + export function addEventListener( el: HTMLElement, event: K, diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index e796d1b77..94123bde1 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -307,7 +307,8 @@ } } - [data-line] span { + [data-line] span, + [data-edit-prediction-suffix] span { color: light-dark( var(--diffs-token-light, var(--diffs-light)), var(--diffs-token-dark, var(--diffs-dark)) diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 13931598c..4f20945ca 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -35,6 +35,15 @@ function createDeferred(): Deferred { return { promise, resolve }; } +function foreignPromise(promise: Promise): Promise { + return { + [Symbol.toStringTag]: 'Promise', + then: promise.then.bind(promise), + catch: promise.catch.bind(promise), + finally: promise.finally.bind(promise), + } as Promise; +} + async function attachFile( editor: Editor, fileContents: FileContents @@ -137,6 +146,35 @@ describe('Editor persisted state lifecycle', () => { } }); + test('restores state from a foreign Promise', async () => { + const dom = installDom(); + const state = savedCaret(3); + const storage: IStateStorage = { + get() { + return foreignPromise(Promise.resolve(state)); + }, + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: AttachedFile | undefined; + + try { + attached = await attachFile(editor, { ...ORIGINAL_FILE }); + await waitFor( + () => editor.getState().selections?.[0]?.start.character === 3 + ); + + expect(editor.getState().selections).toEqual(state.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(); @@ -256,9 +294,11 @@ describe('Editor persisted state lifecycle', () => { throw new Error('unexpected persisted.ts write'); } writes.push(state); - return gate.promise.then(() => { - states.set(cacheKey, state); - }); + return foreignPromise( + gate.promise.then(() => { + states.set(cacheKey, state); + }) + ); }, }; const editor = new Editor({ diff --git a/packages/diffs/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts new file mode 100644 index 000000000..d801fbfd7 --- /dev/null +++ b/packages/diffs/test/editorPrediction.test.ts @@ -0,0 +1,1425 @@ +import { afterAll, describe, expect, jest, spyOn, 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 EditorOptions, + type EditPredictContext, + type EditPredictProvider, + type EditPredictRequest, + type EditPredictResponse, +} from '../src/editor/editor'; +import { TextDocument } from '../src/editor/textDocument'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import { installDom, wait, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +const FILE_NAME = 'src/edit.ts'; +const EDIT_PREDICTION_DEBOUNCE_MS = 300; +const PREDICT_TIMEOUT = 2_000; + +type Surface = 'File' | 'FileDiff'; + +interface PredictionCall { + context: EditPredictContext; + request: EditPredictRequest; +} + +interface PredictionFixture { + cleanup(): Promise; + container: HTMLElement; + content: HTMLElement; + editor: Editor; +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function findEditableContent(container: HTMLElement): HTMLElement | undefined { + return Array.from( + container.shadowRoot?.querySelectorAll('[data-content]') ?? [] + ).find( + (element) => + element.contentEditable === 'true' || + element.getAttribute('contenteditable') === 'true' + ); +} + +async function createPredictionFixture({ + contents, + editorOptions, + name = FILE_NAME, + surface = 'File', +}: { + contents: string; + editorOptions: EditorOptions; + name?: string; + surface?: Surface; +}): Promise { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const editor = new Editor(editorOptions); + let cleanUpSurface: () => void; + + if (surface === 'File') { + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + file.render({ + file: { name, contents }, + fileContainer: container, + forceRender: true, + }); + editor.edit(file); + cleanUpSurface = () => file.cleanUp(); + } else { + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'split', + theme: DEFAULT_THEMES, + }); + fileDiff.render({ + oldFile: { name, contents: contents.replaceAll('value', 'previous') }, + newFile: { name, contents }, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + cleanUpSurface = () => fileDiff.cleanUp(); + } + + await waitFor(() => findEditableContent(container) !== undefined, { + timeout: 3_000, + }); + const content = findEditableContent(container); + if (content === undefined) { + throw new Error(`${surface} did not become editable`); + } + + return { + async cleanup() { + editor.cleanUp(); + cleanUpSurface(); + await wait(0); + dom.cleanup(); + }, + container, + content, + editor, + }; +} + +function setCaret( + editor: Editor, + line: number, + character: number +): void { + const position = { line, character }; + editor.setSelections([{ start: position, end: position, direction: 'none' }]); +} + +function dispatchTextInput(content: HTMLElement, data: string): InputEvent { + const view = content.ownerDocument.defaultView; + if (view == null) { + throw new Error('editor content is not attached to a window'); + } + const event = new view.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + data, + inputType: 'insertText', + }); + content.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + return event; +} + +function dispatchKey( + content: HTMLElement, + key: string, + init: KeyboardEventInit = {}, + type: 'keydown' | 'keyup' = 'keydown' +): KeyboardEvent { + const view = content.ownerDocument.defaultView; + if (view == null) { + throw new Error('editor content is not attached to a window'); + } + const event = new view.KeyboardEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + key, + ...init, + }); + content.dispatchEvent(event); + return event; +} + +function predictionElements(container: HTMLElement): HTMLElement[] { + return Array.from( + container.shadowRoot?.querySelectorAll( + '[data-edit-prediction]' + ) ?? [] + ); +} + +function hasVisiblePrediction(container: HTMLElement): boolean { + return predictionElements(container).some((element) => { + const style = getComputedStyle(element); + return ( + element.hidden === false && + style.display !== 'none' && + style.opacity !== '0' && + style.visibility !== 'hidden' + ); + }); +} + +async function expectCallCount( + calls: PredictionCall[], + count: number +): Promise { + await waitFor(() => calls.length >= count, { + timeout: PREDICT_TIMEOUT, + }); + expect(calls).toHaveLength(count); +} + +describe('Editor edit prediction', () => { + test('debounces typed input and builds a small-document request', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc\r\ndef', + editorOptions: { editPrediction: { provider } }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 3); + dispatchTextInput(fixture.content, 'X'); + + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); + expect(calls).toHaveLength(0); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); + + expect(calls[0].request).toMatchObject({ + cursorOffsetInExcerpt: 4, + editableRange: { start: 0, end: 9 }, + eol: '\r\n', + excerptStartLine: 0, + excerptText: 'abcX\r\ndef', + path: FILE_NAME, + version: 1, + }); + expect(calls[0].context.signal.aborted).toBe(false); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + + test('uses the document EOL when the excerpt has no line break', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 1, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: `${'界'.repeat(2_000)}\r\nshort`, + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 1, 0); + dispatchKey(fixture.content, 'ArrowRight'); + await expectCallCount(calls, 1); + + expect(calls[0].request.excerptText).toBe('short'); + expect(calls[0].request.eol).toBe('\r\n'); + } finally { + await fixture.cleanup(); + } + }); + + test('bounds editable and context ranges around the cursor', async () => { + const calls: PredictionCall[] = []; + const contents = Array.from( + { length: 600 }, + (_, line) => `const value${line} = ${line};` + ).join('\n'); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 300, character: 6 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 300, 5); + dispatchKey(fixture.content, 'ArrowRight'); + await expectCallCount(calls, 1); + + const request = calls[0].request; + expect(request.excerptStartLine).toBeGreaterThan(0); + expect(request.excerptText.length).toBeLessThan(contents.length); + expect(request.editableRange.start).toBeGreaterThan(0); + expect(request.editableRange.end).toBeLessThan( + request.excerptText.length + ); + expect(request.cursorOffsetInExcerpt).toBeGreaterThan( + request.editableRange.start + ); + expect(request.cursorOffsetInExcerpt).toBeLessThan( + request.editableRange.end + ); + } finally { + await fixture.cleanup(); + } + }); + + test('does not materialize the full document for prediction requests or history', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 60, character: 7 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: Array.from( + { length: 120 }, + (_, line) => `const value${line} = ${line};` + ).join('\n'), + editorOptions: {}, + }); + const getText = spyOn(TextDocument.prototype, 'getText'); + try { + fixture.editor.setOptions({ editPrediction: { provider } }); + setCaret(fixture.editor, 60, 5); + fixture.editor.applyEdits([ + { + range: { + start: { line: 60, character: 5 }, + end: { line: 60, character: 5 }, + }, + newText: 'X', + }, + ]); + await expectCallCount(calls, 1); + + expect( + getText.mock.calls.filter(([range]) => range === undefined) + ).toHaveLength(0); + expect(calls[0].request.editHistory[0]?.diff).toContain( + '+constX value60 = 60;' + ); + expect(calls[0].request.excerptStartLine).toBeGreaterThan(0); + } finally { + getText.mockRestore(); + await fixture.cleanup(); + } + }); + + test('keeps pathological long-line requests within 128 KiB or skips them', async () => { + const calls: PredictionCall[] = []; + const contents = '界'.repeat(50_000); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 25_001 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + name: 'pathological.txt', + }); + + try { + setCaret(fixture.editor, 0, 25_000); + dispatchKey(fixture.content, 'ArrowRight'); + await wait(400); + + expect(calls.length).toBeLessThanOrEqual(1); + if (calls[0] !== undefined) { + expect( + new TextEncoder().encode(JSON.stringify(calls[0].request)).byteLength + ).toBeLessThanOrEqual(128 * 1024); + } + expect(fixture.editor.getText()).toBe(contents); + } finally { + await fixture.cleanup(); + } + }); + + test('debounces prediction after a cursor-key movement', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc', + editorOptions: { editPrediction: { provider } }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 0); + const event = dispatchKey(fixture.content, 'ArrowRight'); + expect(event.defaultPrevented).toBe(true); + + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); + expect(calls).toHaveLength(0); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); + expect(calls[0].request).toMatchObject({ + cursorOffsetInExcerpt: 1, + excerptText: 'abc', + version: 0, + }); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + + for (const surface of ['File', 'FileDiff'] as const) { + test(`${surface} eagerly renders and accepts a prediction atomically`, async () => { + const calls: PredictionCall[] = []; + const changes: string[] = []; + const typedText = 'const value = 1'; + const predictedText = 'const answer = 1;\nconsole.log(answer);'; + const newCursor = { line: 1, character: 7 }; + const response: EditPredictResponse = { + edits: [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + }, + newText: 'answer', + }, + { + range: { + start: { line: 0, character: typedText.length }, + end: { line: 0, character: typedText.length }, + }, + newText: ';\nconsole.log(answer);', + }, + ], + newCursor, + }; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve(response); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'const value = ', + editorOptions: { + editPrediction: { provider }, + onChange(file) { + changes.push(file.contents); + }, + }, + surface, + }); + + try { + setCaret(fixture.editor, 0, 'const value = '.length); + dispatchTextInput(fixture.content, '1'); + await expectCallCount(calls, 1); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + expect(predictionElements(fixture.container).length).toBeGreaterThan(0); + expect(fixture.editor.getText()).toBe(typedText); + expect(changes).toEqual([typedText]); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe(predictedText); + expect(fixture.editor.getState().selections).toEqual([ + { start: newCursor, end: newCursor, direction: 0 }, + ]); + expect(changes).toEqual([typedText, predictedText]); + await waitFor( + () => predictionElements(fixture.container).length === 0, + { timeout: PREDICT_TIMEOUT } + ); + expect(predictionElements(fixture.container)).toHaveLength(0); + expect( + fixture.container.shadowRoot?.querySelectorAll( + '[data-edit-prediction-spacer]' + ) + ).toHaveLength(0); + + await expectCallCount(calls, 2); + expect(calls[1].request.editHistory.at(-1)?.source).toBe('prediction'); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '-const value = 1' + ); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '+const answer = 1;' + ); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '+console.log(answer);' + ); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe(typedText); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} masks and preserves the suffix for a mid-line insertion`, async () => { + const contents = 'function value(items: CartItem[]): number {'; + const insertion = ', discount?: number'; + const character = 'function value(items: CartItem[]'.length; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character }, + end: { line: 0, character }, + }, + newText: insertion, + }, + ], + newCursor: { line: 0, character: character + insertion.length }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + const sourceLine = + fixture.content.querySelector('[data-line="1"]'); + await waitFor( + () => + Array.from(sourceLine?.children ?? []).some( + (token) => + Number((token as HTMLElement).dataset.char) >= character + ), + { timeout: PREDICT_TIMEOUT } + ); + const sourceSuffixTokens = Array.from(sourceLine?.children ?? []) + .map((token) => token as HTMLElement) + .flatMap((token) => { + const text = token.textContent ?? ''; + const start = Number(token.dataset.char); + return start + text.length <= character + ? [] + : [ + { + text: text.slice(Math.max(0, character - start)), + dark: token.style.getPropertyValue('--diffs-token-dark'), + light: token.style.getPropertyValue('--diffs-token-light'), + }, + ]; + }); + expect( + sourceSuffixTokens.some( + ({ dark, light }) => dark !== '' && light !== '' + ) + ).toBe(true); + + setCaret(fixture.editor, 0, character); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const prediction = predictionElements(fixture.container)[0]; + expect(prediction.dataset.replacement).toBeUndefined(); + expect( + fixture.container.shadowRoot?.querySelector( + '[data-edit-prediction-insertion-range]' + ) + ).not.toBeNull(); + expect( + prediction.querySelector('[data-edit-prediction-suffix]')?.textContent + ).toBe('): number {'); + expect( + Array.from( + prediction.querySelector('[data-edit-prediction-suffix]') + ?.children ?? [] + ).map((token) => ({ + text: token.textContent, + dark: (token as HTMLElement).style.getPropertyValue( + '--diffs-token-dark' + ), + light: (token as HTMLElement).style.getPropertyValue( + '--diffs-token-light' + ), + })) + ).toEqual(sourceSuffixTokens); + expect( + prediction.querySelector('[data-edit-prediction-line]')?.textContent + ).toBe(', discount?: number): number {'); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe( + 'function value(items: CartItem[], discount?: number): number {' + ); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} composes multiple same-line insertions in the preview`, async () => { + const contents = 'alpha value gamma'; + const firstCharacter = 'alpha '.length; + const secondCharacter = 'alpha value '.length; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: firstCharacter }, + end: { line: 0, character: firstCharacter }, + }, + newText: 'one ', + }, + { + range: { + start: { line: 0, character: secondCharacter }, + end: { line: 0, character: secondCharacter }, + }, + newText: 'two ', + }, + ], + newCursor: { line: 0, character: 25 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + setCaret(fixture.editor, 0, firstCharacter); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const predictions = predictionElements(fixture.container); + expect(predictions).toHaveLength(1); + expect( + predictions[0].querySelector('[data-edit-prediction-line]') + ?.textContent + ).toBe('one value two gamma'); + expect(fixture.editor.getText()).toBe(contents); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('alpha one value two gamma'); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} previews edits sharing a cross-line boundary`, async () => { + const contents = 'abc\ndef value'; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 1, character: 0 }, + }, + newText: 'C', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: 'E', + }, + ], + newCursor: { line: 0, character: 5 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + setCaret(fixture.editor, 0, 2); + await waitFor( + () => predictionElements(fixture.container).length === 1, + { + timeout: PREDICT_TIMEOUT, + } + ); + + expect( + predictionElements(fixture.container).map( + (prediction) => prediction.textContent + ) + ).toEqual(['CdEf value']); + expect(fixture.editor.getText()).toBe(contents); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('abCdEf value'); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} reserves numberless rows for multiline ghost text`, async () => { + const contents = 'const value = 1;\nnext();\nend();'; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 1, character: 7 }, + end: { line: 1, character: 7 }, + }, + newText: '\nghostOne();\nghostTwo();', + }, + ], + newCursor: { line: 3, character: 11 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + const gutter = fixture.content.parentElement?.querySelector( + ':scope > [data-gutter]' + ); + const lineNumbers = () => + Array.from( + fixture.content.querySelectorAll(':scope > [data-line]') + ).map((element) => element.dataset.line); + const gutterNumbers = () => + Array.from( + gutter?.querySelectorAll( + ':scope > [data-column-number]' + ) ?? [] + ).map((element) => element.dataset.columnNumber); + const initialLineNumbers = lineNumbers(); + const initialGutterNumbers = gutterNumbers(); + + try { + setCaret(fixture.editor, 1, 7); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const prediction = predictionElements(fixture.container)[0]; + const ghostLines = Array.from( + prediction.querySelectorAll( + '[data-edit-prediction-line]' + ) + ); + expect(ghostLines).toHaveLength(3); + expect( + ghostLines.every( + (line) => line.closest('[data-line], [data-column-number]') === null + ) + ).toBe(true); + expect(lineNumbers()).toEqual(initialLineNumbers); + expect(gutterNumbers()).toEqual(initialGutterNumbers); + + const anchorLine = + fixture.content.querySelector('[data-line="2"]'); + const anchorGutter = gutter?.querySelector( + '[data-column-number="2"]' + ); + for (const element of [anchorLine, anchorGutter]) { + expect(element?.dataset.editPredictionSpacer).toBe(''); + expect( + element?.style.getPropertyValue( + '--diffs-edit-prediction-spacer-height' + ) + ).toBe('2lh'); + } + + dispatchKey(fixture.content, 'ArrowLeft'); + expect( + fixture.container.shadowRoot?.querySelectorAll( + '[data-edit-prediction-spacer]' + ) + ).toHaveLength(0); + expect(lineNumbers()).toEqual(initialLineNumbers); + expect(gutterNumbers()).toEqual(initialGutterNumbers); + } finally { + await fixture.cleanup(); + } + }); + } + + test('reserves and clears space for a multiline FileDiff prediction at EOF', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '\nnext', + }, + ], + newCursor: { line: 1, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'value', + editorOptions: { editPrediction: { provider } }, + surface: 'FileDiff', + }); + const elementPrototype = + fixture.content.ownerDocument.defaultView!.HTMLElement.prototype; + const getBoundingClientRect = elementPrototype.getBoundingClientRect; + elementPrototype.getBoundingClientRect = function () { + if (this.dataset.code !== undefined) { + return { + bottom: 20, + height: 20, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON() {}, + }; + } + if (this.dataset.editPrediction !== undefined) { + return { + bottom: 40, + height: 40, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON() {}, + }; + } + return getBoundingClientRect.call(this); + }; + + try { + setCaret(fixture.editor, 0, 5); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + expect(fixture.content.style.paddingBlockEnd).toBe('20px'); + + dispatchKey(fixture.content, 'ArrowLeft'); + expect(fixture.content.style.paddingBlockEnd).toBe(''); + } finally { + elementPrototype.getBoundingClientRect = getBoundingClientRect; + await fixture.cleanup(); + } + }); + + test('typing aborts an in-flight prediction and ignores its response', async () => { + const calls: PredictionCall[] = []; + const pending = createDeferred(); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return pending.promise; + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + expect(calls[0].context.signal.aborted).toBe(false); + + dispatchTextInput(fixture.content, 'c'); + expect(calls[0].context.signal.aborted).toBe(true); + pending.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: ' stale', + }, + ], + newCursor: { line: 0, character: 8 }, + }); + await wait(0); + await wait(0); + + expect(fixture.editor.getText()).toBe('abc'); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('cursor movement aborts an in-flight prediction', async () => { + const calls: PredictionCall[] = []; + const pending = createDeferred(); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return pending.promise; + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + dispatchTextInput(fixture.content, 'd'); + await expectCallCount(calls, 1); + + dispatchKey(fixture.content, 'ArrowLeft'); + expect(calls[0].context.signal.aborted).toBe(true); + pending.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 4 }, + }, + newText: ' stale', + }, + ], + newCursor: { line: 0, character: 10 }, + }); + await wait(0); + + expect(fixture.editor.getText()).toBe('abcd'); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('rejects a prediction range that splits a surrogate pair', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 3 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: '😀x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + await expectCallCount(calls, 1); + await wait(0); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('Escape discards a prediction without changing the document', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + + const escape = dispatchKey(fixture.content, 'Escape'); + expect(escape.defaultPrevented).toBe(true); + expect(predictionElements(fixture.container)).toHaveLength(0); + expect(fixture.editor.getText()).toBe('a'); + } finally { + await fixture.cleanup(); + } + }); + + test('an empty response leaves Tab available to the editor', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + await expectCallCount(calls, 1); + await wait(0); + expect(predictionElements(fixture.container)).toHaveLength(0); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).not.toBe('a'); + } finally { + await fixture.cleanup(); + } + }); + + test('Shift+Tab runs outdent instead of accepting a prediction', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: ' a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + await expectCallCount(calls, 1); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const tab = dispatchKey(fixture.content, 'Tab', { shiftKey: true }); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('a'); + expect(fixture.editor.getText()).not.toContain('!'); + } finally { + await fixture.cleanup(); + } + }); + + test('coalesces nearby user edits and caps history at ten entries', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + const character = request.cursorOffsetInExcerpt; + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character }, + end: { line: 0, character }, + }, + newText: 'p', + }, + ], + newCursor: { line: 0, character: character + 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'a'); + await expectCallCount(calls, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 2); + + expect(calls[1].request.editHistory).toHaveLength(1); + expect(calls[1].request.editHistory[0]?.source).toBe('user'); + expect(calls[1].request.editHistory[0]?.diff).toContain('+xab'); + + for (let count = 3; count <= 12; count++) { + if (count % 2 === 1) { + await waitFor( + () => predictionElements(fixture.container).length > 0, + { timeout: PREDICT_TIMEOUT } + ); + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe( + true + ); + } else { + dispatchTextInput(fixture.content, 'u'); + } + await expectCallCount(calls, count); + } + + expect(calls[11].request.editHistory.map(({ source }) => source)).toEqual( + [ + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + ] + ); + } finally { + await fixture.cleanup(); + } + }); + + test('removes an immediately undone user edit and records its redo', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'a'); + await expectCallCount(calls, 1); + expect(calls[0].request.editHistory[0]?.diff).toContain('+xa'); + + fixture.editor.undo(); + await expectCallCount(calls, 2); + expect(calls[1].request.editHistory).toHaveLength(0); + + fixture.editor.redo(); + await expectCallCount(calls, 3); + expect(calls[2].request.editHistory).toHaveLength(1); + expect(calls[2].request.editHistory[0]?.diff).toContain('+xa'); + } finally { + await fixture.cleanup(); + } + }); + + test('subtle predictions are visible only while Alt is held', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 3 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { mode: 'subtle', provider }, + }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + await wait(0); + + expect(hasVisiblePrediction(fixture.container)).toBe(false); + expect(fixture.editor.getText()).toBe('ab'); + + dispatchKey(fixture.content, 'Alt', { altKey: true }); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + expect(hasVisiblePrediction(fixture.container)).toBe(true); + expect(fixture.editor.getText()).toBe('ab'); + + dispatchKey(fixture.content, 'Alt', {}, 'keyup'); + await waitFor(() => !hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + expect(hasVisiblePrediction(fixture.container)).toBe(false); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('ab '); + expect(fixture.editor.getText()).not.toContain('!'); + } finally { + await fixture.cleanup(); + } + }); + + test('accepts a subtle prediction with Alt+Tab', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { mode: 'subtle', provider }, + }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchKey(fixture.content, 'Alt', { altKey: true }); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + + const tab = dispatchKey(fixture.content, 'Tab', { altKey: true }); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('a!'); + } finally { + await fixture.cleanup(); + } + }); + + const filterCases: Array<{ + allowed: boolean; + name: string; + options: Omit< + NonNullable['editPrediction']>, + 'provider' + >; + }> = [ + { + allowed: true, + name: 'exact-string include allows the path', + options: { include: [FILE_NAME] }, + }, + { + allowed: true, + name: 'regular-expression include allows the path', + options: { include: [/\.ts$/] }, + }, + { + allowed: true, + name: 'glob include allows the path', + options: { include: ['**/*.ts'] }, + }, + { + allowed: false, + name: 'an unmatched include blocks the path', + options: { include: ['src/other.ts'] }, + }, + { + allowed: false, + name: 'exact-string exclude overrides an include', + options: { include: [FILE_NAME], exclude: [FILE_NAME] }, + }, + { + allowed: false, + name: 'regular-expression exclude overrides an include', + options: { include: [FILE_NAME], exclude: [/edit\.ts$/] }, + }, + ]; + + for (const { allowed, name, options } of filterCases) { + test(name, async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { ...options, provider }, + }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS); + expect(calls).toHaveLength(allowed ? 1 : 0); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + } + + test('reuses a frozen global regular-expression include', async () => { + const calls: PredictionCall[] = []; + const include = Object.freeze(/\.ts$/g); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: request.cursorOffsetInExcerpt }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { include: [include], provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + dispatchTextInput(fixture.content, 'c'); + await expectCallCount(calls, 2); + expect(include.lastIndex).toBe(0); + } finally { + await fixture.cleanup(); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10f02ccc..15158223a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ catalogs: '@typescript/native-preview': specifier: 7.0.0-dev.20260622.1 version: 7.0.0-dev.20260622.1 + '@vercel/firewall': + specifier: 1.2.1 + version: 1.2.1 '@vitejs/plugin-react': specifier: 5.0.3 version: 5.0.3 @@ -483,6 +486,9 @@ importers: '@shikijs/transformers': specifier: 4.4.1 version: 4.4.1 + '@vercel/firewall': + specifier: 'catalog:' + version: 1.2.1 '@vscode/web-custom-data': specifier: 'catalog:' version: 0.6.3 @@ -2905,6 +2911,10 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vercel/firewall@1.2.1': + resolution: {integrity: sha512-WlxjEPpf+GWYMontNNZqZjvLL40ziGwy9swwI9da/L1fB/syAO1S2fMtlBXkp4EGVF6nlXSKmwvkUd6YPWJbbg==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.0.3': resolution: {integrity: sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8196,6 +8206,8 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@vercel/firewall@1.2.1': {} + '@vitejs/plugin-react@5.0.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4356474b3..686083eb7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -91,6 +91,7 @@ catalog: '@types/node': '20.19.41' '@types/react': '19.2.7' '@types/react-dom': '19.2.3' + '@vercel/firewall': '1.2.1' '@vscode/vsce': '3.2.2' '@typescript/native-preview': '7.0.0-dev.20260622.1' '@vscode/web-custom-data': '0.6.3'