From 303ba7f5742a0f2d1942f42d69216fb4eb28e24e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Jul 2026 02:33:16 +0800 Subject: [PATCH 01/11] [diffs/edit] add inline edit prediction support --- apps/docs/.env.example | 5 +- apps/docs/app/(diffs)/_edit/EditPage.tsx | 25 + .../app/(diffs)/_edit/EditPredictionDemo.tsx | 272 +++++ apps/docs/app/(diffs)/_edit/constants.ts | 70 +- .../app/(diffs)/api/edit-prediction/route.ts | 310 ++++++ apps/docs/app/(diffs)/edit/page.tsx | 13 +- packages/diffs/src/editor/editPrediction.ts | 524 ++++++++++ packages/diffs/src/editor/editor.css | 58 ++ packages/diffs/src/editor/editor.ts | 961 +++++++++++++++-- packages/diffs/test/editorPrediction.test.ts | 983 ++++++++++++++++++ 10 files changed, 3125 insertions(+), 96 deletions(-) create mode 100644 apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx create mode 100644 apps/docs/app/(diffs)/api/edit-prediction/route.ts create mode 100644 packages/diffs/src/editor/editPrediction.ts create mode 100644 packages/diffs/test/editorPrediction.test.ts diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 5e757564c..63c47de92 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -17,4 +17,7 @@ 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 edit prediction demo +MISTRAL_API_KEY="" diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 00f1a1032..9365459f2 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 { EditShortcuts } from './EditShortcuts'; import { FindDemo } from './FindDemo'; @@ -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,26 @@ export function EditPage({ prerenderedDiff={liveEditingDiff} /> +
+ + Pause after typing or moving the cursor to preview an edit + prediction, then press Tab to accept it. This + demo connects the service-agnostic predict() API + to Codestral. 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 CURSOR_ANCHOR = 'return items.'; + +export function EditPredictionDemo({ + prerenderedFile, + prerenderedDiff, +}: EditPredictionDemoProps) { + const editorRef = useRef | null>(null); + const predictionEnabledRef = useRef(false); + const [attached, setAttached] = 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('/api/edit-prediction', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + 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 lines = editor.getText().split(/\r\n|\r|\n/); + const line = lines.findIndex((text) => text.includes(CURSOR_ANCHOR)); + if (line < 0) { + return; + } + const character = lines[line].indexOf(CURSOR_ANCHOR) + CURSOR_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 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 = !predictionEnabled + ? 'No API request sent. Try Codestral to begin.' + : status === 'waiting' + ? 'Waiting 300 ms before predicting…' + : status === 'predicting' + ? 'Predicting…' + : status === 'ready' + ? mode === 'subtle' + ? 'Prediction ready — hold Alt and press Tab to accept.' + : 'Prediction ready — press Tab to accept.' + : status === 'empty' + ? 'No suggestion returned. Keep editing to try again.' + : status === 'error' + ? 'Prediction unavailable. Check the demo service and try again.' + : 'Try Codestral to begin.'; + + return ( +
+
+ handleSurfaceChange(value as Surface)} + aria-label="Surface" + > + File + FileDiff + + + handleModeChange(value as PredictionMode)} + aria-label="Prediction mode" + > + Eager + Subtle + + + + + + + {statusText} + +
+ + {surface === 'file' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index 8e72794ce..9a70ab793 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -1,6 +1,13 @@ -import { DEFAULT_THEMES, type FileContents } from '@pierre/diffs'; +import { + DEFAULT_THEMES, + type FileContents, + parseDiffFromFile, +} from '@pierre/diffs'; 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. @@ -11,6 +18,46 @@ const EDITABLE_FILE_OPTIONS: FileOptions = { useTokenTransformer: true, }; +export 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 // lines, so keep the two in sync if the contents change. export const MARKER_DEMO_FILE: FileContents = { @@ -386,6 +433,25 @@ export const SHORTCUTS_DEMO_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: { + theme: DEFAULT_THEMES, + themeType: 'dark', + diffStyle: 'unified', + useTokenTransformer: true, + }, + }; + export const MARKER_DEMO_FILE_EXAMPLE: PreloadFileOptions = { file: MARKER_DEMO_FILE, options: EDITABLE_FILE_OPTIONS, diff --git a/apps/docs/app/(diffs)/api/edit-prediction/route.ts b/apps/docs/app/(diffs)/api/edit-prediction/route.ts new file mode 100644 index 000000000..199a9363f --- /dev/null +++ b/apps/docs/app/(diffs)/api/edit-prediction/route.ts @@ -0,0 +1,310 @@ +import type { + EditPredictRequest, + EditPredictResponse, +} from '@pierre/diffs/edit'; +import { z } from 'zod'; + +const CACHE_CONTROL = 'no-store'; +const CODESTRAL_FIM_URL = 'https://api.mistral.ai/v1/fim/completions'; +const MAX_HISTORY_BYTES = 6144; +const MAX_OUTPUT_BYTES = 32 * 1024; +const MAX_REQUEST_BYTES = 128 * 1024; +const MAX_UPSTREAM_BYTES = 64 * 1024; +const textEncoder = new TextEncoder(); + +const requestSchema = z + .object({ + path: z + .string() + .min(1) + .max(1024) + .refine((path) => !/[\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 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_BYTES + ) + ) { + return createErrorResponse('Invalid edit prediction request.', 400); + } + + 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: request.signal, + }); + } catch { + return createErrorResponse( + request.signal.aborted + ? 'Edit prediction was cancelled.' + : 'Edit prediction service is unavailable.', + request.signal.aborted ? 499 : 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 { + return createErrorResponse('Invalid edit prediction response.', 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/app/(diffs)/edit/page.tsx b/apps/docs/app/(diffs)/edit/page.tsx index ac3859e05..b3bc83912 100644 --- a/apps/docs/app/(diffs)/edit/page.tsx +++ b/apps/docs/app/(diffs)/edit/page.tsx @@ -2,6 +2,8 @@ import { preloadFile, preloadFileDiff } from '@pierre/diffs/ssr'; import type { Metadata } from 'next'; import { + 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() { ; +} + +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 base?: string; + readonly hunk: string; + readonly start: number; + readonly end: number; + readonly at: number; + readonly source: 'user' | 'prediction'; +} + +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 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[] +): { + oldLineCount: number; + newLineCount: number; + prefixLines: number; + suffixLines: number; +} { + 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 +): string | 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 oldStart = Math.max(0, bounds.prefixLines - 3); + const newStart = Math.max(0, bounds.prefixLines - 3); + const oldEnd = Math.min(bounds.oldLineCount, oldChangedEnd + 3); + const newEnd = Math.min(bounds.newLineCount, newChangedEnd + 3); + const oldCount = oldEnd - oldStart; + const newCount = newEnd - newStart; + const output = [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -${oldCount === 0 ? oldStart : oldStart + 1},${oldCount} +${ + newCount === 0 ? newStart : newStart + 1 + },${newCount} @@`, + ]; + for (let line = oldStart; 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 + : undefined; +} + +export function recordEditPrediction( + history: readonly EditPredictionHistoryRecord[], + path: string, + oldText: string, + newText: string, + source: 'user' | 'prediction', + at: number = Date.now() +): EditPredictionHistoryRecord[] { + const kept = history.slice(-MAX_HISTORY_ENTRIES); + if (oldText === newText) { + return kept; + } + const oldStarts = lineStarts(oldText); + const initial = lineDiffBounds( + oldText, + oldStarts, + newText, + lineStarts(newText) + ); + const start = initial.prefixLines; + const end = initial.oldLineCount - initial.suffixLines; + const last = kept.at(-1); + const gap = + last !== undefined && start > last.end + ? start - last.end + : last !== undefined && last.start > end + ? last.start - end + : 0; + const mergeBase = last?.base; + let merge = + last !== undefined && + mergeBase !== undefined && + last.path === path && + last.source === source && + at - last.at < COALESCE_MS && + gap <= COALESCE_LINES; + let base = merge ? mergeBase! : oldText; + let hunk = formatEditHunk(path, base, newText); + if (hunk === undefined) { + if (merge && base === newText) { + kept.pop(); + } else if (merge) { + base = oldText; + hunk = formatEditHunk(path, base, newText); + merge = false; + } + if (hunk === undefined) { + return kept; + } + } + if (merge) { + kept.pop(); + } + const previous = kept.at(-1); + if (previous?.base !== undefined) { + kept[kept.length - 1] = { ...previous, base: undefined }; + } + const merged = lineDiffBounds( + base, + lineStarts(base), + newText, + lineStarts(newText) + ); + kept.push({ + path, + base, + hunk, + start: merged.prefixLines, + end: merged.newLineCount - merged.suffixLines, + at, + source, + }); + return kept.slice(-MAX_HISTORY_ENTRIES); +} + +function expandLinewise( + costs: readonly number[], + first: number, + last: number, + remaining: number, + preferBefore: boolean +): { first: number; last: number } { + while (remaining > 0 && (first > 0 || last < costs.length - 1)) { + let expanded = false; + if (preferBefore) { + if (first > 0 && costs[first - 1] <= remaining) { + remaining -= costs[--first]; + expanded = true; + } + if (last < costs.length - 1 && costs[last + 1] <= remaining) { + remaining -= costs[++last]; + expanded = true; + } + } else { + if (last < costs.length - 1 && costs[last + 1] <= remaining) { + remaining -= costs[++last]; + expanded = true; + } + if (first > 0 && costs[first - 1] <= remaining) { + remaining -= costs[--first]; + expanded = true; + } + } + if (!expanded) { + break; + } + } + return { first, last }; +} + +export function buildEditPredictionRequest( + path: string, + version: number, + content: string, + cursorOffset: number, + history: readonly EditPredictionHistoryRecord[] +): EditPredictRequest | undefined { + const starts = lineStarts(content); + const normalizedCursor = Number.isFinite(cursorOffset) + ? Math.trunc(cursorOffset) + : 0; + let cursor = Math.max(0, Math.min(normalizedCursor, content.length)); + const previous = content.charCodeAt(cursor - 1); + const next = content.charCodeAt(cursor); + if ( + cursor > 0 && + cursor < content.length && + ((previous === 13 && next === 10) || + (previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff)) + ) { + cursor--; + } + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (starts[middle] <= cursor) { + low = middle; + } else { + high = middle - 1; + } + } + const cursorLine = low; + const tokenCosts = starts.map((start, line) => + Math.max( + 1, + Math.floor( + textEncoder.encode(content.slice(start, lineEnd(content, starts, line))) + .byteLength / 3 + ) + ) + ); + + let editableFirst = cursorLine; + let editableLast = cursorLine; + const initialBudget = Math.floor((EDITABLE_TOKENS * 3) / 4); + let remaining = Math.max(0, initialBudget - tokenCosts[cursorLine]); + while ( + remaining > 0 && + (editableFirst > 0 || editableLast < tokenCosts.length - 1) + ) { + if ( + editableLast < tokenCosts.length - 1 && + tokenCosts[editableLast + 1] <= remaining + ) { + remaining -= tokenCosts[++editableLast]; + } else if (editableLast < tokenCosts.length - 1) { + break; + } + if ( + editableFirst > 0 && + remaining > 0 && + tokenCosts[editableFirst - 1] <= remaining + ) { + remaining -= tokenCosts[--editableFirst]; + } else if (editableFirst > 0 && remaining > 0) { + break; + } + } + remaining += EDITABLE_TOKENS - initialBudget; + ({ first: editableFirst, last: editableLast } = expandLinewise( + tokenCosts, + editableFirst, + editableLast, + remaining, + true + )); + + let contextFirst = editableFirst; + let contextLast = editableLast; + ({ first: contextFirst, last: contextLast } = expandLinewise( + tokenCosts, + contextFirst, + contextLast, + CONTEXT_TOKENS, + true + )); + let editableTokens = 0; + for (let line = editableFirst; line <= editableLast; line++) { + editableTokens += tokenCosts[line]; + } + let contextTokens = 0; + for (let line = contextFirst; line <= contextLast; line++) { + contextTokens += tokenCosts[line]; + } + if ( + editableTokens > MAX_EDITABLE_TOKENS || + contextTokens > MAX_CONTEXT_TOKENS + ) { + return; + } + + const contextStart = starts[contextFirst]; + const contextEnd = lineEnd(content, starts, contextLast); + const excerptText = content.slice(contextStart, contextEnd); + const request: EditPredictRequest = { + path, + version, + eol: + (excerptText.match(/\r\n|\r|\n/)?.[0] as + | '\n' + | '\r\n' + | '\r' + | undefined) ?? + (content.match(/\r\n|\r|\n/)?.[0] as '\n' | '\r\n' | '\r' | undefined) ?? + '\n', + excerptText, + excerptStartLine: contextFirst, + cursorOffsetInExcerpt: cursor - contextStart, + editableRange: { + start: starts[editableFirst] - contextStart, + end: lineEnd(content, starts, 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 (pattern instanceof RegExp) { + const lastIndex = pattern.lastIndex; + pattern.lastIndex = 0; + const matches = pattern.test(path); + pattern.lastIndex = lastIndex; + return matches; + } + + 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..81eac577f 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -69,9 +69,12 @@ 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-replacement-range], [data-marker-range] { position: absolute; top: 0; @@ -80,7 +83,62 @@ 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-replacement-range] { + background-color: var(--diffs-edit-prediction-bg, var(--diffs-bg)); +} +[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] { + z-index: 1; + background: linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + var( + --diffs-editor-edit-prediction-deletion-fg, + color-mix(in lab, var(--diffs-deletion-base) 70%, transparent) + ) + calc(50% - 0.5px), + var( + --diffs-editor-edit-prediction-deletion-fg, + color-mix(in lab, var(--diffs-deletion-base) 70%, transparent) + ) + calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +[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 35f5298d3..f897a09d7 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -28,6 +28,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, @@ -131,57 +139,12 @@ import { 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. */ @@ -196,9 +159,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. @@ -208,10 +171,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. @@ -265,9 +256,20 @@ 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' + | 'editPredictionReplacement'; export class Editor implements DiffsEditor { #options: EditorOptions; @@ -294,13 +296,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 }; @@ -335,12 +330,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; @@ -387,6 +376,28 @@ 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; + }; + #editPredictionHistory: EditPredictionHistoryRecord[] = []; + #editPredictionHistoryText?: string; + #pendingEditPredictionHistorySource?: 'user' | 'prediction'; + #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>, @@ -422,6 +433,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, @@ -432,7 +444,7 @@ export class Editor implements DiffsEditor { ) { const file = this.#fileInstance.__getCurrentFile?.() ?? this.#fileInfo; if (file !== undefined) { - requirePersistedCacheKey(file); + assertCacheKey(file); } } this.#options = nextOptions; @@ -450,6 +462,16 @@ export class Editor implements DiffsEditor { this.#stateStorageOption = undefined; this.#pendingStateWrites.clear(); } + if (this.#options.editPrediction !== previousEditPrediction) { + this.#cancelEditPrediction(true); + this.#editPredictionHistory = []; + this.#editPredictionHistoryText = + this.#options.editPrediction === undefined + ? undefined + : this.#textDocument?.getText(); + this.#pendingEditPredictionHistorySource = undefined; + this.#scheduleEditPrediction(); + } } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -460,7 +482,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(); @@ -730,6 +752,13 @@ export class Editor implements DiffsEditor { cleanUp(recycle = false): void { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); + this.#editPredictionAltPressed = false; + if (!recycle) { + this.#editPredictionHistory = []; + this.#editPredictionHistoryText = undefined; + this.#pendingEditPredictionHistorySource = undefined; + } if (!recycle) { this.#attachState.delivered = false; } @@ -816,12 +845,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) ) { @@ -938,7 +967,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: @@ -947,6 +976,7 @@ export class Editor implements DiffsEditor { if (shouldRebuildDocument) { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); let contents = ''; if ('contents' in fileOrDiff) { contents = fileOrDiff.contents; @@ -967,6 +997,12 @@ export class Editor implements DiffsEditor { new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; + this.#editPredictionHistory = []; + this.#editPredictionHistoryText = + this.#options.editPrediction === undefined + ? undefined + : textDocument.getText(); + this.#pendingEditPredictionHistorySource = undefined; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -1023,6 +1059,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', @@ -1197,7 +1234,7 @@ export class Editor implements DiffsEditor { return; } - const cacheKey = requirePersistedCacheKey(fileInfo); + const cacheKey = assertCacheKey(fileInfo); this.#textDocumentCache.set(cacheKey, textDocument); let storage: IStateStorage; @@ -1253,7 +1290,7 @@ export class Editor implements DiffsEditor { } catch { return; } - if (!isPromise(result)) { + if (!(result instanceof Promise)) { return; } @@ -1288,7 +1325,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; } @@ -1301,7 +1338,7 @@ export class Editor implements DiffsEditor { } catch { return; } - if (isPromise(result)) { + if (result instanceof Promise) { return result.then(applyState).catch(() => {}); } else { try { @@ -1313,7 +1350,7 @@ export class Editor implements DiffsEditor { const pendingWrite = this.#pendingStateWrites.get(cacheKey); const result = pendingWrite === undefined ? readState() : pendingWrite.then(readState); - if (isPromise(result)) { + if (result instanceof Promise) { const pendingRestore = { cacheKey, textDocument, @@ -1577,15 +1614,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) @@ -1698,6 +1752,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 } @@ -1709,6 +1770,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 } @@ -1890,6 +1966,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; + } + // handle the cursor move events manually for multiple selections and virtual viewport const mvShortcut = isMoveCursorShortcut(e); const textDocument = this.#textDocument; @@ -2052,6 +2146,7 @@ export class Editor implements DiffsEditor { return; } if (e.inputType === 'insertCompositionText') { + this.#cancelEditPrediction(true); return; } e.preventDefault(); @@ -2073,6 +2168,7 @@ export class Editor implements DiffsEditor { if (!targetIsContentElement(e)) { return; } + this.#cancelEditPrediction(true); this.#isComposing = true; this.#shouldIgnoreSelectionChange = true; }, @@ -4015,9 +4111,600 @@ export class Editor implements DiffsEditor { }); } + #removeRenderedEditPrediction(): void { + this.#contentElement?.style.removeProperty('padding-block-end'); + 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.response.edits) { + if ( + edit.newText.length === 0 || + !this.#isLineVisible(edit.range.start.line) + ) { + continue; + } + let count = 0; + for (let index = 0; index < edit.newText.length; index++) { + const char = edit.newText.charCodeAt(index); + if (char === 10) { + count++; + } else if (char === 13) { + count++; + if (edit.newText.charCodeAt(index + 1) === 10) { + index++; + } + } + } + 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(); + } + } + + #scheduleEditPrediction(): void { + 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; + } + + const normalizedPath = path.replaceAll('\\', '/'); + if ( + (options.include !== undefined && + !options.include.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + )) || + options.exclude?.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + ) === true + ) { + return; + } + + this.#recordEditPredictionHistory( + this.#pendingEditPredictionHistorySource ?? 'user' + ); + const request = buildEditPredictionRequest( + path, + document.version, + document.getText(), + 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); + const resolvedEdit = document.resolveEdits([edit])[0]; + if (resolvedEdit.start !== start || resolvedEdit.end !== end) { + return; + } + resolvedEdits.push(resolvedEdit); + } + 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; + } + } + + this.#editPrediction = { + document, + version: request.version, + cursorOffset, + rendered: false, + response: { + edits: edits.map((edit) => ({ + range: { + start: document.positionAt(edit.start), + end: document.positionAt(edit.end), + }, + newText: edit.text, + })), + newCursor: { ...newCursor }, + }, + }; + this.#updateSelections(this.#selections); + }) + .catch(() => {}) + .finally(() => { + if (this.#editPredictionAbortController === controller) { + this.#editPredictionAbortController = undefined; + } + }); + }, EDIT_PREDICTION_DEBOUNCE_MS); + } + + #recordEditPredictionHistory(source: 'user' | 'prediction'): void { + const textDocument = this.#textDocument; + const path = this.#fileInfo?.name; + if ( + this.#options.editPrediction === undefined || + textDocument === undefined || + path === undefined + ) { + return; + } + const text = textDocument.getText(); + const previousText = this.#editPredictionHistoryText; + this.#editPredictionHistoryText = text; + this.#pendingEditPredictionHistorySource = undefined; + if (previousText === undefined || previousText === text) { + return; + } + this.#editPredictionHistory = recordEditPrediction( + this.#editPredictionHistory, + path, + previousText, + text, + 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; + } + + for ( + let editIndex = 0; + editIndex < prediction.response.edits.length; + editIndex++ + ) { + const edit = prediction.response.edits[editIndex]; + const { start, end } = edit.range; + const isDeletion = edit.newText.length === 0; + const isReplacement = comparePosition(start, end) !== 0; + if (isReplacement) { + const elementCount = renderCtx.elements.size; + this.#renderSelection( + renderCtx, + isDeletion ? 'editPredictionDeletion' : 'editPredictionReplacement', + { start, end } + ); + prediction.rendered ||= renderCtx.elements.size > elementCount; + } + + 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 (this.#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/); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = h( + 'span', + { + dataset: + lines[lineIndex].length === 0 + ? ['editPredictionLine', 'empty'] + : 'editPredictionLine', + textContent: + lines[lineIndex].length === 0 ? '\u200b' : lines[lineIndex], + }, + element + ); + 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[]) { 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) { + this.#cancelEditPrediction(true); + } + this.#syncEditPredictionSpacers(); + this.#primaryCaretElement = undefined; this.#setEditorActiveLineSafe(null); @@ -4031,6 +4718,9 @@ export class Editor implements DiffsEditor { this.#overlayElements?.clear(); this.#selectionAction?.cleanup(); this.#selectionAction = undefined; + if (selectionsChanged) { + this.#scheduleEditPrediction(); + } return; } @@ -4167,12 +4857,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) { + this.#scheduleEditPrediction(); + } } #renderSelection( @@ -4180,7 +4900,7 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, range: Range, extraDataset?: string ) { @@ -4277,7 +4997,7 @@ export class Editor implements DiffsEditor { startChar: number, endChar: number, isLastLine: boolean, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, extraDataset?: string ) { const wrapOffsets = this.#wrapLineText(line); @@ -4373,7 +5093,7 @@ export class Editor implements DiffsEditor { width: number; }; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, line: number, wrapLine: number, left: number, @@ -4531,6 +5251,17 @@ export class Editor implements DiffsEditor { rangeEl.style.width = `${width}px`; rangeEl.style.transform = `translateX(${left}px) translateY(${y}px)`; + if (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); } @@ -5128,8 +5859,19 @@ export class Editor implements DiffsEditor { change: TextDocumentChange, newSelections?: EditorSelection[], newLineAnnotations?: DiffLineAnnotation[], - options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } + options?: { + skipSearchRefresh?: boolean; + skipFocus?: boolean; + editSource?: 'user' | 'prediction'; + } ) { + if (options?.editSource === 'prediction') { + this.#recordEditPredictionHistory('prediction'); + } else { + this.#pendingEditPredictionHistorySource = 'user'; + } + this.#scheduleEditPrediction(); + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { @@ -5683,3 +6425,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/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts new file mode 100644 index 000000000..d6ebb7752 --- /dev/null +++ b/packages/diffs/test/editorPrediction.test.ts @@ -0,0 +1,983 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; +import { DEFAULT_THEMES } from '../src/constants'; +import { + Editor, + type EditorOptions, + type EditPredictContext, + type EditPredictProvider, + type EditPredictRequest, + type EditPredictResponse, +} from '../src/editor/editor'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import { installDom, wait, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +const FILE_NAME = 'src/edit.ts'; +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 { + setCaret(fixture.editor, 0, 3); + dispatchTextInput(fixture.content, 'X'); + + await wait(250); + expect(calls).toHaveLength(0); + await expectCallCount(calls, 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 { + 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('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 { + setCaret(fixture.editor, 0, 0); + const event = dispatchKey(fixture.content, 'ArrowRight'); + expect(event.defaultPrevented).toBe(true); + + await wait(250); + expect(calls).toHaveLength(0); + await expectCallCount(calls, 1); + expect(calls[0].request).toMatchObject({ + cursorOffsetInExcerpt: 1, + excerptText: 'abc', + version: 0, + }); + } finally { + 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'); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe(typedText); + } 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).not.toBe(''); + + 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('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('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(); + } + }); + + 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 { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + if (allowed) { + await expectCallCount(calls, 1); + } else { + await wait(340); + expect(calls).toHaveLength(0); + } + } finally { + await fixture.cleanup(); + } + }); + } + + test('reuses a global regular-expression include', async () => { + const calls: PredictionCall[] = []; + const include = /\.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(); + } + }); +}); From f390ea05e545b51f080beaeaec2e81978c8a4295 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 26 Jul 2026 01:52:21 +0800 Subject: [PATCH 02/11] Update docs --- apps/docs/app/(diffs)/_docs/DocsPage.tsx | 4 ++ apps/docs/app/(diffs)/docs/Edit/constants.ts | 56 +++++++++++++++++++- apps/docs/app/(diffs)/docs/Edit/content.mdx | 56 +++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 20ba43c39..ec6120c60 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -34,6 +34,7 @@ import { EDIT_LAZY_FILE_EXAMPLE, EDIT_MARKER_EXAMPLE, EDIT_MARKER_TYPE, + EDIT_PREDICTION_EXAMPLE, EDIT_REACT_CODE_VIEW_EXAMPLE, EDIT_REACT_EXAMPLE, EDIT_REACT_FILE_DIFF_EXAMPLE, @@ -427,6 +428,7 @@ async function EditSection() { editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPredictionExample, editorOptionsType, editorPublicApi, editSelectionActionContextType, @@ -446,6 +448,7 @@ async function EditSection() { preloadFile(EDIT_VANILLA_FILE_DIFF_EXAMPLE), preloadFile(EDIT_VANILLA_CODE_VIEW_EXAMPLE), preloadFile(EDIT_LAZY_FILE_EXAMPLE), + preloadFile(EDIT_PREDICTION_EXAMPLE), preloadFile(EDITOR_OPTIONS_TYPE), preloadFile(EDITOR_PUBLIC_API), preloadFile(EDIT_SELECTION_ACTION_CONTEXT_TYPE), @@ -468,6 +471,7 @@ async function EditSection() { editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPredictionExample, editorOptionsType, editorPublicApi, editSelectionActionContextType, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index a5fa56fd1..5493569ce 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('/api/edit-prediction', { + 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', @@ -936,7 +974,11 @@ export const EDITOR_OPTIONS_TYPE: PreloadFileOptions = { FileContents, LineAnnotation, } from '@pierre/diffs'; -import { Editor, type IStateStorage } from '@pierre/diffs/edit'; +import { + Editor, + type EditPredictProvider, + type IStateStorage, +} from '@pierre/diffs/edit'; interface EditorOptions { // Max undo stack entries @@ -967,6 +1009,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 diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index dc56573b5..b874fcf90 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 @@ -369,6 +370,57 @@ implementation. It defaults to `'inMemory'`. Text documents and undo history remain scoped to the `Editor` instance; IndexedDB and custom storage persist only serializable item-local editor state. +### 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). + +The editor schedules `predict()` 300 ms after typing or moving to a single, +collapsed caret. Each new document or cursor change clears the current +prediction, cancels the pending debounce, and aborts in-progress work through +`context.signal`. Forward that signal to `fetch()` or any other cancellable +model call. Responses for an old document version or cursor position are ignored +even if the provider does not stop promptly. + +`mode` defaults to `'eager'`, which displays a ready prediction immediately. +`'subtle'` still runs prediction after the debounce, but hides the ghost text +until the user holds Alt. Pressing Tab accepts a visible +prediction and moves the caret to the response's `newCursor`; otherwise Tab +keeps its normal indentation behavior. Multiline predictions render continuation +text as numberless ghost rows, preserving the document's real line numbers. + +Use `include` and `exclude` to filter the file path passed to the surface. Both +accept strings or regular expressions. String patterns match the whole +slash-normalized path and support `?`, segment-local `*`, and cross-segment +`**`. Omitting `include` enables every path, while `include: []` disables +prediction for every path. Exclusions always take precedence. + +`EditPredictRequest` contains the file `path`, document `version`, detected +`eol`, a bounded `excerptText`, and bounded chronological `editHistory`. Its +`excerptStartLine` is a zero-based document line; `cursorOffsetInExcerpt` and +the half-open `editableRange` are UTF-16 offsets relative to the excerpt. +History entries contain a unified `diff` and a `source` of either `'user'` or +`'prediction'`. + +Return an `EditPredictResponse` whose non-overlapping `edits` use absolute, +zero-based document positions and stay within the request's editable window. +`newCursor` is also absolute and refers to the post-edit document. Use a +collapsed range to insert, an empty `newText` to delete, or a non-empty range +and `newText` to replace. Return `edits: []` with a valid `newCursor` when there +is no suggestion. + ### API Reference These methods are available on an `Editor` instance. Attach it to a rendered @@ -470,7 +522,9 @@ and End keys; on macOS, the modifier with ↑ and ↓ arrows works to | Action | Shortcut | | ---------------------------- | ----------------------------------------------------------------------------- | -| Indent line or selection | Tab | +| Accept an eager prediction | Tab | +| Accept a subtle prediction | Tab | +| Indent line or selection | Tab (no visible prediction) | | Outdent line or selection | Tab | | Move selected line(s) up | | | Move selected line(s) down | | From b8074618e54b97fa2af44e47a69021b53cd68654 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 07:13:12 +0800 Subject: [PATCH 03/11] Add GitHub OAuth support for edit prediction demo --- apps/docs/.env.example | 6 + .../app/(diffs)/_edit/EditPredictionDemo.tsx | 190 +++++++++++--- apps/docs/app/(diffs)/docs/Edit/constants.ts | 2 +- apps/docs/app/(diffs)/edit/_auth/github.ts | 98 +++++++ apps/docs/app/(diffs)/edit/auth/route.ts | 246 ++++++++++++++++++ .../edit-prediction => edit/predict}/route.ts | 6 + 6 files changed, 516 insertions(+), 32 deletions(-) create mode 100644 apps/docs/app/(diffs)/edit/_auth/github.ts create mode 100644 apps/docs/app/(diffs)/edit/auth/route.ts rename apps/docs/app/(diffs)/{api/edit-prediction => edit/predict}/route.ts (98%) diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 63c47de92..9f49bb18a 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -21,3 +21,9 @@ CODE_STORAGE_SYNC_PRIVATE_KEY="" # Mistral API Key for edit prediction demo MISTRAL_API_KEY="" + +# GitHub OAuth Client ID for edit prediction demo +GITHUB_OAUTH_CLIENT_ID="" + +# GitHub OAuth Client Secret for edit prediction demo +GITHUB_OAUTH_CLIENT_SECRET="" diff --git a/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx b/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx index 45c1351ad..4c2e3e892 100644 --- a/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx @@ -12,7 +12,7 @@ import type { PreloadedFileResult, PreloadFileDiffResult, } from '@pierre/diffs/ssr'; -import { IconCursor, IconRefresh } from '@pierre/icons'; +import { IconRefresh } from '@pierre/icons'; import { useCallback, useMemo, useRef, useState } from 'react'; import { EDIT_PREDICTION_NEW_FILE } from './constants'; @@ -36,7 +36,18 @@ type PredictionStatus = const INCLUDE = ['**/*.ts'] as const; const EXCLUDE = ['**/*.test.ts'] as const; -const CURSOR_ANCHOR = 'return items.'; +const statusTextMap = { + idle: 'Idle', + waiting: 'Waiting...', + predicting: 'Predicting...', + ready: ( + <> + Prediction ready — hold Alt and press Tab to accept. + + ), + empty: 'No suggestion returned. Keep editing to try again.', + error: 'Prediction unavailable. Check the demo service and try again.', +}; export function EditPredictionDemo({ prerenderedFile, @@ -45,6 +56,7 @@ export function EditPredictionDemo({ 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); @@ -72,12 +84,16 @@ export function EditPredictionDemo({ setStatus('predicting'); try { - const response = await fetch('/api/edit-prediction', { + 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'); } @@ -145,12 +161,13 @@ export function EditPredictionDemo({ if (editor == null) { return; } + const anchor = 'return items.'; const lines = editor.getText().split(/\r\n|\r|\n/); - const line = lines.findIndex((text) => text.includes(CURSOR_ANCHOR)); + const line = lines.findIndex((text) => text.includes(anchor)); if (line < 0) { return; } - const character = lines[line].indexOf(CURSOR_ANCHOR) + CURSOR_ANCHOR.length; + const character = lines[line].indexOf(anchor) + anchor.length; predictionEnabledRef.current = true; setPredictionEnabled(true); setStatus('waiting'); @@ -165,6 +182,29 @@ export function EditPredictionDemo({ 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); @@ -186,21 +226,11 @@ export function EditPredictionDemo({ [reset] ); - const statusText = !predictionEnabled - ? 'No API request sent. Try Codestral to begin.' - : status === 'waiting' - ? 'Waiting 300 ms before predicting…' - : status === 'predicting' - ? 'Predicting…' - : status === 'ready' - ? mode === 'subtle' - ? 'Prediction ready — hold Alt and press Tab to accept.' - : 'Prediction ready — press Tab to accept.' - : status === 'empty' - ? 'No suggestion returned. Keep editing to try again.' - : status === 'error' - ? 'Prediction unavailable. Check the demo service and try again.' - : 'Try Codestral to begin.'; + const statusText = authenticating + ? 'Checking GitHub sign-in…' + : !predictionEnabled + ? null + : statusTextMap[status]; return (
@@ -223,10 +253,6 @@ export function EditPredictionDemo({ Subtle - + )} +
{surface === 'file' ? ( diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index 5493569ce..e699441ae 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -356,7 +356,7 @@ export const EDIT_PREDICTION_EXAMPLE: PreloadFileOptions = { const provider: EditPredictProvider = { async predict(request, { signal }) { // This server endpoint belongs to your application. - const response = await fetch('/api/edit-prediction', { + const response = await fetch('/edit/predict', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), 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..edba3f208 --- /dev/null +++ b/apps/docs/app/(diffs)/edit/_auth/github.ts @@ -0,0 +1,98 @@ +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 isGithubAuthenticated(request: Request): boolean { + const config = getGithubOAuthConfig(); + const session = getAuthCookie(request, GITHUB_SESSION_COOKIE); + if (config === undefined || session === undefined) { + return false; + } + 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 false; + } + const expected = createHmac('sha256', config.clientSecret) + .update(`${userId}.${expiresAt}`) + .digest('base64url'); + return authValuesMatch(signature, expected); +} 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..5ddff39fa --- /dev/null +++ b/apps/docs/app/(diffs)/edit/auth/route.ts @@ -0,0 +1,246 @@ +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 STATE_MAX_AGE = 60 * 10; + +export const runtime = 'nodejs'; + +export function HEAD(request: Request): Response { + const headers = { 'Cache-Control': CACHE_CONTROL }; + if ( + process.env.NEXT_PUBLIC_SITE !== undefined && + process.env.NEXT_PUBLIC_SITE !== 'diffs' + ) { + 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 (new URL(request.url).searchParams.has('callback')) { + return finishGithubOAuth(request); + } + + if ( + process.env.NEXT_PUBLIC_SITE !== undefined && + process.env.NEXT_PUBLIC_SITE !== 'diffs' + ) { + return new Response('Not found.', { status: 404 }); + } + + 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 { + if ( + process.env.NEXT_PUBLIC_SITE !== undefined && + process.env.NEXT_PUBLIC_SITE !== 'diffs' + ) { + return new Response('Not found.', { status: 404 }); + } + + 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); + } + + 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; login?: unknown }) + : undefined; + if ( + !userResponse.ok || + !Number.isSafeInteger(user?.id) || + Number(user?.id) <= 0 || + typeof user?.login !== 'string' || + user.login.length === 0 + ) { + return authError(request, 'Could not validate the GitHub user.', 502); + } + + try { + 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: request.signal, + } + ); + } catch {} + + const sessionCookie = createGithubSessionCookie(request, Number(user.id)); + 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 }); +} + +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)/api/edit-prediction/route.ts b/apps/docs/app/(diffs)/edit/predict/route.ts similarity index 98% rename from apps/docs/app/(diffs)/api/edit-prediction/route.ts rename to apps/docs/app/(diffs)/edit/predict/route.ts index 199a9363f..4e0a19fc2 100644 --- a/apps/docs/app/(diffs)/api/edit-prediction/route.ts +++ b/apps/docs/app/(diffs)/edit/predict/route.ts @@ -4,6 +4,8 @@ import type { } from '@pierre/diffs/edit'; import { z } from 'zod'; +import { isGithubAuthenticated } from '../_auth/github'; + const CACHE_CONTROL = 'no-store'; const CODESTRAL_FIM_URL = 'https://api.mistral.ai/v1/fim/completions'; const MAX_HISTORY_BYTES = 6144; @@ -53,6 +55,10 @@ export async function POST(request: Request): Promise { return createErrorResponse('Not found.', 404); } + if (!isGithubAuthenticated(request)) { + 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); From aa7de4177443f372d4b06b23dcb7f79df3197e3e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 07:39:40 +0800 Subject: [PATCH 04/11] Update docs --- apps/docs/app/(diffs)/_edit/EditPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 9365459f2..4adfb1dd1 100644 --- a/apps/docs/app/(diffs)/_edit/EditPage.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -62,10 +62,10 @@ export function EditPage({ description={ <> Pause after typing or moving the cursor to preview an edit - prediction, then press Tab to accept it. This - demo connects the service-agnostic predict() API - to Codestral. Switch between File and{' '} - FileDiff. + prediction, then press Tab to accept the + suggestion. This demo connects the service-agnostic{' '} + predict() API to Codestral built by Mistral AI. + Switch between File and FileDiff. } /> From 1925ce2cc7193ffb611dc1b8431d41f3891329db Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 08:34:32 +0800 Subject: [PATCH 05/11] Fix inline suggestion overlap --- packages/diffs/src/editor/editor.css | 8 ++ packages/diffs/src/editor/editor.ts | 86 ++++++++++++++-- packages/diffs/src/style.css | 3 +- packages/diffs/test/editorPrediction.test.ts | 100 +++++++++++++++++++ 4 files changed, 188 insertions(+), 9 deletions(-) diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index 81eac577f..d1965f0c9 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -74,6 +74,7 @@ [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; @@ -102,9 +103,13 @@ 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; @@ -134,6 +139,9 @@ transparent calc(50% + 0.5px) ); } +[data-edit-prediction-insertion-range] { + z-index: 0; +} [data-edit-prediction-replacement-range] { z-index: 1; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index f897a09d7..6f8c01822 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -269,6 +269,7 @@ type OverlayRangeType = | 'marker' | 'bracketMatch' | 'editPredictionDeletion' + | 'editPredictionInsertion' | 'editPredictionReplacement'; export class Editor implements DiffsEditor { @@ -410,6 +411,15 @@ export class Editor implements DiffsEditor { this.#renderRange !== undefined && this.#renderRange.totalLines !== Infinity ) { + const predictionLines = + this.#editPrediction === undefined + ? undefined + : new Set( + this.#editPrediction.response.edits.map( + (edit) => edit.range.start.line + ) + ); + let refreshPrediction = false; const { startingLine, totalLines } = this.#renderRange; const endLine = Math.min( startingLine + totalLines, @@ -420,9 +430,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); + } } }; @@ -4591,6 +4605,7 @@ export class Editor implements DiffsEditor { return; } + const isWrap = this.#isWrap; for ( let editIndex = 0; editIndex < prediction.response.edits.length; @@ -4600,6 +4615,8 @@ export class Editor implements DiffsEditor { 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( @@ -4608,6 +4625,12 @@ export class Editor implements DiffsEditor { { 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)) { @@ -4650,7 +4673,7 @@ export class Editor implements DiffsEditor { delete element.dataset.replacement; element.style.removeProperty('--diffs-edit-prediction-bg'); } - if (this.#isWrap) { + if (isWrap) { element.dataset.wrap = ''; element.style.width = `calc(100cqw - ${lineLeft}px)`; } else { @@ -4658,19 +4681,63 @@ export class Editor implements DiffsEditor { element.style.width = 'max-content'; } const lines = edit.newText.split(/\r\n|\r|\n/); + // Redraw the suffix only when other edits or wrapping cannot relocate it. + let insertionSuffix: Node | undefined; + if ( + isMidLineInsertion && + !isWrap && + prediction.response.edits[editIndex - 1]?.range.end.line !== + start.line && + prediction.response.edits[editIndex + 1]?.range.start.line !== + start.line + ) { + 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: - lines[lineIndex].length === 0 - ? ['editPredictionLine', 'empty'] - : 'editPredictionLine', - textContent: - lines[lineIndex].length === 0 ? '\u200b' : lines[lineIndex], + 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`; } @@ -5251,7 +5318,10 @@ export class Editor implements DiffsEditor { rangeEl.style.width = `${width}px`; rangeEl.style.transform = `translateX(${left}px) translateY(${y}px)`; - if (type === 'editPredictionReplacement') { + if ( + type === 'editPredictionInsertion' || + type === 'editPredictionReplacement' + ) { const lineElement = this.#getLineElement(line); if (lineElement !== undefined) { rangeEl.style.setProperty( 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/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts index d6ebb7752..35a382846 100644 --- a/packages/diffs/test/editorPrediction.test.ts +++ b/packages/diffs/test/editorPrediction.test.ts @@ -436,6 +436,106 @@ describe('Editor edit prediction', () => { } }); + 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} reserves numberless rows for multiline ghost text`, async () => { const contents = 'const value = 1;\nnext();\nend();'; const provider: EditPredictProvider = { From dd8a0911620c6e790dca712db1b0616fd4f6d086 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 11:20:28 +0800 Subject: [PATCH 06/11] refactor --- packages/diffs/src/editor/editPrediction.ts | 519 ++++++++++++++----- packages/diffs/src/editor/editor.ts | 77 ++- packages/diffs/src/editor/textDocument.ts | 41 +- packages/diffs/test/editorPrediction.test.ts | 121 ++++- 4 files changed, 574 insertions(+), 184 deletions(-) diff --git a/packages/diffs/src/editor/editPrediction.ts b/packages/diffs/src/editor/editPrediction.ts index 4a3b5d461..0878592a9 100644 --- a/packages/diffs/src/editor/editPrediction.ts +++ b/packages/diffs/src/editor/editPrediction.ts @@ -1,4 +1,8 @@ import type { Position, TextEdit } from '../types'; +import type { + ResolvedTextEdit, + TextDocumentChangeTransaction, +} from './textDocument'; export interface EditPredictRequest { /** Current file name/path as supplied to the File or FileDiff component. */ @@ -46,12 +50,54 @@ export interface EditPredictProvider { export interface EditPredictionHistoryRecord { readonly path: string; - readonly base?: 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 beforeStart: number; + readonly beforeEnd: number; + readonly afterStart: number; + readonly afterEnd: number; + readonly startLine: number; + readonly beforeChangedStartLine: number; + readonly beforeChangedEndLine: number; + readonly afterChangedStartLine: number; + readonly afterChangedEndLine: number; +} + +interface LineDiffBounds { + readonly oldLineCount: number; + readonly newLineCount: number; + readonly prefixLines: number; + readonly suffixLines: number; } const EDITABLE_TOKENS = 350; @@ -63,6 +109,12 @@ 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[] { @@ -122,12 +174,7 @@ function lineDiffBounds( oldStarts: readonly number[], newText: string, newStarts: readonly number[] -): { - oldLineCount: number; - newLineCount: number; - prefixLines: number; - suffixLines: number; -} { +): LineDiffBounds { const oldLineCount = oldText.length === 0 ? 0 : oldStarts.length; const newLineCount = newText.length === 0 ? 0 : newStarts.length; let prefixLines = 0; @@ -165,8 +212,10 @@ function lineDiffBounds( function formatEditHunk( path: string, oldText: string, - newText: string -): string | undefined { + newText: string, + oldLineOffset = 0, + newLineOffset = oldLineOffset +): { readonly hunk: string; readonly bounds: LineDiffBounds } | undefined { if (oldText === newText) { return; } @@ -196,17 +245,25 @@ function formatEditHunk( return; } - const oldStart = Math.max(0, bounds.prefixLines - 3); - const newStart = Math.max(0, bounds.prefixLines - 3); - const oldEnd = Math.min(bounds.oldLineCount, oldChangedEnd + 3); - const newEnd = Math.min(bounds.newLineCount, newChangedEnd + 3); + const oldStart = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); + const newStart = 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 - oldStart; const newCount = newEnd - newStart; + const oldLine = oldStart + oldLineOffset; + const newLine = newStart + newLineOffset; const output = [ `--- a/${path}`, `+++ b/${path}`, - `@@ -${oldCount === 0 ? oldStart : oldStart + 1},${oldCount} +${ - newCount === 0 ? newStart : newStart + 1 + `@@ -${oldCount === 0 ? oldLine : oldLine + 1},${oldCount} +${ + newCount === 0 ? newLine : newLine + 1 },${newCount} @@`, ]; for (let line = oldStart; line < bounds.prefixLines; line++) { @@ -231,111 +288,292 @@ function formatEditHunk( } const hunk = output.join('\n'); return textEncoder.encode(hunk).byteLength <= MAX_CAPTURE_BYTES - ? hunk + ? { 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, + beforeStart: afterStart, + beforeEnd: afterStart + beforeText.length, + afterStart, + afterEnd, + startLine, + beforeChangedStartLine: startLine + bounds.prefixLines, + beforeChangedEndLine: + startLine + bounds.oldLineCount - bounds.suffixLines, + afterChangedStartLine: startLine + bounds.prefixLines, + afterChangedEndLine: startLine + bounds.newLineCount - bounds.suffixLines, + }; + } + return undefined; +} + export function recordEditPrediction( history: readonly EditPredictionHistoryRecord[], path: string, - oldText: string, - newText: string, + document: EditPredictionDocument, + transaction: TextDocumentChangeTransaction, source: 'user' | 'prediction', at: number = Date.now() ): EditPredictionHistoryRecord[] { const kept = history.slice(-MAX_HISTORY_ENTRIES); - if (oldText === newText) { + 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 oldStarts = lineStarts(oldText); - const initial = lineDiffBounds( - oldText, - oldStarts, - newText, - lineStarts(newText) - ); - const start = initial.prefixLines; - const end = initial.oldLineCount - initial.suffixLines; const last = kept.at(-1); const gap = - last !== undefined && start > last.end - ? start - last.end - : last !== undefined && last.start > end - ? last.start - end + last !== undefined && fragment.beforeChangedStartLine > last.end + ? fragment.beforeChangedStartLine - last.end + : last !== undefined && last.start > fragment.beforeChangedEndLine + ? last.start - fragment.beforeChangedEndLine : 0; - const mergeBase = last?.base; - let merge = + const canMerge = last !== undefined && - mergeBase !== undefined && + last.fragment !== undefined && last.path === path && last.source === source && at - last.at < COALESCE_MS && gap <= COALESCE_LINES; - let base = merge ? mergeBase! : oldText; - let hunk = formatEditHunk(path, base, newText); - if (hunk === undefined) { - if (merge && base === newText) { - kept.pop(); - } else if (merge) { - base = oldText; - hunk = formatEditHunk(path, base, newText); - merge = false; - } - if (hunk === undefined) { - return kept; + + if (canMerge) { + const previous = last.fragment; + const overlapStart = Math.max(previous.currentStart, fragment.beforeStart); + const overlapEnd = Math.min(previous.currentEnd, fragment.beforeEnd); + if ( + overlapStart <= overlapEnd && + previous.currentText.slice( + overlapStart - previous.currentStart, + overlapEnd - previous.currentStart + ) === + fragment.beforeText.slice( + overlapStart - fragment.beforeStart, + overlapEnd - fragment.beforeStart + ) + ) { + const unionStart = Math.min(previous.currentStart, fragment.beforeStart); + const currentText = + previous.currentStart <= fragment.beforeStart + ? previous.currentText + + fragment.beforeText.slice( + Math.max(0, previous.currentEnd - fragment.beforeStart) + ) + : fragment.beforeText + + previous.currentText.slice( + Math.max(0, fragment.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.beforeStart + ? 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; + } + } } } - if (merge) { - kept.pop(); - } + const previous = kept.at(-1); - if (previous?.base !== undefined) { - kept[kept.length - 1] = { ...previous, base: undefined }; + if (previous?.fragment !== undefined) { + kept[kept.length - 1] = { ...previous, fragment: undefined }; } - const merged = lineDiffBounds( - base, - lineStarts(base), - newText, - lineStarts(newText) + const formatted = formatEditHunk( + path, + fragment.beforeText, + fragment.afterText, + fragment.startLine ); + if (formatted === undefined) { + return kept; + } kept.push({ path, - base, - hunk, - start: merged.prefixLines, - end: merged.newLineCount - merged.suffixLines, + hunk: formatted.hunk, + start: fragment.afterChangedStartLine, + end: fragment.afterChangedEndLine, at, source, + fragment: { + baseText: fragment.beforeText, + currentText: fragment.afterText, + currentStart: fragment.afterStart, + currentEnd: fragment.afterEnd, + startLine: fragment.startLine, + }, }); return kept.slice(-MAX_HISTORY_ENTRIES); } function expandLinewise( - costs: readonly number[], + lineCount: number, + costForLine: (line: number) => number, first: number, last: number, remaining: number, preferBefore: boolean ): { first: number; last: number } { - while (remaining > 0 && (first > 0 || last < costs.length - 1)) { + while (remaining > 0 && (first > 0 || last < lineCount - 1)) { let expanded = false; if (preferBefore) { - if (first > 0 && costs[first - 1] <= remaining) { - remaining -= costs[--first]; - expanded = true; + if (first > 0) { + const cost = costForLine(first - 1); + if (cost <= remaining) { + first--; + remaining -= cost; + expanded = true; + } } - if (last < costs.length - 1 && costs[last + 1] <= remaining) { - remaining -= costs[++last]; - expanded = true; + if (last < lineCount - 1) { + const cost = costForLine(last + 1); + if (cost <= remaining) { + last++; + remaining -= cost; + expanded = true; + } } } else { - if (last < costs.length - 1 && costs[last + 1] <= remaining) { - remaining -= costs[++last]; - expanded = true; + if (last < lineCount - 1) { + const cost = costForLine(last + 1); + if (cost <= remaining) { + last++; + remaining -= cost; + expanded = true; + } } - if (first > 0 && costs[first - 1] <= remaining) { - remaining -= costs[--first]; - expanded = true; + if (first > 0) { + const cost = costForLine(first - 1); + if (cost <= remaining) { + first--; + remaining -= cost; + expanded = true; + } } } if (!expanded) { @@ -347,21 +585,27 @@ function expandLinewise( export function buildEditPredictionRequest( path: string, - version: number, - content: string, + document: EditPredictionDocument, cursorOffset: number, history: readonly EditPredictionHistoryRecord[] ): EditPredictRequest | undefined { - const starts = lineStarts(content); + 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, content.length)); - const previous = content.charCodeAt(cursor - 1); - const next = content.charCodeAt(cursor); + 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 < content.length && + cursor < documentLength && ((previous === 13 && next === 10) || (previous >= 0xd800 && previous <= 0xdbff && @@ -370,56 +614,56 @@ export function buildEditPredictionRequest( ) { cursor--; } - let low = 0; - let high = starts.length - 1; - while (low < high) { - const middle = Math.ceil((low + high) / 2); - if (starts[middle] <= cursor) { - low = middle; - } else { - high = middle - 1; + 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 cursorLine = low; - const tokenCosts = starts.map((start, line) => - Math.max( + 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(content.slice(start, lineEnd(content, starts, line))) - .byteLength / 3 - ) - ) - ); + 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 - tokenCosts[cursorLine]); + let remaining = Math.max(0, initialBudget - costForLine(cursorLine)); while ( remaining > 0 && - (editableFirst > 0 || editableLast < tokenCosts.length - 1) + (editableFirst > 0 || editableLast < document.lineCount - 1) ) { - if ( - editableLast < tokenCosts.length - 1 && - tokenCosts[editableLast + 1] <= remaining - ) { - remaining -= tokenCosts[++editableLast]; - } else if (editableLast < tokenCosts.length - 1) { - break; + if (editableLast < document.lineCount - 1) { + const cost = costForLine(editableLast + 1); + if (cost > remaining) { + break; + } + editableLast++; + remaining -= cost; } - if ( - editableFirst > 0 && - remaining > 0 && - tokenCosts[editableFirst - 1] <= remaining - ) { - remaining -= tokenCosts[--editableFirst]; - } else if (editableFirst > 0 && remaining > 0) { - break; + 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( - tokenCosts, + document.lineCount, + costForLine, editableFirst, editableLast, remaining, @@ -429,7 +673,8 @@ export function buildEditPredictionRequest( let contextFirst = editableFirst; let contextLast = editableLast; ({ first: contextFirst, last: contextLast } = expandLinewise( - tokenCosts, + document.lineCount, + costForLine, contextFirst, contextLast, CONTEXT_TOKENS, @@ -437,11 +682,11 @@ export function buildEditPredictionRequest( )); let editableTokens = 0; for (let line = editableFirst; line <= editableLast; line++) { - editableTokens += tokenCosts[line]; + editableTokens += costForLine(line); } let contextTokens = 0; for (let line = contextFirst; line <= contextLast; line++) { - contextTokens += tokenCosts[line]; + contextTokens += costForLine(line); } if ( editableTokens > MAX_EDITABLE_TOKENS || @@ -450,26 +695,30 @@ export function buildEditPredictionRequest( return; } - const contextStart = starts[contextFirst]; - const contextEnd = lineEnd(content, starts, contextLast); - const excerptText = content.slice(contextStart, contextEnd); + 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, - eol: - (excerptText.match(/\r\n|\r|\n/)?.[0] as - | '\n' - | '\r\n' - | '\r' - | undefined) ?? - (content.match(/\r\n|\r|\n/)?.[0] as '\n' | '\r\n' | '\r' | undefined) ?? - '\n', + version: document.version, + eol: document.eol, excerptText, excerptStartLine: contextFirst, cursorOffsetInExcerpt: cursor - contextStart, editableRange: { - start: starts[editableFirst] - contextStart, - end: lineEnd(content, starts, editableLast) - contextStart, + start: + document.offsetAt({ line: editableFirst, character: 0 }) - contextStart, + end: + document.offsetAt({ + line: editableLast, + character: document.getLineLength(editableLast), + }) - contextStart, }, editHistory: history .slice(-MAX_HISTORY_ENTRIES) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 6f8c01822..c229e1c4a 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -119,6 +119,7 @@ import { type PersistStateStorage, } from './stateStorage'; import { + getTextDocumentChangeTransaction, type ResolvedTextEdit, TextDocument, type TextDocumentChange, @@ -389,8 +390,6 @@ export class Editor implements DiffsEditor { response: EditPredictResponse; }; #editPredictionHistory: EditPredictionHistoryRecord[] = []; - #editPredictionHistoryText?: string; - #pendingEditPredictionHistorySource?: 'user' | 'prediction'; #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. @@ -479,11 +478,6 @@ export class Editor implements DiffsEditor { if (this.#options.editPrediction !== previousEditPrediction) { this.#cancelEditPrediction(true); this.#editPredictionHistory = []; - this.#editPredictionHistoryText = - this.#options.editPrediction === undefined - ? undefined - : this.#textDocument?.getText(); - this.#pendingEditPredictionHistorySource = undefined; this.#scheduleEditPrediction(); } } @@ -770,8 +764,6 @@ export class Editor implements DiffsEditor { this.#editPredictionAltPressed = false; if (!recycle) { this.#editPredictionHistory = []; - this.#editPredictionHistoryText = undefined; - this.#pendingEditPredictionHistorySource = undefined; } if (!recycle) { this.#attachState.delivered = false; @@ -1012,11 +1004,6 @@ export class Editor implements DiffsEditor { this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; this.#editPredictionHistory = []; - this.#editPredictionHistoryText = - this.#options.editPrediction === undefined - ? undefined - : textDocument.getText(); - this.#pendingEditPredictionHistorySource = undefined; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -4251,6 +4238,23 @@ export class Editor implements DiffsEditor { } } + #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(): void { this.#cancelEditPrediction(true); const selection = this.#selections?.[0]; @@ -4284,26 +4288,13 @@ export class Editor implements DiffsEditor { return; } - const normalizedPath = path.replaceAll('\\', '/'); - if ( - (options.include !== undefined && - !options.include.some((pattern) => - matchesEditPredictionPattern(normalizedPath, pattern) - )) || - options.exclude?.some((pattern) => - matchesEditPredictionPattern(normalizedPath, pattern) - ) === true - ) { + if (!this.#includesEditPredictionPath(path)) { return; } - this.#recordEditPredictionHistory( - this.#pendingEditPredictionHistorySource ?? 'user' - ); const request = buildEditPredictionRequest( path, - document.version, - document.getText(), + document, cursorOffset, this.#editPredictionHistory ); @@ -4504,28 +4495,26 @@ export class Editor implements DiffsEditor { }, EDIT_PREDICTION_DEBOUNCE_MS); } - #recordEditPredictionHistory(source: 'user' | 'prediction'): void { + #recordEditPredictionHistory( + change: TextDocumentChange, + source: 'user' | 'prediction' + ): void { const textDocument = this.#textDocument; const path = this.#fileInfo?.name; + const transaction = getTextDocumentChangeTransaction(change); if ( - this.#options.editPrediction === undefined || textDocument === undefined || - path === undefined + path === undefined || + transaction === undefined || + !this.#includesEditPredictionPath(path) ) { return; } - const text = textDocument.getText(); - const previousText = this.#editPredictionHistoryText; - this.#editPredictionHistoryText = text; - this.#pendingEditPredictionHistorySource = undefined; - if (previousText === undefined || previousText === text) { - return; - } this.#editPredictionHistory = recordEditPrediction( this.#editPredictionHistory, path, - previousText, - text, + textDocument, + transaction, source ); } @@ -5935,11 +5924,7 @@ export class Editor implements DiffsEditor { editSource?: 'user' | 'prediction'; } ) { - if (options?.editSource === 'prediction') { - this.#recordEditPredictionHistory('prediction'); - } else { - this.#pendingEditPredictionHistorySource = 'user'; - } + this.#recordEditPredictionHistory(change, options?.editSource ?? 'user'); this.#scheduleEditPrediction(); const fileRef = this.getFile(); diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index b1b7e28a8..e2418b5d7 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -60,6 +60,31 @@ export interface TextDocumentChange { ][]; } +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); +} + +function setTextDocumentChangeTransaction( + change: TextDocumentChange, + transaction: TextDocumentChangeTransaction +): void { + transactions.set(change, transaction); +} + // Metadata-less replay results include the resolved edits so Editor can remap // its live selections without storing a snapshot on the history entry. type TextDocumentHistoryResult = [ @@ -78,7 +103,7 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack: EditStack; - #eol: string; + #eol: '\n' | '\r\n' | '\r'; constructor( uri: string, @@ -123,7 +148,7 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get eol(): string { + get eol(): '\n' | '\r\n' | '\r' { return this.#eol; } @@ -294,6 +319,10 @@ export class TextDocument { } else { this.#editStack.push(entry); } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.forwardEdits, + inverseEdits: entry.inverseEdits, + }); return change; } @@ -320,6 +349,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 [ @@ -341,6 +374,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/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts index 35a382846..c97af2f14 100644 --- a/packages/diffs/test/editorPrediction.test.ts +++ b/packages/diffs/test/editorPrediction.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, spyOn, test } from 'bun:test'; import { File } from '../src/components/File'; import { FileDiff } from '../src/components/FileDiff'; @@ -11,6 +11,7 @@ import { 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'; @@ -241,6 +242,34 @@ describe('Editor edit prediction', () => { } }); + 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( @@ -284,6 +313,52 @@ describe('Editor edit prediction', () => { } }); + 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); @@ -428,6 +503,15 @@ describe('Editor edit prediction', () => { 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); @@ -921,6 +1005,41 @@ describe('Editor edit prediction', () => { } }); + 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 = { From 16d36574adfb14e5a9e21277c24247f441625fa7 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 16:11:30 +0800 Subject: [PATCH 07/11] refactor --- apps/docs/.env.example | 5 +- apps/docs/app/(diffs)/_edit/EditPage.tsx | 8 +- .../app/(diffs)/_edit/EditPredictionDemo.tsx | 34 ++-- apps/docs/app/(diffs)/_edit/constants.ts | 11 +- apps/docs/app/(diffs)/edit/auth/route.ts | 148 +++++++++-------- apps/docs/app/(diffs)/edit/predict/route.ts | 31 +++- packages/diffs/src/editor/editPrediction.ts | 151 +++++++----------- packages/diffs/src/editor/editor.css | 17 +- packages/diffs/src/editor/editor.ts | 72 ++++++--- packages/diffs/src/editor/textDocument.ts | 26 +-- .../editor/textDocumentChangeTransaction.ts | 27 ++++ packages/diffs/test/editorPrediction.test.ts | 143 +++++++++++++++-- 12 files changed, 396 insertions(+), 277 deletions(-) create mode 100644 packages/diffs/src/editor/textDocumentChangeTransaction.ts diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 9f49bb18a..1d2e696a6 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -22,8 +22,9 @@ CODE_STORAGE_SYNC_PRIVATE_KEY="" # Mistral API Key for edit prediction demo MISTRAL_API_KEY="" -# GitHub OAuth Client ID for edit prediction demo +# 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 for edit prediction demo +# GitHub OAuth Client Secret GITHUB_OAUTH_CLIENT_SECRET="" diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 4adfb1dd1..49a9684f9 100644 --- a/apps/docs/app/(diffs)/_edit/EditPage.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -63,9 +63,11 @@ export function EditPage({ <> Pause after typing or moving the cursor to preview an edit prediction, then press Tab to accept the - suggestion. This demo connects the service-agnostic{' '} - predict() API to Codestral built by Mistral AI. - Switch between File and FileDiff. + 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. } /> diff --git a/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx b/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx index 4c2e3e892..f3f5b7f30 100644 --- a/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPredictionDemo.tsx @@ -40,13 +40,20 @@ const statusTextMap = { idle: 'Idle', waiting: 'Waiting...', predicting: 'Predicting...', - ready: ( + 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. ), - empty: 'No suggestion returned. Keep editing to try again.', - error: 'Prediction unavailable. Check the demo service and try again.', }; export function EditPredictionDemo({ @@ -228,9 +235,13 @@ export function EditPredictionDemo({ const statusText = authenticating ? 'Checking GitHub sign-in…' - : !predictionEnabled - ? null - : statusTextMap[status]; + : status === 'error' + ? statusTextMap.error + : !predictionEnabled + ? null + : status === 'ready' + ? readyStatusText[mode] + : statusTextMap[status]; return (
@@ -280,6 +291,7 @@ export function EditPredictionDemo({ > Continue with Codestral diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index 9a70ab793..718b91b12 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -12,13 +12,14 @@ import type { // 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; -export const EDIT_PREDICTION_OLD_FILE: FileContents = { +const EDIT_PREDICTION_OLD_FILE: FileContents = { name: 'cart.ts', contents: `// cart calculator @@ -445,10 +446,8 @@ export const EDIT_PREDICTION_FILE_DIFF_EXAMPLE: PreloadFileDiffOptions { - if (new URL(request.url).searchParams.has('callback')) { - return finishGithubOAuth(request); + if (!IS_DIFFS_SITE) { + return new Response('Not found.', { status: 404 }); } - if ( - process.env.NEXT_PUBLIC_SITE !== undefined && - process.env.NEXT_PUBLIC_SITE !== 'diffs' - ) { - return new Response('Not found.', { status: 404 }); + if (new URL(request.url).searchParams.has('callback')) { + return finishGithubOAuth(request); } const config = getGithubOAuthConfig(); @@ -86,13 +82,6 @@ export async function GET(request: Request): Promise { } async function finishGithubOAuth(request: Request): Promise { - if ( - process.env.NEXT_PUBLIC_SITE !== undefined && - process.env.NEXT_PUBLIC_SITE !== 'diffs' - ) { - return new Response('Not found.', { status: 404 }); - } - const config = getGithubOAuthConfig(); if (config === undefined) { return authError(request, 'GitHub sign-in is not configured.', 503); @@ -158,71 +147,80 @@ async function finishGithubOAuth(request: Request): Promise { return authError(request, 'GitHub rejected the authorization code.', 502); } - 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; login?: unknown }) - : undefined; - if ( - !userResponse.ok || - !Number.isSafeInteger(user?.id) || - Number(user?.id) <= 0 || - typeof user?.login !== 'string' || - user.login.length === 0 - ) { - return authError(request, 'Could not validate the GitHub user.', 502); - } - - try { - await fetch( - `https://api.github.com/applications/${encodeURIComponent(config.clientId)}/token`, - { - method: 'DELETE', + let userResponse: Response; + try { + userResponse = await fetch('https://api.github.com/user', { cache: 'no-store', headers: { ...GITHUB_HEADERS, - Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ access_token: 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) ); - } catch {} - - const sessionCookie = createGithubSessionCookie(request, Number(user.id)); - if (sessionCookie === undefined) { - return authError(request, 'GitHub sign-in is not configured.', 503); + 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.'); + } } - 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 }); } function authError( diff --git a/apps/docs/app/(diffs)/edit/predict/route.ts b/apps/docs/app/(diffs)/edit/predict/route.ts index 4e0a19fc2..a1e898918 100644 --- a/apps/docs/app/(diffs)/edit/predict/route.ts +++ b/apps/docs/app/(diffs)/edit/predict/route.ts @@ -8,10 +8,11 @@ import { isGithubAuthenticated } from '../_auth/github'; const CACHE_CONTROL = 'no-store'; const CODESTRAL_FIM_URL = 'https://api.mistral.ai/v1/fim/completions'; -const MAX_HISTORY_BYTES = 6144; +const MAX_HISTORY_ENTRY_BYTES = 6144; const MAX_OUTPUT_BYTES = 32 * 1024; const MAX_REQUEST_BYTES = 128 * 1024; const MAX_UPSTREAM_BYTES = 64 * 1024; +const MISTRAL_TIMEOUT_MS = 15_000; const textEncoder = new TextEncoder(); const requestSchema = z @@ -105,12 +106,17 @@ export async function POST(request: Request): Promise { splitsTextUnit(excerptText, cursorOffsetInExcerpt) || splitsTextUnit(excerptText, editableRange.end) || input.editHistory.some( - ({ diff }) => textEncoder.encode(diff).byteLength > MAX_HISTORY_BYTES + ({ diff }) => + textEncoder.encode(diff).byteLength > MAX_HISTORY_ENTRY_BYTES ) ) { return createErrorResponse('Invalid edit prediction request.', 400); } + const upstreamSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(MISTRAL_TIMEOUT_MS), + ]); let upstream: Response; try { upstream = await fetch(CODESTRAL_FIM_URL, { @@ -129,14 +135,17 @@ export async function POST(request: Request): Promise { temperature: 0, stream: false, }), - signal: request.signal, + signal: upstreamSignal, }); } catch { + if (request.signal.aborted) { + return createErrorResponse('Edit prediction was cancelled.', 499); + } return createErrorResponse( - request.signal.aborted - ? 'Edit prediction was cancelled.' + upstreamSignal.aborted + ? 'Edit prediction service timed out.' : 'Edit prediction service is unavailable.', - request.signal.aborted ? 499 : 502 + upstreamSignal.aborted ? 504 : 502 ); } @@ -153,7 +162,15 @@ export async function POST(request: Request): Promise { try { upstreamText = await readTextWithinLimit(upstream.body, MAX_UPSTREAM_BYTES); } catch { - return createErrorResponse('Invalid edit prediction response.', 502); + 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); diff --git a/packages/diffs/src/editor/editPrediction.ts b/packages/diffs/src/editor/editPrediction.ts index 0878592a9..66d94937c 100644 --- a/packages/diffs/src/editor/editPrediction.ts +++ b/packages/diffs/src/editor/editPrediction.ts @@ -1,8 +1,6 @@ import type { Position, TextEdit } from '../types'; -import type { - ResolvedTextEdit, - TextDocumentChangeTransaction, -} from './textDocument'; +import type { ResolvedTextEdit } from './textDocument'; +import type { TextDocumentChangeTransaction } from './textDocumentChangeTransaction'; export interface EditPredictRequest { /** Current file name/path as supplied to the File or FileDiff component. */ @@ -82,15 +80,9 @@ interface EditPredictionHistoryFragment { interface EditPredictionTransactionFragment { readonly beforeText: string; readonly afterText: string; - readonly beforeStart: number; - readonly beforeEnd: number; - readonly afterStart: number; - readonly afterEnd: number; + readonly startOffset: number; readonly startLine: number; - readonly beforeChangedStartLine: number; - readonly beforeChangedEndLine: number; - readonly afterChangedStartLine: number; - readonly afterChangedEndLine: number; + readonly bounds: LineDiffBounds; } interface LineDiffBounds { @@ -213,8 +205,7 @@ function formatEditHunk( path: string, oldText: string, newText: string, - oldLineOffset = 0, - newLineOffset = oldLineOffset + lineOffset = 0 ): { readonly hunk: string; readonly bounds: LineDiffBounds } | undefined { if (oldText === newText) { return; @@ -245,8 +236,7 @@ function formatEditHunk( return; } - const oldStart = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); - const newStart = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); + const start = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); const oldEnd = Math.min( bounds.oldLineCount, oldChangedEnd + DIFF_CONTEXT_LINES @@ -255,18 +245,17 @@ function formatEditHunk( bounds.newLineCount, newChangedEnd + DIFF_CONTEXT_LINES ); - const oldCount = oldEnd - oldStart; - const newCount = newEnd - newStart; - const oldLine = oldStart + oldLineOffset; - const newLine = newStart + newLineOffset; + const oldCount = oldEnd - start; + const newCount = newEnd - start; + const line = start + lineOffset; const output = [ `--- a/${path}`, `+++ b/${path}`, - `@@ -${oldCount === 0 ? oldLine : oldLine + 1},${oldCount} +${ - newCount === 0 ? newLine : newLine + 1 + `@@ -${oldCount === 0 ? line : line + 1},${oldCount} +${ + newCount === 0 ? line : line + 1 },${newCount} @@`, ]; - for (let line = oldStart; line < bounds.prefixLines; line++) { + for (let line = start; line < bounds.prefixLines; line++) { output.push( ` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` ); @@ -375,16 +364,9 @@ function captureEditPredictionTransaction( return { beforeText, afterText, - beforeStart: afterStart, - beforeEnd: afterStart + beforeText.length, - afterStart, - afterEnd, + startOffset: afterStart, startLine, - beforeChangedStartLine: startLine + bounds.prefixLines, - beforeChangedEndLine: - startLine + bounds.oldLineCount - bounds.suffixLines, - afterChangedStartLine: startLine + bounds.prefixLines, - afterChangedEndLine: startLine + bounds.newLineCount - bounds.suffixLines, + bounds, }; } return undefined; @@ -410,12 +392,17 @@ export function recordEditPrediction( 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 && fragment.beforeChangedStartLine > last.end - ? fragment.beforeChangedStartLine - last.end - : last !== undefined && last.start > fragment.beforeChangedEndLine - ? last.start - fragment.beforeChangedEndLine + last !== undefined && changedStartLine > last.end + ? changedStartLine - last.end + : last !== undefined && last.start > beforeChangedEndLine + ? last.start - beforeChangedEndLine : 0; const canMerge = last !== undefined && @@ -427,8 +414,9 @@ export function recordEditPrediction( if (canMerge) { const previous = last.fragment; - const overlapStart = Math.max(previous.currentStart, fragment.beforeStart); - const overlapEnd = Math.min(previous.currentEnd, fragment.beforeEnd); + 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( @@ -436,20 +424,20 @@ export function recordEditPrediction( overlapEnd - previous.currentStart ) === fragment.beforeText.slice( - overlapStart - fragment.beforeStart, - overlapEnd - fragment.beforeStart + overlapStart - fragment.startOffset, + overlapEnd - fragment.startOffset ) ) { - const unionStart = Math.min(previous.currentStart, fragment.beforeStart); + const unionStart = Math.min(previous.currentStart, fragment.startOffset); const currentText = - previous.currentStart <= fragment.beforeStart + previous.currentStart <= fragment.startOffset ? previous.currentText + fragment.beforeText.slice( - Math.max(0, previous.currentEnd - fragment.beforeStart) + Math.max(0, previous.currentEnd - fragment.startOffset) ) : fragment.beforeText + previous.currentText.slice( - Math.max(0, fragment.beforeEnd - previous.currentStart) + Math.max(0, beforeEnd - previous.currentStart) ); const prefix = currentText.slice(0, previous.currentStart - unionStart); const suffix = currentText.slice(previous.currentEnd - unionStart); @@ -460,7 +448,7 @@ export function recordEditPrediction( transaction.appliedEdits ); const startLine = - previous.currentStart <= fragment.beforeStart + previous.currentStart <= fragment.startOffset ? previous.startLine : fragment.startLine; if ( @@ -516,15 +504,18 @@ export function recordEditPrediction( kept.push({ path, hunk: formatted.hunk, - start: fragment.afterChangedStartLine, - end: fragment.afterChangedEndLine, + start: changedStartLine, + end: + fragment.startLine + + fragment.bounds.newLineCount - + fragment.bounds.suffixLines, at, source, fragment: { baseText: fragment.beforeText, currentText: fragment.afterText, - currentStart: fragment.afterStart, - currentEnd: fragment.afterEnd, + currentStart: fragment.startOffset, + currentEnd: fragment.startOffset + fragment.afterText.length, startLine: fragment.startLine, }, }); @@ -536,44 +527,24 @@ function expandLinewise( costForLine: (line: number) => number, first: number, last: number, - remaining: number, - preferBefore: boolean + remaining: number ): { first: number; last: number } { while (remaining > 0 && (first > 0 || last < lineCount - 1)) { let expanded = false; - if (preferBefore) { - 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; - } - } - } else { - if (last < lineCount - 1) { - const cost = costForLine(last + 1); - if (cost <= remaining) { - last++; - remaining -= cost; - expanded = true; - } + if (first > 0) { + const cost = costForLine(first - 1); + if (cost <= remaining) { + first--; + remaining -= cost; + expanded = true; } - 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) { @@ -666,8 +637,7 @@ export function buildEditPredictionRequest( costForLine, editableFirst, editableLast, - remaining, - true + remaining )); let contextFirst = editableFirst; @@ -677,8 +647,7 @@ export function buildEditPredictionRequest( costForLine, contextFirst, contextLast, - CONTEXT_TOKENS, - true + CONTEXT_TOKENS )); let editableTokens = 0; for (let line = editableFirst; line <= editableLast; line++) { @@ -737,12 +706,8 @@ export function matchesEditPredictionPattern( path: string, pattern: string | RegExp ): boolean { - if (pattern instanceof RegExp) { - const lastIndex = pattern.lastIndex; - pattern.lastIndex = 0; - const matches = pattern.test(path); - pattern.lastIndex = lastIndex; - return matches; + if (typeof pattern !== 'string') { + return new RegExp(pattern.source, pattern.flags).test(path); } pattern = pattern.replaceAll('\\', '/'); diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index d1965f0c9..b580b11a0 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -122,20 +122,17 @@ 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-editor-edit-prediction-deletion-fg, - color-mix(in lab, var(--diffs-deletion-base) 70%, transparent) - ) - calc(50% - 0.5px), - var( - --diffs-editor-edit-prediction-deletion-fg, - color-mix(in lab, var(--diffs-deletion-base) 70%, 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) ); } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c229e1c4a..8f86bc29a 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -20,6 +20,7 @@ import type { SelectionSide, TextEdit, } from '../types'; +import { countLineBreaks } from '../utils/computeFileOffsets'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; import { @@ -119,11 +120,11 @@ import { type PersistStateStorage, } from './stateStorage'; import { - getTextDocumentChangeTransaction, type ResolvedTextEdit, TextDocument, type TextDocumentChange, } from './textDocument'; +import { getTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; import { getExpandedAsciiTextColumns, getUnicodeMeasurementOffsets, @@ -1951,6 +1952,13 @@ export class Editor implements DiffsEditor { addEventListener(contentEl, 'keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); + if ( + this.#editPredictionTimer !== undefined || + this.#editPredictionAbortController !== undefined || + this.#editPrediction !== undefined + ) { + this.#cancelEditPrediction(true); + } this.#searchPanel?.close(); this.#searchPanel = undefined; this.#retainSearchPanelFocus = false; @@ -4113,7 +4121,6 @@ export class Editor implements DiffsEditor { } #removeRenderedEditPrediction(): void { - this.#contentElement?.style.removeProperty('padding-block-end'); for (const [key, element] of this.#overlayElements ?? []) { if (key.startsWith('editPrediction')) { element.remove(); @@ -4145,18 +4152,7 @@ export class Editor implements DiffsEditor { ) { continue; } - let count = 0; - for (let index = 0; index < edit.newText.length; index++) { - const char = edit.newText.charCodeAt(index); - if (char === 10) { - count++; - } else if (char === 13) { - count++; - if (edit.newText.charCodeAt(index + 1) === 10) { - index++; - } - } - } + const count = countLineBreaks(edit.newText); if (count > (continuationLines.get(edit.range.start.line) ?? 0)) { continuationLines.set(edit.range.start.line, count); } @@ -4255,8 +4251,15 @@ export class Editor implements DiffsEditor { ); } - #scheduleEditPrediction(): void { - this.#cancelEditPrediction(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 || @@ -4367,11 +4370,19 @@ export class Editor implements DiffsEditor { } const start = document.offsetAt(edit.range.start); const end = document.offsetAt(edit.range.end); - const resolvedEdit = document.resolveEdits([edit])[0]; - if (resolvedEdit.start !== start || resolvedEdit.end !== end) { + if ( + splitsSurrogatePair( + document.charAt(start - 1) + document.charAt(start), + 1 + ) || + splitsSurrogatePair( + document.charAt(end - 1) + document.charAt(end), + 1 + ) + ) { return; } - resolvedEdits.push(resolvedEdit); + resolvedEdits.push({ start, end, text: edit.newText }); } resolvedEdits.sort((left, right) => { const startDelta = left.start - right.start; @@ -4737,7 +4748,10 @@ export class Editor implements DiffsEditor { } } - #updateSelections(selections: EditorSelection[]) { + #updateSelections( + selections: EditorSelection[], + updateEditPrediction = true + ) { this.__postponeBgTokenizeToNextFrame(); const previousSelections = this.#selections; @@ -4756,7 +4770,7 @@ export class Editor implements DiffsEditor { } } } - if (selectionsChanged) { + if (selectionsChanged && updateEditPrediction) { this.#cancelEditPrediction(true); } this.#syncEditPredictionSpacers(); @@ -4774,7 +4788,7 @@ export class Editor implements DiffsEditor { this.#overlayElements?.clear(); this.#selectionAction?.cleanup(); this.#selectionAction = undefined; - if (selectionsChanged) { + if (selectionsChanged && updateEditPrediction) { this.#scheduleEditPrediction(); } return; @@ -4946,7 +4960,7 @@ export class Editor implements DiffsEditor { } this.#updateSelectionActionPopover(); - if (selectionsChanged) { + if (selectionsChanged && updateEditPrediction) { this.#scheduleEditPrediction(); } } @@ -5924,8 +5938,11 @@ export class Editor implements DiffsEditor { editSource?: 'user' | 'prediction'; } ) { - this.#recordEditPredictionHistory(change, options?.editSource ?? 'user'); - this.#scheduleEditPrediction(); + 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; @@ -6068,7 +6085,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 @@ -6092,6 +6109,9 @@ export class Editor implements DiffsEditor { this.focus({ preventScroll: true }); } } + if (this.#options.editPrediction !== undefined) { + this.#scheduleEditPrediction(editPredictionWasEnabled); + } } #applyChangeToLineAnnotations( diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index e2418b5d7..1e2078718 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -14,6 +14,7 @@ import { } from './editStack'; import { PieceTable } from './pieceTable'; import type { SearchParams } from './searchPanel'; +import { setTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; export type { Position, Range, TextEdit } from '../types'; @@ -60,31 +61,6 @@ export interface TextDocumentChange { ][]; } -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); -} - -function setTextDocumentChangeTransaction( - change: TextDocumentChange, - transaction: TextDocumentChangeTransaction -): void { - transactions.set(change, transaction); -} - // Metadata-less replay results include the resolved edits so Editor can remap // its live selections without storing a snapshot on the history entry. type TextDocumentHistoryResult = [ diff --git a/packages/diffs/src/editor/textDocumentChangeTransaction.ts b/packages/diffs/src/editor/textDocumentChangeTransaction.ts new file mode 100644 index 000000000..b0731e35f --- /dev/null +++ b/packages/diffs/src/editor/textDocumentChangeTransaction.ts @@ -0,0 +1,27 @@ +import type { ResolvedTextEdit, 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/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts index c97af2f14..35c76a6de 100644 --- a/packages/diffs/test/editorPrediction.test.ts +++ b/packages/diffs/test/editorPrediction.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, spyOn, test } from 'bun:test'; +import { afterAll, describe, expect, jest, spyOn, test } from 'bun:test'; import { File } from '../src/components/File'; import { FileDiff } from '../src/components/FileDiff'; @@ -20,6 +20,7 @@ afterAll(async () => { }); const FILE_NAME = 'src/edit.ts'; +const EDIT_PREDICTION_DEBOUNCE_MS = 300; const PREDICT_TIMEOUT = 2_000; type Surface = 'File' | 'FileDiff'; @@ -220,12 +221,14 @@ describe('Editor edit prediction', () => { }); try { + jest.useFakeTimers(); setCaret(fixture.editor, 0, 3); dispatchTextInput(fixture.content, 'X'); - await wait(250); + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); expect(calls).toHaveLength(0); - await expectCallCount(calls, 1); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); expect(calls[0].request).toMatchObject({ cursorOffsetInExcerpt: 4, @@ -238,6 +241,7 @@ describe('Editor edit prediction', () => { }); expect(calls[0].context.signal.aborted).toBe(false); } finally { + jest.useRealTimers(); await fixture.cleanup(); } }); @@ -411,19 +415,22 @@ describe('Editor edit prediction', () => { }); try { + jest.useFakeTimers(); setCaret(fixture.editor, 0, 0); const event = dispatchKey(fixture.content, 'ArrowRight'); expect(event.defaultPrevented).toBe(true); - await wait(250); + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); expect(calls).toHaveLength(0); - await expectCallCount(calls, 1); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); expect(calls[0].request).toMatchObject({ cursorOffsetInExcerpt: 1, excerptText: 'abc', version: 0, }); } finally { + jest.useRealTimers(); await fixture.cleanup(); } }); @@ -769,7 +776,7 @@ describe('Editor edit prediction', () => { timeout: PREDICT_TIMEOUT, }); - expect(fixture.content.style.paddingBlockEnd).not.toBe(''); + expect(fixture.content.style.paddingBlockEnd).toBe('20px'); dispatchKey(fixture.content, 'ArrowLeft'); expect(fixture.content.style.paddingBlockEnd).toBe(''); @@ -865,6 +872,77 @@ describe('Editor edit prediction', () => { } }); + 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 = { @@ -1097,6 +1175,45 @@ describe('Editor edit prediction', () => { } }); + 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; @@ -1157,23 +1274,21 @@ describe('Editor edit prediction', () => { }); try { + jest.useFakeTimers(); setCaret(fixture.editor, 0, 1); dispatchTextInput(fixture.content, 'b'); - if (allowed) { - await expectCallCount(calls, 1); - } else { - await wait(340); - expect(calls).toHaveLength(0); - } + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS); + expect(calls).toHaveLength(allowed ? 1 : 0); } finally { + jest.useRealTimers(); await fixture.cleanup(); } }); } - test('reuses a global regular-expression include', async () => { + test('reuses a frozen global regular-expression include', async () => { const calls: PredictionCall[] = []; - const include = /\.ts$/g; + const include = Object.freeze(/\.ts$/g); const provider: EditPredictProvider = { predict(request, context) { calls.push({ context, request }); From 519747b242b188887bbcb2bbe5c7d286d37eed77 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 16:12:50 +0800 Subject: [PATCH 08/11] typo --- apps/docs/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 1d2e696a6..a9a56c69f 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -19,7 +19,7 @@ GITHUB_APP_PRIVATE_KEY="" # Key from code.storage for syncing CODE_STORAGE_SYNC_PRIVATE_KEY="" -# Mistral API Key for edit prediction demo +# Mistral API Key for the edit prediction demo MISTRAL_API_KEY="" # GitHub OAuth App for the edit prediction demo From 0a4640848f8c8944d51bc9ba114ad86048715452 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 16:38:54 +0800 Subject: [PATCH 09/11] Update docs --- apps/docs/app/(diffs)/_docs/DocsPage.tsx | 20 + apps/docs/app/(diffs)/docs/Edit/constants.ts | 127 +++++ apps/docs/app/(diffs)/docs/Edit/content.mdx | 547 ++++++++----------- 3 files changed, 375 insertions(+), 319 deletions(-) diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index ec6120c60..802a5675f 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -30,15 +30,20 @@ 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_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, @@ -423,11 +428,15 @@ async function CodeViewSection() { async function EditSection() { const [ + editAutofocusReactExample, + editAutofocusVanillaExample, editDemoFile, + editFocusPositionExample, editVanillaFileExample, editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPersistStateExample, editPredictionExample, editorOptionsType, editorPublicApi, @@ -439,15 +448,20 @@ 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(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(EDITOR_PUBLIC_API), @@ -459,6 +473,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), @@ -466,11 +481,15 @@ async function EditSection() { const content = await renderMDX({ filePath: '(diffs)/docs/Edit/content.mdx', scope: { + editAutofocusReactExample, + editAutofocusVanillaExample, editDemoFile, + editFocusPositionExample, editVanillaFileExample, editVanillaFileDiffExample, editVanillaCodeViewExample, editLazyFileExample, + editPersistStateExample, editPredictionExample, editorOptionsType, editorPublicApi, @@ -482,6 +501,7 @@ async function EditSection() { editReactExample, editReactFileDiffExample, editReactMultiFileDiffExample, + editReactProviderExample, editUndoRedoExample, editWorkerPoolReactExample, editWorkerPoolVanillaExample, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index e699441ae..1cdccd29f 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -1058,6 +1058,133 @@ 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_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 b874fcf90..38057e0a4 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -36,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 +74,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 to `onChange` 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 the collection 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 +90,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 +119,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 @@ -333,94 +141,13 @@ 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 remain scoped to the `Editor` instance; IndexedDB and custom storage persist only serializable item-local editor state. -### 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). - -The editor schedules `predict()` 300 ms after typing or moving to a single, -collapsed caret. Each new document or cursor change clears the current -prediction, cancels the pending debounce, and aborts in-progress work through -`context.signal`. Forward that signal to `fetch()` or any other cancellable -model call. Responses for an old document version or cursor position are ignored -even if the provider does not stop promptly. - -`mode` defaults to `'eager'`, which displays a ready prediction immediately. -`'subtle'` still runs prediction after the debounce, but hides the ghost text -until the user holds Alt. Pressing Tab accepts a visible -prediction and moves the caret to the response's `newCursor`; otherwise Tab -keeps its normal indentation behavior. Multiline predictions render continuation -text as numberless ghost rows, preserving the document's real line numbers. - -Use `include` and `exclude` to filter the file path passed to the surface. Both -accept strings or regular expressions. String patterns match the whole -slash-normalized path and support `?`, segment-local `*`, and cross-segment -`**`. Omitting `include` enables every path, while `include: []` disables -prediction for every path. Exclusions always take precedence. - -`EditPredictRequest` contains the file `path`, document `version`, detected -`eol`, a bounded `excerptText`, and bounded chronological `editHistory`. Its -`excerptStartLine` is a zero-based document line; `cursorOffsetInExcerpt` and -the half-open `editableRange` are UTF-16 offsets relative to the excerpt. -History entries contain a unified `diff` and a `source` of either `'user'` or -`'prediction'`. - -Return an `EditPredictResponse` whose non-overlapping `edits` use absolute, -zero-based document positions and stay within the request's editable window. -`newCursor` is also absolute and refers to the post-edit document. Use a -collapsed range to insert, an empty `newText` to delete, or a non-empty range -and `newText` to replace. Return `edits: []` with a valid `newCursor` when there -is no suggestion. - ### API Reference These methods are available on an `Editor` instance. Attach it to a rendered @@ -470,50 +197,6 @@ Limit the stack size with `historyMaxEntries` in -### Using Worker Pool - -When you offload syntax highlighting to a worker pool, edit mode still needs the -token transformer pipeline to re-highlight lines as you type. Set -`useTokenTransformer: true` on the pool's `highlighterOptions`. - -See [Worker Pool](#worker-pool) for worker factory setup and pool options. In -vanilla JS, pass the pool as the second argument to `File` or `FileDiff`. In -React, wrap your tree in `WorkerPoolContextProvider`. Then attach the editor as -usual. - - - -### Lazy Loading - -Because `@pierre/diffs/edit` is a standalone entry point, you can dynamic-import -it only when the user enters edit mode. That keeps the initial page bundle -smaller and can improve LCP (Largest Contentful Paint) on pages where editing is -rare. - - - -### Selection Action - -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. @@ -546,3 +229,229 @@ and End keys; on macOS, the modifier with ↑ and ↓ arrows works to | Find next match of selection | D | | Undo | Z | | Redo | Z | + +### 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 to `onChange` 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 the collection 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 + +When you offload syntax highlighting to a worker pool, edit mode still needs the +token transformer pipeline to re-highlight lines as you type. Set +`useTokenTransformer: true` on the pool's `highlighterOptions`. + +See [Worker Pool](#worker-pool) for worker factory setup and pool options. In +vanilla JS, pass the pool as the second argument to `File` or `FileDiff`. In +React, wrap your tree in `WorkerPoolContextProvider`. Then attach the editor as +usual. + + + +### Lazy Loading + +Because `@pierre/diffs/edit` is a standalone entry point, you can dynamic-import +it only when the user enters edit mode. That keeps the initial page bundle +smaller and can improve LCP (Largest Contentful Paint) on pages where editing is +rare. + + From dae253f656a8aceba9f32882060c00f0074a1898 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 17:58:59 +0800 Subject: [PATCH 10/11] fix codex --- apps/docs/app/(diffs)/edit/_auth/github.ts | 14 ++- apps/docs/app/(diffs)/edit/predict/route.ts | 33 +++++- apps/docs/package.json | 1 + packages/diffs/src/editor/editor.ts | 100 +++++++++++++----- packages/diffs/src/editor/utils.ts | 9 ++ .../test/editorPersistStateLifecycle.test.ts | 46 +++++++- packages/diffs/test/editorPrediction.test.ts | 54 ++++++++++ pnpm-lock.yaml | 12 +++ pnpm-workspace.yaml | 1 + 9 files changed, 235 insertions(+), 35 deletions(-) diff --git a/apps/docs/app/(diffs)/edit/_auth/github.ts b/apps/docs/app/(diffs)/edit/_auth/github.ts index edba3f208..217f45715 100644 --- a/apps/docs/app/(diffs)/edit/_auth/github.ts +++ b/apps/docs/app/(diffs)/edit/_auth/github.ts @@ -75,11 +75,13 @@ export function createGithubSessionCookie( ); } -export function isGithubAuthenticated(request: Request): boolean { +export function getAuthenticatedGithubUserId( + request: Request +): string | undefined { const config = getGithubOAuthConfig(); const session = getAuthCookie(request, GITHUB_SESSION_COOKIE); if (config === undefined || session === undefined) { - return false; + return; } const [userId, expiresAt, signature, ...extra] = session.split('.'); if ( @@ -89,10 +91,14 @@ export function isGithubAuthenticated(request: Request): boolean { signature === undefined || Number(expiresAt) <= Math.floor(Date.now() / 1000) ) { - return false; + return; } const expected = createHmac('sha256', config.clientSecret) .update(`${userId}.${expiresAt}`) .digest('base64url'); - return authValuesMatch(signature, expected); + return authValuesMatch(signature, expected) ? userId : undefined; +} + +export function isGithubAuthenticated(request: Request): boolean { + return getAuthenticatedGithubUserId(request) !== undefined; } diff --git a/apps/docs/app/(diffs)/edit/predict/route.ts b/apps/docs/app/(diffs)/edit/predict/route.ts index a1e898918..4c165f811 100644 --- a/apps/docs/app/(diffs)/edit/predict/route.ts +++ b/apps/docs/app/(diffs)/edit/predict/route.ts @@ -2,12 +2,14 @@ import type { EditPredictRequest, EditPredictResponse, } from '@pierre/diffs/edit'; +import { checkRateLimit } from '@vercel/firewall'; import { z } from 'zod'; -import { isGithubAuthenticated } from '../_auth/github'; +import { getAuthenticatedGithubUserId } from '../_auth/github'; const CACHE_CONTROL = 'no-store'; const CODESTRAL_FIM_URL = 'https://api.mistral.ai/v1/fim/completions'; +const EDIT_PREDICT_RATE_LIMIT_ID = 'edit-predict'; const MAX_HISTORY_ENTRY_BYTES = 6144; const MAX_OUTPUT_BYTES = 32 * 1024; const MAX_REQUEST_BYTES = 128 * 1024; @@ -56,7 +58,8 @@ export async function POST(request: Request): Promise { return createErrorResponse('Not found.', 404); } - if (!isGithubAuthenticated(request)) { + const githubUserId = getAuthenticatedGithubUserId(request); + if (githubUserId === undefined) { return createErrorResponse('GitHub sign-in required.', 401); } @@ -113,6 +116,32 @@ export async function POST(request: Request): Promise { 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), 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/editor.ts b/packages/diffs/src/editor/editor.ts index 8f86bc29a..2c282d570 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -138,6 +138,7 @@ import { extend, getLineNumberAttr, h, + isPromise, round, } from './utils'; @@ -265,6 +266,7 @@ 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' @@ -389,6 +391,7 @@ export class Editor implements DiffsEditor { cursorOffset: number; rendered: boolean; response: EditPredictResponse; + renderEdits: readonly TextEdit[]; }; #editPredictionHistory: EditPredictionHistoryRecord[] = []; #editPredictionSpacers = new Map(); @@ -415,7 +418,7 @@ export class Editor implements DiffsEditor { this.#editPrediction === undefined ? undefined : new Set( - this.#editPrediction.response.edits.map( + this.#editPrediction.renderEdits.map( (edit) => edit.range.start.line ) ); @@ -1292,11 +1295,11 @@ export class Editor implements DiffsEditor { } catch { return; } - if (!(result instanceof Promise)) { + if (!isPromise(result)) { return; } - this.#trackStateWrite(cacheKey, result); + this.#trackStateWrite(cacheKey, Promise.resolve(result)); } #trackStateWrite(cacheKey: string, result: Promise): void { @@ -1340,8 +1343,10 @@ export class Editor implements DiffsEditor { } catch { return; } - if (result instanceof Promise) { - return result.then(applyState).catch(() => {}); + if (isPromise(result)) { + return Promise.resolve(result) + .then(applyState) + .catch(() => {}); } else { try { applyState(result); @@ -1352,14 +1357,15 @@ export class Editor implements DiffsEditor { const pendingWrite = this.#pendingStateWrites.get(cacheKey); const result = pendingWrite === undefined ? readState() : pendingWrite.then(readState); - if (result instanceof Promise) { + 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(() => { @@ -4145,7 +4151,7 @@ export class Editor implements DiffsEditor { this.#editPredictionAltPressed) ) { const continuationLines = new Map(); - for (const edit of prediction.response.edits) { + for (const edit of prediction.renderEdits) { if ( edit.newText.length === 0 || !this.#isLineVisible(edit.range.start.line) @@ -4479,21 +4485,70 @@ export class Editor implements DiffsEditor { } } + 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]; + const line = edit.range.start.line; + let groupEnd = index; + while ( + responseEdits[groupEnd].range.end.line === line && + responseEdits[groupEnd + 1]?.range.start.line === line && + responseEdits[groupEnd + 1]?.range.end.line === line + ) { + groupEnd++; + } + if (groupEnd === index) { + // Cross-line edits cannot share their boundary line with another + // preview overlay. + if (renderEdits.at(-1)?.range.end.line === line) { + return; + } + renderEdits.push(edit); + continue; + } + + const lineText = document.getLineText(line); + let character = edit.range.start.character; + let newText = ''; + for ( + let sameLineIndex = index; + sameLineIndex <= groupEnd; + sameLineIndex++ + ) { + const sameLineEdit = responseEdits[sameLineIndex]; + newText += + lineText.slice(character, sameLineEdit.range.start.character) + + sameLineEdit.newText; + character = sameLineEdit.range.end.character; + } + renderEdits.push({ + range: { + start: { ...edit.range.start }, + end: { line, character: lineText.length }, + }, + newText: newText + lineText.slice(character), + }); + index = groupEnd; + } this.#editPrediction = { document, version: request.version, cursorOffset, rendered: false, response: { - edits: edits.map((edit) => ({ - range: { - start: document.positionAt(edit.start), - end: document.positionAt(edit.end), - }, - newText: edit.text, - })), + edits: responseEdits, newCursor: { ...newCursor }, }, + renderEdits, }; this.#updateSelections(this.#selections); }) @@ -4608,10 +4663,10 @@ export class Editor implements DiffsEditor { const isWrap = this.#isWrap; for ( let editIndex = 0; - editIndex < prediction.response.edits.length; + editIndex < prediction.renderEdits.length; editIndex++ ) { - const edit = prediction.response.edits[editIndex]; + const edit = prediction.renderEdits[editIndex]; const { start, end } = edit.range; const isDeletion = edit.newText.length === 0; const isReplacement = comparePosition(start, end) !== 0; @@ -4681,16 +4736,9 @@ export class Editor implements DiffsEditor { element.style.width = 'max-content'; } const lines = edit.newText.split(/\r\n|\r|\n/); - // Redraw the suffix only when other edits or wrapping cannot relocate it. + // Redraw the suffix when wrapping cannot relocate it. let insertionSuffix: Node | undefined; - if ( - isMidLineInsertion && - !isWrap && - prediction.response.edits[editIndex - 1]?.range.end.line !== - start.line && - prediction.response.edits[editIndex + 1]?.range.start.line !== - start.line - ) { + if (isMidLineInsertion && !isWrap) { const sourceLine = this.#getLineElement(start.line); if (sourceLine === undefined) { insertionSuffix = document.createTextNode( 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/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 index 35c76a6de..239018f87 100644 --- a/packages/diffs/test/editorPrediction.test.ts +++ b/packages/diffs/test/editorPrediction.test.ts @@ -627,6 +627,60 @@ describe('Editor edit prediction', () => { } }); + 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} reserves numberless rows for multiline ghost text`, async () => { const contents = 'const value = 1;\nnext();\nend();'; const provider: EditPredictProvider = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9f6ceda5..5f9eb406e 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.2.0 version: 4.2.0 + '@vercel/firewall': + specifier: 'catalog:' + version: 1.2.1 '@vscode/web-custom-data': specifier: 'catalog:' version: 0.6.3 @@ -2902,6 +2908,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} @@ -8189,6 +8199,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 75097528a..cc5f37b1c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -82,6 +82,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' From 0b00db6c008781217ced2fecc0744e4938d99e8f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Jul 2026 20:26:46 +0800 Subject: [PATCH 11/11] fix codex --- packages/diffs/src/editor/editor.ts | 47 +++++++++-------- packages/diffs/test/editorPrediction.test.ts | 54 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 2c282d570..008b174f5 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -4497,45 +4497,44 @@ export class Editor implements DiffsEditor { const renderEdits: TextEdit[] = []; for (let index = 0; index < responseEdits.length; index++) { const edit = responseEdits[index]; - const line = edit.range.start.line; let groupEnd = index; + let groupEndLine = edit.range.end.line; while ( - responseEdits[groupEnd].range.end.line === line && - responseEdits[groupEnd + 1]?.range.start.line === line && - responseEdits[groupEnd + 1]?.range.end.line === line + responseEdits[groupEnd + 1]?.range.start.line === groupEndLine ) { groupEnd++; + groupEndLine = responseEdits[groupEnd].range.end.line; } if (groupEnd === index) { - // Cross-line edits cannot share their boundary line with another - // preview overlay. - if (renderEdits.at(-1)?.range.end.line === line) { - return; - } renderEdits.push(edit); continue; } - const lineText = document.getLineText(line); - let character = edit.range.start.character; - let newText = ''; - for ( - let sameLineIndex = index; - sameLineIndex <= groupEnd; - sameLineIndex++ - ) { - const sameLineEdit = responseEdits[sameLineIndex]; - newText += - lineText.slice(character, sameLineEdit.range.start.character) + - sameLineEdit.newText; - character = sameLineEdit.range.end.character; + 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, character: lineText.length }, + end: { + line: groupEndLine, + character: groupEndCharacter, + }, }, - newText: newText + lineText.slice(character), + newText: newText.join(''), }); index = groupEnd; } diff --git a/packages/diffs/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts index 239018f87..d801fbfd7 100644 --- a/packages/diffs/test/editorPrediction.test.ts +++ b/packages/diffs/test/editorPrediction.test.ts @@ -681,6 +681,60 @@ describe('Editor edit prediction', () => { } }); + 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 = {