diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 5e757564c..a9a56c69f 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -17,4 +17,14 @@ GITHUB_WEBHOOK_SECRET="" GITHUB_APP_PRIVATE_KEY="" # Key from code.storage for syncing -CODE_STORAGE_SYNC_PRIVATE_KEY="" \ No newline at end of file +CODE_STORAGE_SYNC_PRIVATE_KEY="" + +# Mistral API Key for the edit prediction demo +MISTRAL_API_KEY="" + +# GitHub OAuth App for the edit prediction demo +# Set its authorization callback URL to /edit/auth?callback +GITHUB_OAUTH_CLIENT_ID="" + +# GitHub OAuth Client Secret +GITHUB_OAUTH_CLIENT_SECRET="" diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 2f4be5c3a..ddc90df98 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -40,6 +40,7 @@ import { EDIT_ON_CHANGE_EXAMPLE, EDIT_PERSIST_STATE_EXAMPLE, EDIT_PERSIST_STATE_REACT_EXAMPLE, + EDIT_PREDICTION_EXAMPLE, EDIT_REACT_CODE_VIEW_EXAMPLE, EDIT_REACT_CREATE_EDITOR_EXAMPLE, EDIT_REACT_EXAMPLE, @@ -443,6 +444,7 @@ async function EditSection() { editorPublicApi, editSelectionActionContextType, editSelectionActionExample, + editPredictionExample, editPersistStateExample, editPersistStateReactExample, editMarkerType, @@ -470,6 +472,7 @@ async function EditSection() { preloadFile(EDITOR_PUBLIC_API), preloadFile(EDIT_SELECTION_ACTION_CONTEXT_TYPE), preloadFile(EDIT_SELECTION_ACTION_EXAMPLE), + preloadFile(EDIT_PREDICTION_EXAMPLE), preloadFile(EDIT_PERSIST_STATE_EXAMPLE), preloadFile(EDIT_PERSIST_STATE_REACT_EXAMPLE), preloadFile(EDIT_MARKER_TYPE), @@ -500,6 +503,7 @@ async function EditSection() { editorPublicApi, editSelectionActionContextType, editSelectionActionExample, + editPredictionExample, editPersistStateExample, editPersistStateReactExample, editMarkerType, diff --git a/apps/docs/app/(diffs)/_edit/CodestralIcon.tsx b/apps/docs/app/(diffs)/_edit/CodestralIcon.tsx new file mode 100644 index 000000000..382d4bca0 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/CodestralIcon.tsx @@ -0,0 +1,84 @@ +export function CodestralIcon() { + return ( + + ); +} diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 2671ee225..985fa37b1 100644 --- a/apps/docs/app/(diffs)/_edit/EditPage.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -6,6 +6,7 @@ import type { import { WorkerPoolContext } from '../_components/WorkerPoolContext'; import { LiveEditing } from '../_examples/LiveEditing/LiveEditing'; import { EditHero } from './EditHero'; +import { EditPredictionDemo } from './EditPredictionDemo'; import { EditReference } from './EditReference'; import { FindDemo } from './FindDemo'; import { HistoryDemo } from './HistoryDemo'; @@ -21,6 +22,8 @@ import { PierreCompanySection } from '@/components/PierreCompanySection'; interface EditPageProps { liveEditingFile: PreloadedFileResult; liveEditingDiff: PreloadFileDiffResult; + editPredictionFile: PreloadedFileResult; + editPredictionDiff: PreloadFileDiffResult; markerFile: PreloadedFileResult; findFile: PreloadedFileResult; historyFile: PreloadedFileResult; @@ -31,6 +34,8 @@ interface EditPageProps { export function EditPage({ liveEditingFile, liveEditingDiff, + editPredictionFile, + editPredictionDiff, markerFile, findFile, historyFile, @@ -50,6 +55,28 @@ export function EditPage({ prerenderedDiff={liveEditingDiff} /> +
+ + Pause after typing or moving the cursor to preview an edit + prediction, then press Tab to accept the + suggestion—or hold Alt while pressing{' '} + Tab in subtle mode. This demo connects the + service-agnostic predict() API to Codestral built + by Mistral AI. Switch between File and{' '} + FileDiff. + + } + /> + +
+
; + prerenderedDiff: PreloadFileDiffResult; +} + +type Surface = 'file' | 'diff'; +type PredictionMode = 'eager' | 'subtle'; +type PredictionStatus = + | 'idle' + | 'waiting' + | 'predicting' + | 'ready' + | 'empty' + | 'error'; + +const INCLUDE = ['**/*.ts'] as const; +const EXCLUDE = ['**/*.test.ts'] as const; +const statusTextMap = { + idle: 'Idle', + waiting: 'Waiting...', + predicting: 'Predicting...', + empty: 'No suggestion returned. Keep editing to try again.', + error: 'Prediction unavailable. Check the demo service and try again.', +}; +const readyStatusText = { + eager: ( + <> + Prediction ready — press Tab to accept. + + ), + subtle: ( + <> + Prediction ready — hold Alt and press Tab to accept. + + ), +}; + +export function EditPredictionDemo({ + prerenderedFile, + prerenderedDiff, +}: EditPredictionDemoProps) { + const editorRef = useRef | null>(null); + const predictionEnabledRef = useRef(false); + const [attached, setAttached] = useState(false); + const [authenticating, setAuthenticating] = useState(false); + const [hasEdits, setHasEdits] = useState(false); + const [mode, setMode] = useState('eager'); + const [predictionEnabled, setPredictionEnabled] = useState(false); + const [resetKey, setResetKey] = useState(0); + const [status, setStatus] = useState('idle'); + const [surface, setSurface] = useState('file'); + + const provider = useMemo( + () => ({ + async predict(request, { signal }) { + if (!predictionEnabledRef.current) { + const prefix = request.excerptText.slice( + 0, + request.cursorOffsetInExcerpt + ); + const lines = prefix.split(request.eol); + return { + edits: [], + newCursor: { + line: request.excerptStartLine + lines.length - 1, + character: lines.at(-1)?.length ?? 0, + }, + }; + } + + setStatus('predicting'); + try { + const response = await fetch('/edit/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + if (response.status === 401) { + window.location.assign('/edit/auth'); + throw new Error('GitHub sign-in required'); + } + if (!response.ok) { + throw new Error('Edit prediction request failed'); + } + const prediction = (await response.json()) as EditPredictResponse; + if (!signal.aborted) { + setStatus(prediction.edits.length === 0 ? 'empty' : 'ready'); + } + return prediction; + } catch (error) { + if (!signal.aborted) { + setStatus('error'); + } + throw error; + } + }, + }), + [] + ); + + const editPrediction = useMemo( + () => ({ provider, mode, include: INCLUDE, exclude: EXCLUDE }), + [mode, provider] + ); + const editorOptions = useMemo>( + () => ({ + editPrediction, + onAttach(editor) { + editorRef.current = editor; + setAttached(true); + }, + onChange(file) { + setHasEdits(file.contents !== EDIT_PREDICTION_NEW_FILE.contents); + if (predictionEnabledRef.current) { + setStatus('waiting'); + } + }, + }), + [editPrediction] + ); + + const pristineFileDiff = useMemo( + () => cloneFileDiffMetadata(prerenderedDiff.fileDiff), + [prerenderedDiff.fileDiff] + ); + const liveFileDiff = useMemo( + () => ({ + ...cloneFileDiffMetadata(pristineFileDiff), + cacheKey: `${pristineFileDiff.name}-${String(resetKey)}`, + }), + [pristineFileDiff, resetKey] + ); + + const reset = useCallback(() => { + predictionEnabledRef.current = false; + editorRef.current = null; + setAttached(false); + setHasEdits(false); + setPredictionEnabled(false); + setStatus('idle'); + setResetKey((key) => key + 1); + }, []); + + const placeCursor = useCallback(() => { + const editor = editorRef.current; + if (editor == null) { + return; + } + const anchor = 'return items.'; + const lines = editor.getText().split(/\r\n|\r|\n/); + const line = lines.findIndex((text) => text.includes(anchor)); + if (line < 0) { + return; + } + const character = lines[line].indexOf(anchor) + anchor.length; + predictionEnabledRef.current = true; + setPredictionEnabled(true); + setStatus('waiting'); + editor.setOptions({ editPrediction: { ...editPrediction } }); + editor.setSelections([ + { + start: { line, character }, + end: { line, character }, + direction: 'none', + }, + ]); + editor.focus({ preventScroll: true }); + }, [editPrediction]); + + const tryCodestral = useCallback(async () => { + setAuthenticating(true); + try { + const response = await fetch('/edit/auth', { + method: 'HEAD', + cache: 'no-store', + }); + if (response.status === 401) { + window.location.assign('/edit/auth'); + return; + } + if (!response.ok) { + setStatus('error'); + return; + } + placeCursor(); + } catch { + setStatus('error'); + } finally { + setAuthenticating(false); + } + }, [placeCursor]); + + const handleModeChange = useCallback( + (value: PredictionMode) => { + setMode(value); + editorRef.current?.setOptions({ + editPrediction: { ...editPrediction, mode: value }, + }); + if (predictionEnabledRef.current) { + setStatus('waiting'); + } + }, + [editPrediction] + ); + + const handleSurfaceChange = useCallback( + (value: Surface) => { + setSurface(value); + reset(); + }, + [reset] + ); + + const statusText = authenticating + ? 'Checking GitHub sign-in…' + : status === 'error' + ? statusTextMap.error + : !predictionEnabled + ? null + : status === 'ready' + ? readyStatusText[mode] + : statusTextMap[status]; + + return ( +
+
+ handleSurfaceChange(value as Surface)} + aria-label="Surface" + > + File + FileDiff + + + handleModeChange(value as PredictionMode)} + aria-label="Prediction mode" + > + Eager + Subtle + + + + +
+ {statusText !== null && ( + + {statusText} + + )} + {!predictionEnabled && ( + + )} +
+
+ + {surface === 'file' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index ebf2805ad..83aa3d878 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -1,15 +1,63 @@ -import { DEFAULT_THEMES, type FileContents } from '@pierre/diffs'; +import { + DEFAULT_THEMES, + type FileContents, + parseDiffFromFile, +} from '@pierre/diffs'; import type { EditorCommand, EditorKeymap } from '@pierre/diffs/edit'; import type { FileOptions } from '@pierre/diffs/react'; -import type { PreloadFileOptions } from '@pierre/diffs/ssr'; +import type { + PreloadFileDiffOptions, + PreloadFileOptions, +} from '@pierre/diffs/ssr'; // The editor requires the token transformer, so enabling it in the SSR preload // keeps hydration from rerendering the surface after the editor attaches. // Mirrors LiveEditing/constants.ts. -const EDITABLE_FILE_OPTIONS: FileOptions = { +const EDITABLE_OPTIONS = { theme: DEFAULT_THEMES, themeType: 'dark', useTokenTransformer: true, +} as const; +const EDITABLE_FILE_OPTIONS: FileOptions = EDITABLE_OPTIONS; + +const EDIT_PREDICTION_OLD_FILE: FileContents = { + name: 'cart.ts', + contents: `// cart calculator + +export type CartItem = { + id: string + name: string + price: number + quantity: number +} + +export function cartTotal(items: CartItem[]): number { + let total = 0 + + for (const item of items) { + total += item.price * item.quantity + } + + return total +} +`, +}; + +export const EDIT_PREDICTION_NEW_FILE: FileContents = { + name: 'cart.ts', + contents: `// cart calculator + +export type CartItem = { + id: string + name: string + price: number + quantity: number +} + +export function cartTotal(items: CartItem[]): number { + return items. +} +`, }; // Lint-marker demo source. Marker positions below are tied to these exact @@ -328,6 +376,23 @@ const DEFAULT_KEYMAP_FILE: FileContents = { // Server-side preload inputs. Spreading the resolved results into ships // pre-rendered, already-highlighted shadow DOM so each demo paints instantly // instead of flashing in after client highlighting. +export const EDIT_PREDICTION_FILE_EXAMPLE: PreloadFileOptions = { + file: EDIT_PREDICTION_NEW_FILE, + options: EDITABLE_FILE_OPTIONS, +}; + +export const EDIT_PREDICTION_FILE_DIFF_EXAMPLE: PreloadFileDiffOptions = + { + fileDiff: parseDiffFromFile( + EDIT_PREDICTION_OLD_FILE, + EDIT_PREDICTION_NEW_FILE + ), + options: { + ...EDITABLE_OPTIONS, + diffStyle: 'unified', + }, + }; + export const MARKER_DEMO_FILE_EXAMPLE: PreloadFileOptions = { file: MARKER_DEMO_FILE, options: EDITABLE_FILE_OPTIONS, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index e71a8fd78..a6d279a5b 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -299,6 +299,44 @@ button.addEventListener('click', () => { options, }; +export const EDIT_PREDICTION_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_edit_prediction.ts', + contents: `import type { + EditorOptions, + EditPredictProvider, + EditPredictResponse, +} from '@pierre/diffs/edit'; + +const provider: EditPredictProvider = { + async predict(request, { signal }) { + // This server endpoint belongs to your application. + const response = await fetch('/edit/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + throw new Error('Edit prediction failed'); + } + return (await response.json()) as EditPredictResponse; + }, +}; + +export const editorOptions = { + editPrediction: { + provider, + mode: 'eager', + include: ['**/*.ts', '**/*.tsx'], + exclude: ['**/*.test.ts', '**/generated/**'], + }, +} satisfies EditorOptions;`, + }, + options, +}; + export const EDIT_SELECTION_ACTION_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_selection_action.ts', @@ -1018,6 +1056,7 @@ export const EDITOR_OPTIONS_TYPE: PreloadFileOptions = { } from '@pierre/diffs'; import { Editor, + type EditPredictProvider, type EditorKeymap, type IStateStorage, } from '@pierre/diffs/edit'; @@ -1062,6 +1101,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. Recommended in Electron apps — use the native // clipboard API: https://www.electronjs.org/docs/latest/api/clipboard clipboard?: { diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index 94b9d2f2f..1a0da3bd3 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -17,6 +17,7 @@ Edit mode features include: - Brace matching - 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) - Markers (inline diagnostics) - SSR support @@ -230,6 +231,50 @@ 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 @@ -425,4 +470,9 @@ Shortcuts use Cmd on macOS and Ctrl on Windows and Linux. Jumping to the document start or end uses the modifier with the Home and End keys; on macOS, the modifier with ↑ and ↓ arrows works too. +Edit prediction also uses Tab (eager) or + +Alt+Tab (subtle) to accept a visible suggestion; otherwise +Tab indents as usual. See [Edit Prediction](#edit-mode-edit-prediction). + 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..d6244638e --- /dev/null +++ b/apps/docs/app/(diffs)/edit/_auth/github.ts @@ -0,0 +1,126 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const GITHUB_AUTH_FALLBACK = '/edit#tab-tab-tab'; +export const GITHUB_AUTH_RETURN_COOKIE = 'pierre_github_auth_return'; +export const GITHUB_OAUTH_STATE_COOKIE = 'pierre_github_oauth_state'; + +const GITHUB_SESSION_COOKIE = 'pierre_github_session'; +const SESSION_MAX_AGE = 60 * 60 * 24 * 7; + +export function getGithubOAuthConfig(): + | { clientId: string; clientSecret: string } + | undefined { + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID?.trim(); + const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) { + return undefined; + } + return { clientId, clientSecret }; +} + +export function getAuthCookie( + request: Request, + name: string +): string | undefined { + for (const cookie of request.headers.get('cookie')?.split(';') ?? []) { + const [cookieName, ...value] = cookie.trim().split('='); + if (cookieName === name) { + try { + return decodeURIComponent(value.join('=')); + } catch { + return undefined; + } + } + } + return undefined; +} + +export function serializeAuthCookie( + request: Request, + name: string, + value: string, + maxAge: number, + path: string +): string { + return `${name}=${encodeURIComponent(value)}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${String(maxAge)}${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`; +} + +export function authValuesMatch(value: string, expected: string): boolean { + const valueBytes = Buffer.from(value); + const expectedBytes = Buffer.from(expected); + return ( + valueBytes.length === expectedBytes.length && + timingSafeEqual(valueBytes, expectedBytes) + ); +} + +export function createGithubSessionCookie( + request: Request, + userId: number +): string | undefined { + const config = getGithubOAuthConfig(); + if (config === undefined) { + return undefined; + } + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE; + const payload = `${String(userId)}.${String(expiresAt)}`; + const signature = createHmac('sha256', config.clientSecret) + .update(payload) + .digest('base64url'); + return serializeAuthCookie( + request, + GITHUB_SESSION_COOKIE, + `${payload}.${signature}`, + SESSION_MAX_AGE, + '/edit' + ); +} + +export function getAuthenticatedGithubUserId( + request: Request +): string | undefined { + const config = getGithubOAuthConfig(); + const session = getAuthCookie(request, GITHUB_SESSION_COOKIE); + if (config === undefined || session === undefined) { + return; + } + const [userId, expiresAt, signature, ...extra] = session.split('.'); + if ( + extra.length > 0 || + !/^[1-9]\d*$/.test(userId ?? '') || + !/^\d+$/.test(expiresAt ?? '') || + signature === undefined || + Number(expiresAt) <= Math.floor(Date.now() / 1000) + ) { + return; + } + const expected = createHmac('sha256', config.clientSecret) + .update(`${userId}.${expiresAt}`) + .digest('base64url'); + return authValuesMatch(signature, expected) ? userId : undefined; +} + +export function isGithubAuthenticated(request: Request): boolean { + return getAuthenticatedGithubUserId(request) !== undefined; +} + +export function normalizeGithubAuthReturnTo( + value: string | undefined, + requestUrl: string +): string { + if (value === undefined || !value.startsWith('/') || value.startsWith('//')) { + return GITHUB_AUTH_FALLBACK; + } + try { + const url = new URL(value, requestUrl); + if ( + url.origin !== new URL(requestUrl).origin || + (url.pathname !== '/edit' && url.pathname !== '/playground') + ) { + return GITHUB_AUTH_FALLBACK; + } + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return GITHUB_AUTH_FALLBACK; + } +} 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..c54a034dd --- /dev/null +++ b/apps/docs/app/(diffs)/edit/auth/route.ts @@ -0,0 +1,267 @@ +import { randomBytes } from 'node:crypto'; + +import { + authValuesMatch, + createGithubSessionCookie, + getAuthCookie, + getGithubOAuthConfig, + GITHUB_AUTH_RETURN_COOKIE, + GITHUB_OAUTH_STATE_COOKIE, + isGithubAuthenticated, + normalizeGithubAuthReturnTo, + serializeAuthCookie, +} from '../_auth/github'; + +const AUTH_PATH = '/edit/auth'; +const CACHE_CONTROL = 'no-store'; +const CALLBACK_URL = '/edit/auth?callback'; +const GITHUB_API_VERSION = '2026-03-10'; +const GITHUB_HEADERS = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Pierre-Diffs', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, +}; +const IS_DIFFS_SITE = (process.env.NEXT_PUBLIC_SITE ?? 'diffs') === 'diffs'; +const STATE_MAX_AGE = 60 * 10; +const TOKEN_REVOCATION_TIMEOUT_MS = 5_000; + +export const runtime = 'nodejs'; + +export function HEAD(request: Request): Response { + const headers = { 'Cache-Control': CACHE_CONTROL }; + if (!IS_DIFFS_SITE) { + return new Response(null, { status: 404, headers }); + } + if (getGithubOAuthConfig() === undefined) { + return new Response(null, { status: 503, headers }); + } + return new Response(null, { + status: isGithubAuthenticated(request) ? 204 : 401, + headers, + }); +} + +export async function GET(request: Request): Promise { + if (!IS_DIFFS_SITE) { + return new Response('Not found.', { status: 404 }); + } + + const requestUrl = new URL(request.url); + if (requestUrl.searchParams.has('callback')) { + return finishGithubOAuth(request); + } + + const returnTo = normalizeGithubAuthReturnTo( + requestUrl.searchParams.get('returnTo') ?? undefined, + request.url + ); + + 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(returnTo, 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); + + const headers = new Headers({ + 'Cache-Control': CACHE_CONTROL, + Location: authorizeUrl.toString(), + }); + headers.append( + 'Set-Cookie', + serializeAuthCookie( + request, + GITHUB_OAUTH_STATE_COOKIE, + state, + STATE_MAX_AGE, + AUTH_PATH + ) + ); + headers.append( + 'Set-Cookie', + serializeAuthCookie( + request, + GITHUB_AUTH_RETURN_COOKIE, + returnTo, + STATE_MAX_AGE, + AUTH_PATH + ) + ); + return new Response(null, { status: 302, headers }); +} + +async function finishGithubOAuth(request: Request): Promise { + const config = getGithubOAuthConfig(); + if (config === undefined) { + return authError(request, 'GitHub sign-in is not configured.', 503); + } + + const requestUrl = new URL(request.url); + const returnTo = normalizeGithubAuthReturnTo( + getAuthCookie(request, GITHUB_AUTH_RETURN_COOKIE), + request.url + ); + const state = requestUrl.searchParams.get('state'); + const expectedState = getAuthCookie(request, GITHUB_OAUTH_STATE_COOKIE); + if ( + state === null || + expectedState === undefined || + !authValuesMatch(state, expectedState) + ) { + return authError(request, 'Invalid GitHub OAuth state.', 400); + } + + if (requestUrl.searchParams.has('error')) { + return authError(request, 'GitHub sign-in was cancelled.', 400); + } + + const code = requestUrl.searchParams.get('code'); + if (code === null || code.length === 0 || code.length > 1024) { + return authError( + request, + 'GitHub did not return an authorization code.', + 400 + ); + } + + const callbackUrl = new URL(CALLBACK_URL, request.url); + let tokenResponse: Response; + try { + tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: callbackUrl.toString(), + }), + signal: request.signal, + }); + } catch { + return authError(request, 'GitHub sign-in is unavailable.', 502); + } + + let tokenJSON: unknown; + try { + tokenJSON = await tokenResponse.json(); + } catch { + return authError(request, 'GitHub returned an invalid access token.', 502); + } + const accessToken = + tokenJSON !== null && typeof tokenJSON === 'object' + ? (tokenJSON as { access_token?: unknown }).access_token + : undefined; + if (!tokenResponse.ok || typeof accessToken !== 'string') { + return authError(request, 'GitHub rejected the authorization code.', 502); + } + + try { + let userResponse: Response; + try { + userResponse = await fetch('https://api.github.com/user', { + cache: 'no-store', + headers: { + ...GITHUB_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + signal: request.signal, + }); + } catch { + return authError(request, 'Could not validate the GitHub user.', 502); + } + + let userJSON: unknown; + try { + userJSON = await userResponse.json(); + } catch { + return authError(request, 'GitHub returned an invalid user.', 502); + } + const user = + userJSON !== null && typeof userJSON === 'object' + ? (userJSON as { id?: unknown }) + : undefined; + const userId = user?.id; + if ( + !userResponse.ok || + typeof userId !== 'number' || + !Number.isSafeInteger(userId) || + userId <= 0 + ) { + return authError(request, 'Could not validate the GitHub user.', 502); + } + + const sessionCookie = createGithubSessionCookie(request, userId); + if (sessionCookie === undefined) { + return authError(request, 'GitHub sign-in is not configured.', 503); + } + const headers = new Headers({ + 'Cache-Control': CACHE_CONTROL, + Location: new URL(returnTo, request.url).toString(), + }); + clearGithubOAuthCookies(request, headers); + headers.append('Set-Cookie', sessionCookie); + return new Response(null, { status: 302, headers }); + } finally { + try { + const revokeResponse = await fetch( + `https://api.github.com/applications/${encodeURIComponent(config.clientId)}/token`, + { + method: 'DELETE', + cache: 'no-store', + headers: { + ...GITHUB_HEADERS, + Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ access_token: accessToken }), + signal: AbortSignal.timeout(TOKEN_REVOCATION_TIMEOUT_MS), + } + ); + if (!revokeResponse.ok) { + console.warn( + `GitHub OAuth token revocation failed with status ${String(revokeResponse.status)}.` + ); + } + } catch { + console.warn('GitHub OAuth token revocation failed.'); + } + } +} + +function authError( + request: Request, + message: string, + status: number +): Response { + const headers = new Headers({ 'Cache-Control': CACHE_CONTROL }); + clearGithubOAuthCookies(request, headers); + return new Response(message, { + status, + headers, + }); +} + +function clearGithubOAuthCookies(request: Request, headers: Headers): void { + headers.append( + 'Set-Cookie', + serializeAuthCookie(request, GITHUB_OAUTH_STATE_COOKIE, '', 0, AUTH_PATH) + ); + headers.append( + 'Set-Cookie', + serializeAuthCookie(request, GITHUB_AUTH_RETURN_COOKIE, '', 0, AUTH_PATH) + ); +} diff --git a/apps/docs/app/(diffs)/edit/page.tsx b/apps/docs/app/(diffs)/edit/page.tsx index 4f7ee4bad..591d7d42e 100644 --- a/apps/docs/app/(diffs)/edit/page.tsx +++ b/apps/docs/app/(diffs)/edit/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next'; import { DEFAULT_KEYMAP_FILE_EXAMPLE, + EDIT_PREDICTION_FILE_DIFF_EXAMPLE, + EDIT_PREDICTION_FILE_EXAMPLE, FIND_DEMO_FILE_EXAMPLE, HISTORY_DEMO_FILE_EXAMPLE, MARKER_DEMO_FILE_EXAMPLE, @@ -16,7 +18,7 @@ import { const editTitle = 'Pierre Diffs — now with edit'; const editDescription = - 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with selection management, multiple cursors, undo history, find/replace, and lint markers.'; + 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with inline edit predictions, selection management, multiple cursors, undo history, find/replace, and lint markers.'; export const metadata: Metadata = { title: editTitle, @@ -34,11 +36,14 @@ export const metadata: Metadata = { // Server-renders every edit demo so they all paint highlighted on first load // and hydrate cleanly (no flash): the "Live editing" File surface, and the -// lint-marker, find-in-file, undo-history, shortcuts, and selection files. +// edit-prediction, lint-marker, find-in-file, undo-history, shortcuts, and +// selection surfaces. export default async function EditRoute() { const [ liveEditingFile, liveEditingDiff, + editPredictionFile, + editPredictionDiff, markerFile, findFile, historyFile, @@ -47,6 +52,8 @@ export default async function EditRoute() { ] = await Promise.all([ preloadFile(LIVE_EDITING_FILE_EXAMPLE), preloadFileDiff(LIVE_EDITING_FILE_DIFF_EXAMPLE), + preloadFile(EDIT_PREDICTION_FILE_EXAMPLE), + preloadFileDiff(EDIT_PREDICTION_FILE_DIFF_EXAMPLE), preloadFile(MARKER_DEMO_FILE_EXAMPLE), preloadFile(FIND_DEMO_FILE_EXAMPLE), preloadFile(HISTORY_DEMO_FILE_EXAMPLE), @@ -58,6 +65,8 @@ export default async function EditRoute() { !/[\r\n]/.test(path)), + version: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + eol: z.enum(['\n', '\r\n', '\r']), + excerptText: z.string().max(MAX_REQUEST_BYTES), + excerptStartLine: z.number().int().nonnegative().max(10_000_000), + cursorOffsetInExcerpt: z.number().int().nonnegative(), + editableRange: z + .object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }) + .strict(), + editHistory: z + .array( + z + .object({ + diff: z.string().min(1), + source: z.enum(['user', 'prediction']), + }) + .strict() + ) + .max(10), + }) + .strict(); + +export const runtime = 'nodejs'; + +export async function POST(request: Request): Promise { + if ( + process.env.NEXT_PUBLIC_SITE !== undefined && + process.env.NEXT_PUBLIC_SITE !== 'diffs' + ) { + return createErrorResponse('Not found.', 404); + } + + const githubUserId = getAuthenticatedGithubUserId(request); + if (githubUserId === undefined) { + return createErrorResponse('GitHub sign-in required.', 401); + } + + const apiKey = process.env.MISTRAL_API_KEY?.trim(); + if (apiKey === undefined || apiKey === '') { + return createErrorResponse('Edit prediction is not configured.', 503); + } + + if ( + request.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() !== 'application/json' + ) { + return createErrorResponse('Expected an application/json request.', 415); + } + + let requestText: string | undefined; + try { + requestText = await readTextWithinLimit(request.body, MAX_REQUEST_BYTES); + } catch { + return createErrorResponse('Could not read the request body.', 400); + } + if (requestText === undefined) { + return createErrorResponse('Request body is too large.', 413); + } + + let json: unknown; + try { + json = JSON.parse(requestText); + } catch { + return createErrorResponse('Request body must be valid JSON.', 400); + } + + const parsed = requestSchema.safeParse(json); + if (!parsed.success) { + return createErrorResponse('Invalid edit prediction request.', 400); + } + const input: EditPredictRequest = parsed.data; + const { cursorOffsetInExcerpt, editableRange, excerptText } = input; + if ( + editableRange.start > cursorOffsetInExcerpt || + cursorOffsetInExcerpt > editableRange.end || + editableRange.end > excerptText.length || + splitsTextUnit(excerptText, editableRange.start) || + splitsTextUnit(excerptText, cursorOffsetInExcerpt) || + splitsTextUnit(excerptText, editableRange.end) || + input.editHistory.some( + ({ diff }) => + textEncoder.encode(diff).byteLength > MAX_HISTORY_ENTRY_BYTES + ) + ) { + return createErrorResponse('Invalid edit prediction request.', 400); + } + + if (process.env.NODE_ENV !== 'development') { + try { + const { error, rateLimited } = await checkRateLimit( + EDIT_PREDICT_RATE_LIMIT_ID, + { + request, + rateLimitKey: githubUserId, + } + ); + if (rateLimited) { + return createErrorResponse('Edit prediction rate limit exceeded.', 429); + } + if (error !== undefined) { + return createErrorResponse( + 'Edit prediction rate limiter is unavailable.', + 503 + ); + } + } catch { + return createErrorResponse( + 'Edit prediction rate limiter is unavailable.', + 503 + ); + } + } + + const upstreamSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(MISTRAL_TIMEOUT_MS), + ]); + let upstream: Response; + try { + upstream = await fetch(CODESTRAL_FIM_URL, { + method: 'POST', + cache: 'no-store', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'codestral-latest', + prompt: excerptText.slice(0, cursorOffsetInExcerpt), + suffix: excerptText.slice(cursorOffsetInExcerpt), + max_tokens: 128, + temperature: 0, + stream: false, + }), + signal: upstreamSignal, + }); + } catch { + if (request.signal.aborted) { + return createErrorResponse('Edit prediction was cancelled.', 499); + } + return createErrorResponse( + upstreamSignal.aborted + ? 'Edit prediction service timed out.' + : 'Edit prediction service is unavailable.', + upstreamSignal.aborted ? 504 : 502 + ); + } + + if (!upstream.ok) { + return createErrorResponse( + upstream.status === 429 + ? 'Edit prediction rate limit exceeded.' + : 'Edit prediction service returned an error.', + upstream.status === 429 ? 429 : 502 + ); + } + + let upstreamText: string | undefined; + try { + upstreamText = await readTextWithinLimit(upstream.body, MAX_UPSTREAM_BYTES); + } catch { + if (request.signal.aborted) { + return createErrorResponse('Edit prediction was cancelled.', 499); + } + return createErrorResponse( + upstreamSignal.aborted + ? 'Edit prediction service timed out.' + : 'Invalid edit prediction response.', + upstreamSignal.aborted ? 504 : 502 + ); + } + if (upstreamText === undefined) { + return createErrorResponse('Edit prediction response is too large.', 502); + } + + let upstreamJSON: unknown; + try { + upstreamJSON = JSON.parse(upstreamText); + } catch { + return createErrorResponse('Invalid edit prediction response.', 502); + } + if ( + upstreamJSON === null || + typeof upstreamJSON !== 'object' || + !Array.isArray((upstreamJSON as { choices?: unknown }).choices) || + (upstreamJSON as { choices: unknown[] }).choices.length !== 1 + ) { + return createErrorResponse('Invalid edit prediction response.', 502); + } + + const choice = (upstreamJSON as { choices: unknown[] }).choices[0]; + if ( + choice === null || + typeof choice !== 'object' || + (choice as { finish_reason?: unknown }).finish_reason !== 'stop' + ) { + return createErrorResponse( + (choice as { finish_reason?: unknown } | null)?.finish_reason === 'length' + ? 'Edit prediction was truncated.' + : 'Invalid edit prediction response.', + 502 + ); + } + + const message = (choice as { message?: unknown }).message; + const completion = + message !== null && typeof message === 'object' + ? (message as { content?: unknown }).content + : undefined; + if ( + typeof completion !== 'string' || + textEncoder.encode(completion).byteLength > MAX_OUTPUT_BYTES + ) { + return createErrorResponse('Invalid edit prediction response.', 502); + } + + const newText = completion.replace(/\r\n|\r|\n/g, input.eol); + if (textEncoder.encode(newText).byteLength > MAX_OUTPUT_BYTES) { + return createErrorResponse('Edit prediction response is too large.', 502); + } + + const relativeCursor = positionAt(excerptText, cursorOffsetInExcerpt); + const cursor = { + line: input.excerptStartLine + relativeCursor.line, + character: relativeCursor.character, + }; + const insertedCursor = positionAt(newText, newText.length); + const response: EditPredictResponse = { + edits: + newText === '' + ? [] + : [ + { + range: { start: cursor, end: cursor }, + newText, + }, + ], + newCursor: + insertedCursor.line === 0 + ? { + line: cursor.line, + character: cursor.character + insertedCursor.character, + } + : { + line: cursor.line + insertedCursor.line, + character: insertedCursor.character, + }, + }; + return Response.json(response, { + headers: { 'Cache-Control': CACHE_CONTROL }, + }); +} + +async function readTextWithinLimit( + body: ReadableStream | null, + limit: number +): Promise { + if (body === null) { + return ''; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + return; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +function splitsTextUnit(text: string, offset: number): boolean { + const current = text.charCodeAt(offset); + const previous = text.charCodeAt(offset - 1); + return ( + (previous === 13 && current === 10) || + (previous >= 0xd800 && + previous <= 0xdbff && + current >= 0xdc00 && + current <= 0xdfff) + ); +} + +function positionAt( + text: string, + offset: number +): { line: number; character: number } { + let line = 0; + let lineStart = 0; + for (let index = 0; index < offset; index++) { + const character = text.charCodeAt(index); + if (character === 13 && text.charCodeAt(index + 1) === 10) { + index++; + } + if (character === 10 || character === 13) { + line++; + lineStart = index + 1; + } + } + return { line, character: offset - lineStart }; +} + +function createErrorResponse(error: string, status: number): Response { + return Response.json( + { error }, + { + status, + headers: { 'Cache-Control': CACHE_CONTROL }, + } + ); +} diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx index b1aaca774..41bba8d69 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx @@ -8,7 +8,12 @@ import { isDiffAnnotationCollection, type SelectedLineRange, } from '@pierre/diffs'; -import type { Editor, EditorOptions } from '@pierre/diffs/edit'; +import type { + Editor, + EditorOptions, + EditPredictProvider, + EditPredictResponse, +} from '@pierre/diffs/edit'; import { type CodeViewReactOptions, FileDiff, @@ -37,6 +42,7 @@ import { IconListOrdered, IconParagraph, IconPencil, + IconSparkle, IconSymbolDiffstat, IconWordWrap, IconXSquircle, @@ -46,6 +52,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { toast } from 'sonner'; +import { CodestralIcon } from '../_edit/CodestralIcon'; import type { PlaygroundAnnotationMetadata } from './constants'; import { CODE_VIEW_ITEMS, @@ -115,6 +122,23 @@ const VIEW_MODE_OPTIONS = [ const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = []; +type PredictionStatus = + | 'idle' + | 'waiting' + | 'predicting' + | 'ready' + | 'empty' + | 'error'; + +const PREDICTION_STATUS_TEXT: Record = { + idle: '', + waiting: 'Codestral ready.', + predicting: 'Predicting…', + ready: 'Prediction ready — press Tab to accept.', + empty: 'No suggestion returned. Keep editing to try again.', + error: 'Prediction unavailable. Check the demo service and try again.', +}; + // Pure rendering options shared by all three view modes. These keys don't depend // on the annotation metadata generic, so a single annotation-agnostic type keeps // them assignable to FileDiff, VirtualizedFileDiff, and CodeView alike (spreading @@ -175,6 +199,9 @@ interface PlaygroundControlsContentProps { setEnableGutterUtility: (v: boolean) => void; showAnnotations: boolean; setShowAnnotations: (v: boolean) => void; + editPredictionEnabled: boolean; + setEditPredictionEnabled: (v: boolean) => void; + editPredictionDisabled: boolean; mode: Mode; setMode: (v: Mode) => void; showMarkers: boolean; @@ -219,6 +246,9 @@ function PlaygroundControlsContent({ setEnableGutterUtility, showAnnotations, setShowAnnotations, + editPredictionEnabled, + setEditPredictionEnabled, + editPredictionDisabled, mode, setMode, showMarkers, @@ -500,6 +530,19 @@ function PlaygroundControlsContent({ onCheckedChange={setShowAnnotations} /> + } + label="Edit prediction" + checked={editPredictionEnabled} + onCheckedChange={setEditPredictionEnabled} + disabled={editPredictionDisabled} + title={ + editPredictionDisabled + ? 'Start editing a file to enable edit prediction' + : undefined + } + /> + {/* Markers use the Normal view's active edit-session editor. */} {viewMode === 'normal' && ( (urlState.mode); const [showMarkers, setShowMarkers] = useState(urlState.showMarkers); + const initialEditPredictionEnabled = + urlState.editPrediction && urlState.mode === 'edit'; + const editPredictionEnabledRef = useRef(initialEditPredictionEnabled); + const [editPredictionEnabled, setEditPredictionEnabled] = useState( + initialEditPredictionEnabled + ); + const [hasActiveChildEditor, setHasActiveChildEditor] = useState(false); + const codestralEnabledRef = useRef(false); + const [authenticating, setAuthenticating] = useState(false); + const [codestralEnabled, setCodestralEnabled] = useState(false); + const [predictionStatus, setPredictionStatus] = + useState('idle'); const [selectedRange, setSelectedRange] = useState( urlState.selectedRange ); @@ -715,6 +770,133 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { : 'none'; const edit = mode === 'edit'; + const editable = viewMode === 'normal' ? edit : hasActiveChildEditor; + + const disableEditPrediction = useCallback(() => { + editPredictionEnabledRef.current = false; + setEditPredictionEnabled(false); + }, []); + + const handleModeChange = useCallback( + (nextMode: Mode) => { + setMode(nextMode); + if (nextMode !== 'edit') { + disableEditPrediction(); + } + }, + [disableEditPrediction] + ); + + const handleChildEditingChange = useCallback( + (editing: boolean) => { + setHasActiveChildEditor(editing); + if (!editing) { + disableEditPrediction(); + } + }, + [disableEditPrediction] + ); + + const handleEditPredictionEnabledChange = useCallback( + (enabled: boolean) => { + editPredictionEnabledRef.current = enabled; + setEditPredictionEnabled(enabled); + if (enabled && codestralEnabled) { + setPredictionStatus('waiting'); + } + }, + [codestralEnabled] + ); + + const redirectToGithubAuth = useCallback(() => { + const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.assign( + `/edit/auth?returnTo=${encodeURIComponent(returnTo)}` + ); + }, []); + + const predictionProvider = useMemo( + () => ({ + async predict(request, { signal }) { + if (!editPredictionEnabledRef.current || !codestralEnabledRef.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, + }, + }; + } + + setPredictionStatus('predicting'); + try { + const response = await fetch('/edit/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + if (response.status === 401) { + redirectToGithubAuth(); + throw new Error('GitHub sign-in required'); + } + if (!response.ok) { + throw new Error('Edit prediction request failed'); + } + const prediction = (await response.json()) as EditPredictResponse; + if (!signal.aborted) { + setPredictionStatus( + prediction.edits.length === 0 ? 'empty' : 'ready' + ); + } + return prediction; + } catch (error) { + if (!signal.aborted) { + setPredictionStatus('error'); + } + throw error; + } + }, + }), + [redirectToGithubAuth] + ); + const editPrediction = useMemo( + () => ({ mode: 'eager' as const, provider: predictionProvider }), + [predictionProvider] + ); + + const tryCodestral = useCallback(async () => { + setAuthenticating(true); + try { + const response = await fetch('/edit/auth', { + method: 'HEAD', + cache: 'no-store', + }); + if (response.status === 401) { + redirectToGithubAuth(); + return; + } + if (!response.ok) { + setPredictionStatus('error'); + return; + } + codestralEnabledRef.current = true; + setCodestralEnabled(true); + setPredictionStatus('waiting'); + } catch { + setPredictionStatus('error'); + } finally { + setAuthenticating(false); + } + }, [redirectToGithubAuth]); + const predictionStatusText = authenticating + ? 'Checking GitHub sign-in…' + : PREDICTION_STATUS_TEXT[predictionStatus]; // Edits remap annotation line numbers (an Enter above a comment shifts it // down); onChange hands the remapped set back so the `lineAnnotations` prop @@ -727,6 +909,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { const editorRef = useRef | null>(null); const editorOptions = useMemo>( () => ({ + editPrediction, onAttach(editor) { editorRef.current = editor; editor.focus({ lineNumber: 'first-visible', preventScroll: true }); @@ -742,7 +925,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { } }, }), - [] + [editPrediction] ); // Apply (or clear) the demo markers whenever the normal view enters an edit @@ -803,6 +986,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { params.set('gutter', enableGutterUtility ? '1' : '0'); if (showAnnotations !== DEFAULTS.annotations) params.set('annot', showAnnotations ? '1' : '0'); + if (editPredictionEnabled !== DEFAULTS.editPrediction) + params.set('predict', editPredictionEnabled ? '1' : '0'); if (mode !== DEFAULTS.mode) params.set('edit', mode); if (showMarkers !== DEFAULTS.markers) params.set('markers', showMarkers ? '1' : '0'); @@ -837,6 +1022,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { enableLineSelection, enableGutterUtility, showAnnotations, + editPredictionEnabled, mode, showMarkers, committedSelectedRange, @@ -951,10 +1137,15 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { // Editing is controlled only in Normal view. Virtualizer and CodeView own // per-surface controls, so return Normal to Review when switching views. - const setViewModeAndResetEditor = useCallback((mode: ViewMode) => { - setViewMode(mode); - if (mode !== 'normal') setMode('review'); - }, []); + const setViewModeAndResetEditor = useCallback( + (mode: ViewMode) => { + setViewMode(mode); + setHasActiveChildEditor(false); + disableEditPrediction(); + if (mode !== 'normal') setMode('review'); + }, + [disableEditPrediction] + ); const [usePrerenderedHTML, setUsePrerenderedHTML] = useState( () => viewMode === 'normal' @@ -994,8 +1185,11 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { setEnableGutterUtility, showAnnotations, setShowAnnotations, + editPredictionEnabled, + setEditPredictionEnabled: handleEditPredictionEnabledChange, + editPredictionDisabled: !editable, mode, - setMode, + setMode: handleModeChange, showMarkers, setShowMarkers, selectedRange, @@ -1195,6 +1389,35 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
+ {editPredictionEnabled && ( +
+

+ Enable Codestral, then edit a file and type to preview a prediction. + Press Tab to accept it. +

+
+ {(authenticating || predictionStatus !== 'idle') && ( + + {predictionStatusText} + + )} + {!codestralEnabled && ( + + )} +
+
+ )} {viewMode === 'normal' ? ( fileDiff ) : viewMode === 'virtualizer' ? ( @@ -1204,6 +1427,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { enableLineSelection={enableLineSelection} enableGutterComments={enableGutterUtility} showAnnotations={showAnnotations} + editPrediction={editPrediction} + onEditingChange={handleChildEditingChange} /> ) : viewMode === 'virtualizer-element' ? ( ) : ( )} diff --git a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx index a5929c635..2e04f35d9 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx @@ -33,18 +33,16 @@ const CODE_VIEW_STYLES = { height: '70vh', overflow: 'auto' } as const; type PlaygroundItem = CodeViewItem; -const CODE_VIEW_EDITOR_OPTIONS: EditorOptions = { - onAttach(editor) { - editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - }, -}; - interface PlaygroundCodeViewProps { items: PlaygroundItem[]; options: CodeViewReactOptions; enableLineSelection: boolean; enableGutterComments: boolean; showAnnotations: boolean; + editPrediction: NonNullable< + EditorOptions['editPrediction'] + >; + onEditingChange: (editing: boolean) => void; } // Renders a mix of diff and file items in a CodeView. Unlike the Virtualizer @@ -69,10 +67,33 @@ export function PlaygroundCodeView({ enableLineSelection, enableGutterComments, showAnnotations, + editPrediction, + onEditingChange, }: PlaygroundCodeViewProps) { const [items, setItems] = useState(initialItems); const [selectedLines, setSelectedLines] = useState(null); + const editorOptions = useMemo>( + () => ({ + editPrediction, + onAttach(editor) { + editor.focus({ lineNumber: 'first-visible', preventScroll: true }); + }, + }), + [editPrediction] + ); + const hasEditingItem = items.some((item) => item.edit === true); + + useEffect(() => { + onEditingChange(hasEditingItem); + }, [hasEditingItem, onEditingChange]); + + useEffect( + () => () => { + onEditingChange(false); + }, + [onEditingChange] + ); const toggleEdit = useCallback((id: string, edit: boolean) => { setItems((current) => @@ -427,7 +448,7 @@ export function PlaygroundCodeView({ return ( ['editPrediction']>; + onEditingChange: (editing: boolean) => void; } // Renders the diff list through the React wrapper, which always @@ -48,13 +50,41 @@ export function PlaygroundVirtualizerElementView({ enableLineSelection, enableGutterComments, showAnnotations, + editPrediction, + onEditingChange, }: PlaygroundVirtualizerElementViewProps) { + const editingItemsRef = useRef(new Set()); + const handleEditingChange = useCallback( + (id: string, editing: boolean) => { + if (editing) { + editingItemsRef.current.add(id); + } else { + editingItemsRef.current.delete(id); + } + onEditingChange(editingItemsRef.current.size > 0); + }, + [onEditingChange] + ); + + useEffect( + () => () => { + onEditingChange(false); + }, + [onEditingChange] + ); + return ( - + + handleEditingChange('long-readme', editing) + } + /> {diffs.map((fileDiff) => ( + handleEditingChange(fileDiff.name, editing) + } /> ))} ); } -const FILE_EDITOR_OPTIONS: EditorOptions = { - onAttach(editor) { - editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - }, -}; - // The long README plain-file surface leading the list. Carries the same // header Edit toggle as the diffs (the app-level EditProvider creates its // editor); no comment wiring, since the demo file has no annotations. -function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { +function ElementVirtualizerFile({ + options, + editPrediction, + onEditingChange, +}: { + options: SharedRenderOptions; + editPrediction: NonNullable['editPrediction']>; + onEditingChange: (editing: boolean) => void; +}) { const [editing, setEditing] = useState(false); + const editorOptions = useMemo>( + () => ({ + editPrediction, + onAttach(editor) { + editor.focus({ lineNumber: 'first-visible', preventScroll: true }); + }, + }), + [editPrediction] + ); const fileOptions = useMemo>( () => ({ @@ -96,7 +141,10 @@ function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { return ( ); - }, [editing]); + }, [editing, onEditingChange]); return ( ); @@ -128,6 +176,8 @@ interface ElementVirtualizerDiffProps { enableLineSelection: boolean; enableGutterComments: boolean; showAnnotations: boolean; + editPrediction: NonNullable['editPrediction']>; + onEditingChange: (editing: boolean) => void; } const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = @@ -143,6 +193,8 @@ function ElementVirtualizerDiff({ enableLineSelection, enableGutterComments, showAnnotations, + editPrediction, + onEditingChange, }: ElementVirtualizerDiffProps) { const [editing, setEditing] = useState(false); const [annotations, setAnnotations] = useState< @@ -160,6 +212,7 @@ function ElementVirtualizerDiff({ // nowhere for the frames in between. const editorOptions = useMemo>( () => ({ + editPrediction, onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, @@ -174,7 +227,7 @@ function ElementVirtualizerDiff({ } }, }), - [] + [editPrediction] ); const addCommentAtRange = useCallback((range: SelectedLineRange) => { @@ -295,7 +348,10 @@ function ElementVirtualizerDiff({ return ( ); - }, [editing]); + }, [editing, onEditingChange]); return ( ['editPrediction']>; + onEditingChange: (editing: boolean) => void; } // Both edit-toggle state icons, inlined as SVG markup. @pierre/icons ships @@ -100,6 +102,8 @@ export function PlaygroundVirtualizerView({ enableLineSelection, enableGutterComments, showAnnotations, + editPrediction, + onEditingChange, }: PlaygroundVirtualizerViewProps) { const pool = useWorkerPool(); const contentRef = useRef(null); @@ -134,6 +138,16 @@ export function PlaygroundVirtualizerView({ } const virtualizer = new Virtualizer(); + const editingToggles = new Set(); + const setToggleEditing = (toggle: HTMLButtonElement, editing: boolean) => { + toggle.setAttribute('aria-pressed', editing ? 'true' : 'false'); + if (editing) { + editingToggles.add(toggle); + } else { + editingToggles.delete(toggle); + } + onEditingChange(editingToggles.size > 0); + }; // Passing `document` makes the page/window the scroll container. virtualizer.setup(document); @@ -146,6 +160,7 @@ export function PlaygroundVirtualizerView({ readmeContainer.style.display = 'block'; content.appendChild(readmeContainer); const readmeEditor = new Editor({ + editPrediction, onAttach(attachedEditor) { attachedEditor.focus({ lineNumber: 'first-visible', @@ -167,7 +182,7 @@ export function PlaygroundVirtualizerView({ ); readmeToggle.addEventListener('click', () => { const editing = readmeToggle.getAttribute('aria-pressed') !== 'true'; - readmeToggle.setAttribute('aria-pressed', editing ? 'true' : 'false'); + setToggleEditing(readmeToggle, editing); if (editing) { readmeEditor.edit(fileInstance); } else { @@ -196,6 +211,7 @@ export function PlaygroundVirtualizerView({ // pre-edit lines. An annotation whose line was deleted is dropped from // the set; retire its orphaned React root. const editor = new Editor({ + editPrediction, onAttach(attachedEditor) { attachedEditor.focus({ lineNumber: 'first-visible', @@ -342,7 +358,7 @@ export function PlaygroundVirtualizerView({ // `aria-pressed` (which also drives the shared toggle styles). editToggle.addEventListener('click', () => { const editing = editToggle.getAttribute('aria-pressed') !== 'true'; - editToggle.setAttribute('aria-pressed', editing ? 'true' : 'false'); + setToggleEditing(editToggle, editing); if (editing) { editor.edit(instance); } else { @@ -375,11 +391,12 @@ export function PlaygroundVirtualizerView({ annotationsRef.current = []; virtualizer.cleanUp(); content.replaceChildren(); + onEditingChange(false); }; // Option changes are applied imperatively in the effect below rather than by // rebuilding the whole virtualizer. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [diffs, pool]); + }, [diffs, editPrediction, onEditingChange, pool]); // Apply live option changes to the existing instances. Spreading over // `instance.options` preserves each file's per-instance callbacks (edit diff --git a/apps/docs/app/(diffs)/playground/searchParams.ts b/apps/docs/app/(diffs)/playground/searchParams.ts index cb010d594..cd40fbde2 100644 --- a/apps/docs/app/(diffs)/playground/searchParams.ts +++ b/apps/docs/app/(diffs)/playground/searchParams.ts @@ -82,6 +82,7 @@ export const DEFAULTS = { gutterButton: true, interactionMode: 'comment' as const, annotations: true, + editPrediction: false, mode: 'review' as Mode, markers: false, } as const; @@ -102,6 +103,7 @@ export interface PlaygroundUrlState { enableLineSelection: boolean; enableGutterUtility: boolean; showAnnotations: boolean; + editPrediction: boolean; mode: Mode; showMarkers: boolean; selectedRange: SelectedLineRange | null; @@ -179,6 +181,7 @@ export function parsePlaygroundSearchParams( enableLineSelection, enableGutterUtility, showAnnotations: pickBool(get('annot'), DEFAULTS.annotations), + editPrediction: pickBool(get('predict'), DEFAULTS.editPrediction), // Edit mode only exists in the Normal view (other views render per-file // edit controls instead), so only honor `?edit=edit` when starting there. mode: viewMode === 'normal' && get('edit') === 'edit' ? 'edit' : 'review', diff --git a/apps/docs/package.json b/apps/docs/package.json index e042732a8..3439fd015 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-tooltip": "catalog:", "@radix-ui/react-use-controllable-state": "catalog:", "@shikijs/transformers": "catalog:", + "@vercel/firewall": "catalog:", "@vscode/web-custom-data": "catalog:", "babel-plugin-react-compiler": "catalog:", "class-variance-authority": "catalog:", diff --git a/packages/diffs/src/editor/editPrediction.ts b/packages/diffs/src/editor/editPrediction.ts new file mode 100644 index 000000000..faa512db9 --- /dev/null +++ b/packages/diffs/src/editor/editPrediction.ts @@ -0,0 +1,737 @@ +import type { Position, ResolvedTextEdit, TextEdit } from '../types'; +import type { TextDocumentChangeTransaction } from './textDocumentChangeTransaction'; + +export interface EditPredictRequest { + /** Current file name/path as supplied to the File or FileDiff component. */ + readonly path: string; + /** Document version. */ + readonly version: number; + /** Document end-of-line sequence. */ + readonly eol: '\n' | '\r\n' | '\r'; + /** Bounded slice of the file around the cursor (not the whole file). */ + readonly excerptText: string; + /** Zero-based document line where the excerpt starts. */ + readonly excerptStartLine: number; + /** UTF-16 cursor offset relative to `excerptText`. */ + readonly cursorOffsetInExcerpt: number; + /** Half-open UTF-16 range within `excerptText` that may be edited. */ + readonly editableRange: { readonly start: number; readonly end: number }; + /** Chronological, bounded edit history for this document. */ + readonly editHistory: ReadonlyArray<{ + /** Edit in unified-diff format. */ + readonly diff: string; + /** Edit source. */ + readonly source: 'user' | 'prediction'; + }>; +} + +export interface EditPredictContext { + /** Aborted when the document or cursor changes. */ + readonly signal: AbortSignal; +} + +export interface EditPredictResponse { + /** Non-overlapping edits using absolute document positions. */ + readonly edits: readonly TextEdit[]; + /** Absolute post-edit cursor position. */ + readonly newCursor: Position; +} + +export interface EditPredictProvider { + /** Predicts the next edit for the given request. */ + predict: ( + request: EditPredictRequest, + context: EditPredictContext + ) => Promise; +} + +export interface EditPredictionHistoryRecord { + readonly path: string; + readonly hunk: string; + readonly start: number; + readonly end: number; + readonly at: number; + readonly source: 'user' | 'prediction'; + readonly fragment?: EditPredictionHistoryFragment; +} + +interface EditPredictionDocument { + readonly version: number; + readonly eol: '\n' | '\r\n' | '\r'; + readonly lineCount: number; + positionAt(offset: number): Position; + positionsAt(offsets: readonly number[]): Position[]; + offsetAt(position: Position): number; + getLineText(line: number): string; + getLineLength(line: number): number; + getTextSlice(start: number, end: number): string; + charAt(offset: number): string; +} + +interface EditPredictionHistoryFragment { + readonly baseText: string; + readonly currentText: string; + readonly currentStart: number; + readonly currentEnd: number; + readonly startLine: number; +} + +interface EditPredictionTransactionFragment { + readonly beforeText: string; + readonly afterText: string; + readonly startOffset: number; + readonly startLine: number; + readonly bounds: LineDiffBounds; +} + +interface LineDiffBounds { + readonly oldLineCount: number; + readonly newLineCount: number; + readonly prefixLines: number; + readonly suffixLines: number; +} + +const EDITABLE_TOKENS = 350; +const CONTEXT_TOKENS = 150; +const MAX_EDITABLE_TOKENS = 512; +const MAX_CONTEXT_TOKENS = 662; +const MAX_REQUEST_BYTES = 128 * 1024; +const MAX_HISTORY_ENTRIES = 10; +const MAX_CAPTURE_BYTES = 6144; +const COALESCE_MS = 1000; +const COALESCE_LINES = 8; +const DIFF_CONTEXT_LINES = 3; +const CAPTURE_CONTEXT_LINES = DIFF_CONTEXT_LINES + COALESCE_LINES; +const CAPTURE_CONTEXT_OPTIONS = [ + CAPTURE_CONTEXT_LINES, + DIFF_CONTEXT_LINES, +] as const; +const textEncoder = new TextEncoder(); + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) === 13 && text.charCodeAt(index + 1) === 10) { + index++; + } + if (text.charCodeAt(index) === 10 || text.charCodeAt(index) === 13) { + starts.push(index + 1); + } + } + return starts; +} + +function lineEnd( + text: string, + starts: readonly number[], + line: number +): number { + const next = starts[line + 1]; + return next === undefined + ? text.length + : next - + (text.charCodeAt(next - 1) === 10 && text.charCodeAt(next - 2) === 13 + ? 2 + : 1); +} + +function linesEqual( + left: string, + leftStarts: readonly number[], + leftLine: number, + right: string, + rightStarts: readonly number[], + rightLine: number +): boolean { + const leftStart = leftStarts[leftLine]; + const rightStart = rightStarts[rightLine]; + const length = lineEnd(left, leftStarts, leftLine) - leftStart; + if (length !== lineEnd(right, rightStarts, rightLine) - rightStart) { + return false; + } + for (let index = 0; index < length; index++) { + if ( + left.charCodeAt(leftStart + index) !== + right.charCodeAt(rightStart + index) + ) { + return false; + } + } + return true; +} + +function lineDiffBounds( + oldText: string, + oldStarts: readonly number[], + newText: string, + newStarts: readonly number[] +): LineDiffBounds { + const oldLineCount = oldText.length === 0 ? 0 : oldStarts.length; + const newLineCount = newText.length === 0 ? 0 : newStarts.length; + let prefixLines = 0; + while (prefixLines < oldLineCount && prefixLines < newLineCount) { + if ( + !linesEqual( + oldText, + oldStarts, + prefixLines, + newText, + newStarts, + prefixLines + ) + ) { + break; + } + prefixLines++; + } + + let suffixLines = 0; + while ( + suffixLines < oldLineCount - prefixLines && + suffixLines < newLineCount - prefixLines + ) { + const oldLine = oldLineCount - 1 - suffixLines; + const newLine = newLineCount - 1 - suffixLines; + if (!linesEqual(oldText, oldStarts, oldLine, newText, newStarts, newLine)) { + break; + } + suffixLines++; + } + return { oldLineCount, newLineCount, prefixLines, suffixLines }; +} + +function formatEditHunk( + path: string, + oldText: string, + newText: string, + lineOffset = 0 +): { readonly hunk: string; readonly bounds: LineDiffBounds } | undefined { + if (oldText === newText) { + return; + } + const oldStarts = lineStarts(oldText); + const newStarts = lineStarts(newText); + const bounds = lineDiffBounds(oldText, oldStarts, newText, newStarts); + if ( + bounds.prefixLines === bounds.oldLineCount && + bounds.prefixLines === bounds.newLineCount + ) { + return; + } + const oldChangedEnd = bounds.oldLineCount - bounds.suffixLines; + const newChangedEnd = bounds.newLineCount - bounds.suffixLines; + const oldChanged = oldText.slice( + oldStarts[bounds.prefixLines] ?? oldText.length, + oldStarts[oldChangedEnd] ?? oldText.length + ); + const newChanged = newText.slice( + newStarts[bounds.prefixLines] ?? newText.length, + newStarts[newChangedEnd] ?? newText.length + ); + if ( + textEncoder.encode(oldChanged).byteLength > MAX_CAPTURE_BYTES || + textEncoder.encode(newChanged).byteLength > MAX_CAPTURE_BYTES + ) { + return; + } + + const start = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES); + const oldEnd = Math.min( + bounds.oldLineCount, + oldChangedEnd + DIFF_CONTEXT_LINES + ); + const newEnd = Math.min( + bounds.newLineCount, + newChangedEnd + DIFF_CONTEXT_LINES + ); + const oldCount = oldEnd - start; + const newCount = newEnd - start; + const line = start + lineOffset; + const output = [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -${oldCount === 0 ? line : line + 1},${oldCount} +${ + newCount === 0 ? line : line + 1 + },${newCount} @@`, + ]; + for (let line = start; line < bounds.prefixLines; line++) { + output.push( + ` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + for (let line = bounds.prefixLines; line < oldChangedEnd; line++) { + output.push( + `-${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + for (let line = bounds.prefixLines; line < newChangedEnd; line++) { + output.push( + `+${newText.slice(newStarts[line], lineEnd(newText, newStarts, line))}` + ); + } + for (let line = oldChangedEnd; line < oldEnd; line++) { + output.push( + ` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}` + ); + } + const hunk = output.join('\n'); + return textEncoder.encode(hunk).byteLength <= MAX_CAPTURE_BYTES + ? { hunk, bounds } + : undefined; +} + +// Applies offset edits to a bounded document slice without materializing the +// surrounding file. Returns undefined if the slice does not contain the edits. +function applyEditsToSlice( + text: string, + sliceStart: number, + edits: readonly ResolvedTextEdit[] +): string | undefined { + const chunks: string[] = []; + let offset = 0; + for (const edit of edits) { + const start = edit.start - sliceStart; + const end = edit.end - sliceStart; + if (start < offset || end < start || end > text.length) { + return; + } + chunks.push(text.slice(offset, start), edit.text); + offset = end; + } + chunks.push(text.slice(offset)); + return chunks.join(''); +} + +// Captures a small post-edit window and reconstructs its pre-edit contents +// from the inverse edits already stored by the undo transaction. +function captureEditPredictionTransaction( + document: EditPredictionDocument, + transaction: TextDocumentChangeTransaction +): EditPredictionTransactionFragment | undefined { + const inverseEdits = transaction.inverseEdits; + if (inverseEdits.length === 0) { + return; + } + let changedStart = inverseEdits[0].start; + let changedEnd = inverseEdits[0].end; + for (let index = 1; index < inverseEdits.length; index++) { + changedStart = Math.min(changedStart, inverseEdits[index].start); + changedEnd = Math.max(changedEnd, inverseEdits[index].end); + } + const [startPosition, endPosition] = document.positionsAt([ + changedStart, + changedEnd, + ]); + for (const contextLines of CAPTURE_CONTEXT_OPTIONS) { + const startLine = Math.max(0, startPosition.line - contextLines); + const endLine = Math.min( + document.lineCount - 1, + endPosition.line + contextLines + ); + const afterStart = document.offsetAt({ + line: startLine, + character: 0, + }); + const afterEnd = + endLine + 1 < document.lineCount + ? document.offsetAt({ line: endLine + 1, character: 0 }) + : document.offsetAt({ + line: endLine, + character: document.getLineLength(endLine), + }); + if (afterEnd - afterStart > MAX_CAPTURE_BYTES) { + continue; + } + const afterText = document.getTextSlice(afterStart, afterEnd); + if (textEncoder.encode(afterText).byteLength > MAX_CAPTURE_BYTES) { + continue; + } + const beforeText = applyEditsToSlice(afterText, afterStart, inverseEdits); + if ( + beforeText === undefined || + beforeText.length > MAX_CAPTURE_BYTES || + textEncoder.encode(beforeText).byteLength > MAX_CAPTURE_BYTES + ) { + continue; + } + const bounds = lineDiffBounds( + beforeText, + lineStarts(beforeText), + afterText, + lineStarts(afterText) + ); + return { + beforeText, + afterText, + startOffset: afterStart, + startLine, + bounds, + }; + } + return undefined; +} + +export function recordEditPrediction( + history: readonly EditPredictionHistoryRecord[], + path: string, + document: EditPredictionDocument, + transaction: TextDocumentChangeTransaction, + source: 'user' | 'prediction', + at: number = Date.now() +): EditPredictionHistoryRecord[] { + const kept = history.slice(-MAX_HISTORY_ENTRIES); + const fragment = captureEditPredictionTransaction(document, transaction); + if (fragment === undefined) { + const previous = kept.at(-1); + if (previous?.fragment !== undefined) { + kept[kept.length - 1] = { ...previous, fragment: undefined }; + } + return kept; + } + if (fragment.beforeText === fragment.afterText) { + return kept; + } + const changedStartLine = fragment.startLine + fragment.bounds.prefixLines; + const beforeChangedEndLine = + fragment.startLine + + fragment.bounds.oldLineCount - + fragment.bounds.suffixLines; + const last = kept.at(-1); + const gap = + last !== undefined && changedStartLine > last.end + ? changedStartLine - last.end + : last !== undefined && last.start > beforeChangedEndLine + ? last.start - beforeChangedEndLine + : 0; + const canMerge = + last !== undefined && + last.fragment !== undefined && + last.path === path && + last.source === source && + at - last.at < COALESCE_MS && + gap <= COALESCE_LINES; + + if (canMerge) { + const previous = last.fragment; + const beforeEnd = fragment.startOffset + fragment.beforeText.length; + const overlapStart = Math.max(previous.currentStart, fragment.startOffset); + const overlapEnd = Math.min(previous.currentEnd, beforeEnd); + if ( + overlapStart <= overlapEnd && + previous.currentText.slice( + overlapStart - previous.currentStart, + overlapEnd - previous.currentStart + ) === + fragment.beforeText.slice( + overlapStart - fragment.startOffset, + overlapEnd - fragment.startOffset + ) + ) { + const unionStart = Math.min(previous.currentStart, fragment.startOffset); + const currentText = + previous.currentStart <= fragment.startOffset + ? previous.currentText + + fragment.beforeText.slice( + Math.max(0, previous.currentEnd - fragment.startOffset) + ) + : fragment.beforeText + + previous.currentText.slice( + Math.max(0, beforeEnd - previous.currentStart) + ); + const prefix = currentText.slice(0, previous.currentStart - unionStart); + const suffix = currentText.slice(previous.currentEnd - unionStart); + const baseText = prefix + previous.baseText + suffix; + const nextText = applyEditsToSlice( + currentText, + unionStart, + transaction.appliedEdits + ); + const startLine = + previous.currentStart <= fragment.startOffset + ? previous.startLine + : fragment.startLine; + if ( + nextText !== undefined && + baseText.length <= MAX_CAPTURE_BYTES && + nextText.length <= MAX_CAPTURE_BYTES && + textEncoder.encode(baseText).byteLength <= MAX_CAPTURE_BYTES && + textEncoder.encode(nextText).byteLength <= MAX_CAPTURE_BYTES + ) { + if (baseText === nextText) { + kept.pop(); + return kept; + } + const formatted = formatEditHunk(path, baseText, nextText, startLine); + if (formatted !== undefined) { + kept[kept.length - 1] = { + path, + hunk: formatted.hunk, + start: startLine + formatted.bounds.prefixLines, + end: + startLine + + formatted.bounds.newLineCount - + formatted.bounds.suffixLines, + at, + source, + fragment: { + baseText, + currentText: nextText, + currentStart: unionStart, + currentEnd: unionStart + nextText.length, + startLine, + }, + }; + return kept; + } + } + } + } + + const previous = kept.at(-1); + if (previous?.fragment !== undefined) { + kept[kept.length - 1] = { ...previous, fragment: undefined }; + } + const formatted = formatEditHunk( + path, + fragment.beforeText, + fragment.afterText, + fragment.startLine + ); + if (formatted === undefined) { + return kept; + } + kept.push({ + path, + hunk: formatted.hunk, + start: changedStartLine, + end: + fragment.startLine + + fragment.bounds.newLineCount - + fragment.bounds.suffixLines, + at, + source, + fragment: { + baseText: fragment.beforeText, + currentText: fragment.afterText, + currentStart: fragment.startOffset, + currentEnd: fragment.startOffset + fragment.afterText.length, + startLine: fragment.startLine, + }, + }); + return kept.slice(-MAX_HISTORY_ENTRIES); +} + +function expandLinewise( + lineCount: number, + costForLine: (line: number) => number, + first: number, + last: number, + remaining: number +): { first: number; last: number } { + while (remaining > 0 && (first > 0 || last < lineCount - 1)) { + let expanded = false; + if (first > 0) { + const cost = costForLine(first - 1); + if (cost <= remaining) { + first--; + remaining -= cost; + expanded = true; + } + } + if (last < lineCount - 1) { + const cost = costForLine(last + 1); + if (cost <= remaining) { + last++; + remaining -= cost; + expanded = true; + } + } + if (!expanded) { + break; + } + } + return { first, last }; +} + +export function buildEditPredictionRequest( + path: string, + document: EditPredictionDocument, + cursorOffset: number, + history: readonly EditPredictionHistoryRecord[] +): EditPredictRequest | undefined { + if (document.lineCount <= 0) { + return; + } + const lastLine = document.lineCount - 1; + const documentLength = document.offsetAt({ + line: lastLine, + character: document.getLineLength(lastLine), + }); + const normalizedCursor = Number.isFinite(cursorOffset) + ? Math.trunc(cursorOffset) + : 0; + let cursor = Math.max(0, Math.min(normalizedCursor, documentLength)); + const previous = document.charAt(cursor - 1).charCodeAt(0); + const next = document.charAt(cursor).charCodeAt(0); + if ( + cursor > 0 && + cursor < documentLength && + ((previous === 13 && next === 10) || + (previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff)) + ) { + cursor--; + } + const cursorLine = document.positionAt(cursor).line; + const tokenCosts = new Map(); + const costForLine = (line: number): number => { + const cached = tokenCosts.get(line); + if (cached !== undefined) { + return cached; + } + const lineLength = document.getLineLength(line); + if (Math.floor(lineLength / 3) > MAX_CONTEXT_TOKENS) { + const cost = MAX_CONTEXT_TOKENS + 1; + tokenCosts.set(line, cost); + return cost; + } + const cost = Math.max( + 1, + Math.floor(textEncoder.encode(document.getLineText(line)).byteLength / 3) + ); + tokenCosts.set(line, cost); + return cost; + }; + + let editableFirst = cursorLine; + let editableLast = cursorLine; + const initialBudget = Math.floor((EDITABLE_TOKENS * 3) / 4); + let remaining = Math.max(0, initialBudget - costForLine(cursorLine)); + while ( + remaining > 0 && + (editableFirst > 0 || editableLast < document.lineCount - 1) + ) { + if (editableLast < document.lineCount - 1) { + const cost = costForLine(editableLast + 1); + if (cost > remaining) { + break; + } + editableLast++; + remaining -= cost; + } + if (editableFirst > 0 && remaining > 0) { + const cost = costForLine(editableFirst - 1); + if (cost > remaining) { + break; + } + editableFirst--; + remaining -= cost; + } + } + remaining += EDITABLE_TOKENS - initialBudget; + ({ first: editableFirst, last: editableLast } = expandLinewise( + document.lineCount, + costForLine, + editableFirst, + editableLast, + remaining + )); + + let contextFirst = editableFirst; + let contextLast = editableLast; + ({ first: contextFirst, last: contextLast } = expandLinewise( + document.lineCount, + costForLine, + contextFirst, + contextLast, + CONTEXT_TOKENS + )); + let editableTokens = 0; + for (let line = editableFirst; line <= editableLast; line++) { + editableTokens += costForLine(line); + } + let contextTokens = 0; + for (let line = contextFirst; line <= contextLast; line++) { + contextTokens += costForLine(line); + } + if ( + editableTokens > MAX_EDITABLE_TOKENS || + contextTokens > MAX_CONTEXT_TOKENS + ) { + return; + } + + const contextStart = document.offsetAt({ + line: contextFirst, + character: 0, + }); + const contextEnd = document.offsetAt({ + line: contextLast, + character: document.getLineLength(contextLast), + }); + const excerptText = document.getTextSlice(contextStart, contextEnd); + const request: EditPredictRequest = { + path, + version: document.version, + eol: document.eol, + excerptText, + excerptStartLine: contextFirst, + cursorOffsetInExcerpt: cursor - contextStart, + editableRange: { + start: + document.offsetAt({ line: editableFirst, character: 0 }) - contextStart, + end: + document.offsetAt({ + line: editableLast, + character: document.getLineLength(editableLast), + }) - contextStart, + }, + editHistory: history + .slice(-MAX_HISTORY_ENTRIES) + .map(({ hunk, source }) => ({ + diff: hunk, + source, + })), + }; + return textEncoder.encode(JSON.stringify(request)).byteLength <= + MAX_REQUEST_BYTES + ? request + : undefined; +} + +export function matchesEditPredictionPattern( + path: string, + pattern: string | RegExp +): boolean { + if (typeof pattern !== 'string') { + return new RegExp(pattern.source, pattern.flags).test(path); + } + + pattern = pattern.replaceAll('\\', '/'); + let source = '^'; + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + if (character === '*') { + if (pattern[index + 1] === '*') { + index++; + if (pattern[index + 1] === '/') { + index++; + source += '(?:.*/)?'; + } else { + source += '.*'; + } + } else { + source += '[^/]*'; + } + } else if (character === '?') { + source += '[^/]'; + } else { + source += /[\\^$.*+?()[\]{}|]/.test(character) + ? `\\${character}` + : character; + } + } + return new RegExp(`${source}$`).test(path); +} diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index a65a8bf78..b580b11a0 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -69,9 +69,13 @@ display: contents; } [data-caret], +[data-edit-prediction], [data-selection-range], [data-match-range], [data-bracket-match-range], +[data-edit-prediction-deletion-range], +[data-edit-prediction-insertion-range], +[data-edit-prediction-replacement-range], [data-marker-range] { position: absolute; top: 0; @@ -80,7 +84,66 @@ line-height: var(--diffs-line-height); pointer-events: none; } +[data-edit-prediction] { + z-index: 1; + height: auto; + overflow: visible; + width: max-content; + user-select: none; + color: var( + --diffs-editor-edit-prediction-fg, + color-mix(in lab, var(--diffs-fg) 45%, transparent) + ); +} +[data-edit-prediction-line] { + box-sizing: border-box; + display: block; + min-height: 1lh; + width: max-content; + white-space: pre; +} +[data-edit-prediction][data-replacement] [data-edit-prediction-line], +[data-edit-prediction-insertion-range], +[data-edit-prediction-replacement-range] { + background-color: var(--diffs-edit-prediction-bg, var(--diffs-bg)); +} +[data-edit-prediction-suffix] { + color: var(--diffs-fg); +} +[data-edit-prediction][data-wrap] [data-edit-prediction-line] { + max-width: 100%; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +[data-edit-prediction-line][data-empty]::after { + content: '↵'; +} +[data-edit-prediction-spacer] { + margin-block-end: var(--diffs-edit-prediction-spacer-height); +} +[data-edit-prediction-deletion-range] { + --diffs-edit-prediction-deletion-color: var( + --diffs-editor-edit-prediction-deletion-fg, + color-mix(in lab, var(--diffs-deletion-base) 70%, transparent) + ); + + z-index: 1; + background: linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + var(--diffs-edit-prediction-deletion-color) calc(50% - 0.5px), + var(--diffs-edit-prediction-deletion-color) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +[data-edit-prediction-insertion-range] { + z-index: 0; +} +[data-edit-prediction-replacement-range] { + z-index: 1; +} [data-caret] { + z-index: 2; width: 2px; background-color: var( --diffs-bg-caret-override, diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index b8af7cd87..a35b1960e 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -22,6 +22,7 @@ import type { SelectionSide, TextEdit, } from '../types'; +import { countLineBreaks } from '../utils/computeFileOffsets'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; import { @@ -31,6 +32,14 @@ import { resolveFindAgainShortcut, } from './command'; import editorCSS from './editor.css?inline'; +import { + buildEditPredictionRequest, + type EditPredictionHistoryRecord, + type EditPredictProvider, + type EditPredictResponse, + matchesEditPredictionPattern, + recordEditPrediction, +} from './editPrediction'; import { EditStack } from './editStack'; import { type LanguageConfigMap, @@ -114,6 +123,7 @@ import { type PersistStateStorage, } from './stateStorage'; import { TextDocument, type TextDocumentChange } from './textDocument'; +import { getTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; import { getExpandedAsciiTextColumns, getUnicodeMeasurementOffsets, @@ -127,61 +137,17 @@ import { extend, getLineNumberAttr, h, + isPromise, lookupScrollContainer, 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'; interface ViewportInputWatch { userScrolled(): boolean; @@ -203,9 +169,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. @@ -215,10 +181,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. @@ -274,10 +268,23 @@ const MAX_EDIT_WIDEN_WINDOW_MULTIPLE = 2; // line. Past this many lines the cache resets and refills lazily for whatever // is measured next. A memory bound, not a correctness-critical value. const MAX_WRAP_OFFSETS_CACHE_LINES = 10_000; +const EDIT_PREDICTION_DEBOUNCE_MS = 300; +const MAX_EDIT_PREDICTION_RESPONSE_EDITS = 256; +const MAX_EDIT_PREDICTION_RESPONSE_BYTES = 128 * 1024; +const editPredictionTextEncoder = new TextEncoder(); const SELECTION_ACTION_POPOVER_PLACEMENT_KEY = 'selection-action'; const MULTI_SELECTION_CLIPBOARD_TYPE = 'application/vnd.pierre.diffs-selections+json'; +type OverlayRangeType = + | 'selection' + | 'match' + | 'marker' + | 'bracketMatch' + | 'editPredictionDeletion' + | 'editPredictionInsertion' + | 'editPredictionReplacement'; + export class Editor implements DiffsEditor { #options: EditorOptions; #metrics = new Metrics(); @@ -303,13 +310,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 }; @@ -344,12 +344,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; @@ -396,6 +390,27 @@ export class Editor implements DiffsEditor { #retainSearchPanelFocus = false; #fontRemeasureScheduled = false; #themeSelectionRefreshFrame?: number; + #editPredictionTimer?: ReturnType; + #editPredictionAbortController?: AbortController; + #editPredictionGeneration = 0; + #editPredictionAltPressed = false; + #editPrediction?: { + document: TextDocument; + version: number; + cursorOffset: number; + rendered: boolean; + response: EditPredictResponse; + renderEdits: readonly TextEdit[]; + }; + #editPredictionHistory: EditPredictionHistoryRecord[] = []; + #editPredictionSpacers = new Map(); + // onAttach is deferred until the synchronized document and DOM are usable. + // Track the state so cleanup cannot notify an editor from an ended session. + #attachState = { + generation: 0, + callback: undefined as (() => void) | undefined, + delivered: false, + }; #onDeferTokenize = ( lines: Map>, @@ -408,6 +423,15 @@ export class Editor implements DiffsEditor { this.#renderRange !== undefined && this.#renderRange.totalLines !== Infinity ) { + const predictionLines = + this.#editPrediction === undefined + ? undefined + : new Set( + this.#editPrediction.renderEdits.map( + (edit) => edit.range.start.line + ) + ); + let refreshPrediction = false; const { startingLine, totalLines } = this.#renderRange; const endLine = Math.min( startingLine + totalLines, @@ -418,9 +442,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); + } } }; @@ -431,6 +459,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, @@ -441,7 +470,7 @@ export class Editor implements DiffsEditor { ) { const file = this.#fileInstance.__getCurrentFile?.() ?? this.#fileInfo; if (file !== undefined) { - requirePersistedCacheKey(file); + assertCacheKey(file); } } this.#options = nextOptions; @@ -459,6 +488,11 @@ export class Editor implements DiffsEditor { this.#stateStorageOption = undefined; this.#pendingStateWrites.clear(); } + if (this.#options.editPrediction !== previousEditPrediction) { + this.#cancelEditPrediction(true); + this.#editPredictionHistory = []; + this.#scheduleEditPrediction(); + } } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -469,7 +503,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(); @@ -738,6 +772,11 @@ export class Editor implements DiffsEditor { cleanUp(recycle = false): void { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); + this.#editPredictionAltPressed = false; + if (!recycle) { + this.#editPredictionHistory = []; + } if (!recycle) { this.#attachState.delivered = false; } @@ -824,12 +863,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) ) { @@ -947,7 +986,7 @@ export class Editor implements DiffsEditor { this.#fileInfo.cacheKey !== fileOrDiff.cacheKey; const persistedCacheKey = this.#options.persistState === true - ? requirePersistedCacheKey(fileOrDiff) + ? assertCacheKey(fileOrDiff) : undefined; let persistedStateTarget: @@ -956,6 +995,7 @@ export class Editor implements DiffsEditor { if (shouldRebuildDocument) { this.#invalidateOnAttach(); + this.#cancelEditPrediction(false); let contents = ''; if ('contents' in fileOrDiff) { contents = fileOrDiff.contents; @@ -983,6 +1023,7 @@ export class Editor implements DiffsEditor { new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; + this.#editPredictionHistory = []; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -1043,6 +1084,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', @@ -1217,7 +1259,7 @@ export class Editor implements DiffsEditor { return; } - const cacheKey = requirePersistedCacheKey(fileInfo); + const cacheKey = assertCacheKey(fileInfo); this.#textDocumentCache.set(cacheKey, textDocument); let storage: IStateStorage; @@ -1278,7 +1320,7 @@ export class Editor implements DiffsEditor { return; } - this.#trackStateWrite(cacheKey, result); + this.#trackStateWrite(cacheKey, Promise.resolve(result)); } #trackStateWrite(cacheKey: string, result: Promise): void { @@ -1313,7 +1355,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; } @@ -1345,7 +1387,9 @@ export class Editor implements DiffsEditor { return; } if (isPromise(result)) { - return result.then(applyState).catch(() => {}); + return Promise.resolve(result) + .then(applyState) + .catch(() => {}); } else { try { applyState(result); @@ -1358,13 +1402,14 @@ export class Editor implements DiffsEditor { pendingWrite === undefined ? readState() : pendingWrite.then(readState); if (isPromise(result)) { inputWatch = this.#watchViewportUserInput(); + const completion = Promise.resolve(result).catch(() => {}); const pendingRestore = { cacheKey, textDocument, documentVersion, selections, view, - completion: result.catch(() => {}), + completion, }; this.#pendingStateRestore = pendingRestore; void pendingRestore.completion.finally(() => { @@ -1678,15 +1723,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) @@ -1799,6 +1861,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 } @@ -1810,6 +1879,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 } @@ -1981,6 +2065,24 @@ export class Editor implements DiffsEditor { // typing, moving); let selectionchange sync #selections again. this.#suppressNativeSelectionSync = false; + if ( + e.key === 'Tab' && + !e.shiftKey && + !e.ctrlKey && + !e.metaKey && + !e.isComposing && + !this.#isComposing && + (!e.altKey || this.#options.editPrediction?.mode === 'subtle') && + this.#acceptEditPrediction( + this.#options.editPrediction?.mode !== 'subtle' || + this.#editPredictionAltPressed || + e.altKey + ) + ) { + e.preventDefault(); + return; + } + const command = resolveEditorCommandFromKeyboardEvent( e, this.#options.keymap @@ -1988,6 +2090,13 @@ export class Editor implements DiffsEditor { if (command !== undefined) { e.preventDefault(); if (command === 'simplifySelection') { + if ( + this.#editPredictionTimer !== undefined || + this.#editPredictionAbortController !== undefined || + this.#editPrediction !== undefined + ) { + this.#cancelEditPrediction(true); + } this.#searchPanel?.close(); this.#searchPanel = undefined; this.#retainSearchPanelFocus = false; @@ -2154,6 +2263,7 @@ export class Editor implements DiffsEditor { return; } if (e.inputType === 'insertCompositionText') { + this.#cancelEditPrediction(true); return; } e.preventDefault(); @@ -2175,6 +2285,7 @@ export class Editor implements DiffsEditor { if (!targetIsContentElement(e)) { return; } + this.#cancelEditPrediction(true); this.#isComposing = true; this.#shouldIgnoreSelectionChange = true; }, @@ -4090,9 +4201,702 @@ export class Editor implements DiffsEditor { }); } - #updateSelections(selections: EditorSelection[]) { + #removeRenderedEditPrediction(): void { + for (const [key, element] of this.#overlayElements ?? []) { + if (key.startsWith('editPrediction')) { + element.remove(); + this.#overlayElements?.delete(key); + } + } + } + + // Reserve numberless grid space for ghost continuation lines without adding + // rows that could be mistaken for document content by the editor. + #syncEditPredictionSpacers(): void { + const nextSpacers = new Map(); + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const contentElement = this.#contentElement; + if ( + prediction !== undefined && + prediction.document === textDocument && + prediction.version === textDocument?.version && + contentElement !== undefined && + (this.#options.editPrediction?.mode !== 'subtle' || + this.#editPredictionAltPressed) + ) { + const continuationLines = new Map(); + for (const edit of prediction.renderEdits) { + if ( + edit.newText.length === 0 || + !this.#isLineVisible(edit.range.start.line) + ) { + continue; + } + const count = countLineBreaks(edit.newText); + if (count > (continuationLines.get(edit.range.start.line) ?? 0)) { + continuationLines.set(edit.range.start.line, count); + } + } + + if (continuationLines.size > 0) { + let rowIndexes: Map | undefined; + const startingLine = this.#renderRange?.startingLine ?? 0; + for (const [line, count] of continuationLines) { + const lineElement = this.#getLineElement(line); + if (lineElement === undefined) { + continue; + } + let rowIndex = line - startingLine; + if (contentElement.children[rowIndex] !== lineElement) { + if (rowIndexes === undefined) { + rowIndexes = new Map(); + for ( + let index = 0; + index < contentElement.children.length; + index++ + ) { + rowIndexes.set(contentElement.children[index], index); + } + } + rowIndex = rowIndexes.get(lineElement) ?? -1; + } + if (rowIndex < 0) { + continue; + } + nextSpacers.set(lineElement, count); + const gutterRow = this.#gutterElement?.children[rowIndex]; + if (gutterRow instanceof HTMLElement) { + nextSpacers.set(gutterRow, count); + } + } + } + } + + let changed = false; + for (const [element, count] of this.#editPredictionSpacers) { + if (nextSpacers.get(element) === count) { + continue; + } + delete element.dataset.editPredictionSpacer; + element.style.removeProperty('--diffs-edit-prediction-spacer-height'); + changed = true; + } + for (const [element, count] of nextSpacers) { + if (this.#editPredictionSpacers.get(element) === count) { + continue; + } + element.dataset.editPredictionSpacer = ''; + element.style.setProperty( + '--diffs-edit-prediction-spacer-height', + `${count}lh` + ); + changed = true; + } + this.#editPredictionSpacers = nextSpacers; + if (changed) { + this.#resetCache(); + } + } + + #cancelEditPrediction(removeRendered: boolean): void { + if (this.#editPredictionTimer !== undefined) { + clearTimeout(this.#editPredictionTimer); + this.#editPredictionTimer = undefined; + } + this.#editPredictionAbortController?.abort(); + this.#editPredictionAbortController = undefined; + this.#editPredictionGeneration++; + this.#editPrediction = undefined; + this.#contentElement?.style.removeProperty('padding-block-end'); + this.#syncEditPredictionSpacers(); + if (removeRendered) { + this.#removeRenderedEditPrediction(); + } + } + + #includesEditPredictionPath(path: string): boolean { + const options = this.#options.editPrediction; + if (options === undefined) { + return false; + } + const normalizedPath = path.replaceAll('\\', '/'); + return ( + (options.include === undefined || + options.include.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + )) && + options.exclude?.some((pattern) => + matchesEditPredictionPattern(normalizedPath, pattern) + ) !== true + ); + } + + #scheduleEditPrediction(alreadyCancelled = false): void { + if ( + !alreadyCancelled || + this.#editPredictionTimer !== undefined || + this.#editPredictionAbortController !== undefined || + this.#editPrediction !== undefined + ) { + this.#cancelEditPrediction(true); + } + const selection = this.#selections?.[0]; + if ( + this.#options.editPrediction === undefined || + this.#textDocument === undefined || + this.#fileInfo === undefined || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) + ) { + return; + } + + const document = this.#textDocument; + const cursorOffset = document.offsetAt(getCaretPosition(selection)); + this.#editPredictionTimer = setTimeout(() => { + this.#editPredictionTimer = undefined; + const options = this.#options.editPrediction; + const currentSelection = this.#selections?.[0]; + const path = this.#fileInfo?.name; + if ( + options === undefined || + path === undefined || + this.#textDocument !== document || + this.#selections?.length !== 1 || + currentSelection === undefined || + !isCollapsedSelection(currentSelection) || + document.offsetAt(getCaretPosition(currentSelection)) !== cursorOffset + ) { + return; + } + + if (!this.#includesEditPredictionPath(path)) { + return; + } + + const request = buildEditPredictionRequest( + path, + document, + cursorOffset, + this.#editPredictionHistory + ); + if (request === undefined) { + return; + } + const excerptStartOffset = document.offsetAt({ + line: request.excerptStartLine, + character: 0, + }); + const editableStart = excerptStartOffset + request.editableRange.start; + const editableEnd = excerptStartOffset + request.editableRange.end; + const controller = new AbortController(); + const generation = ++this.#editPredictionGeneration; + this.#editPredictionAbortController = controller; + + let prediction: Promise; + try { + prediction = options.provider.predict(request, { + signal: controller.signal, + }); + } catch { + this.#editPredictionAbortController = undefined; + return; + } + + void Promise.resolve(prediction) + .then((response) => { + const selection = this.#selections?.[0]; + if ( + controller.signal.aborted || + generation !== this.#editPredictionGeneration || + this.#editPredictionAbortController !== controller || + this.#textDocument !== document || + document.version !== request.version || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) || + document.offsetAt(getCaretPosition(selection)) !== cursorOffset + ) { + return; + } + + if ( + response == null || + !Array.isArray(response.edits) || + response.edits.length === 0 || + response.edits.length > MAX_EDIT_PREDICTION_RESPONSE_EDITS || + response.newCursor == null + ) { + return; + } + const resolvedEdits: ResolvedTextEdit[] = []; + let responseBytes = 0; + for (const edit of response.edits) { + if ( + edit == null || + typeof edit.newText !== 'string' || + !isValidEditPredictionPosition(document, edit.range?.start) || + !isValidEditPredictionPosition(document, edit.range?.end) || + comparePosition(edit.range.start, edit.range.end) > 0 + ) { + return; + } + responseBytes += editPredictionTextEncoder.encode( + edit.newText + ).byteLength; + if (responseBytes > MAX_EDIT_PREDICTION_RESPONSE_BYTES) { + return; + } + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + if ( + splitsSurrogatePair( + document.charAt(start - 1) + document.charAt(start), + 1 + ) || + splitsSurrogatePair( + document.charAt(end - 1) + document.charAt(end), + 1 + ) + ) { + return; + } + resolvedEdits.push({ start, end, text: edit.newText }); + } + resolvedEdits.sort((left, right) => { + const startDelta = left.start - right.start; + return startDelta === 0 ? left.end - right.end : startDelta; + }); + for (let index = 0; index < resolvedEdits.length; index++) { + const edit = resolvedEdits[index]; + if ( + edit.start < editableStart || + edit.end > editableEnd || + (index > 0 && resolvedEdits[index - 1].end > edit.start) + ) { + return; + } + } + const edits = resolvedEdits.filter( + (edit) => edit.text !== document.getTextSlice(edit.start, edit.end) + ); + if (edits.length === 0) { + return; + } + + const firstEditPosition = document.positionAt(edits[0].start); + const lastEditPosition = document.positionAt(edits.at(-1)!.end); + const affectedStart = document.offsetAt({ + line: firstEditPosition.line, + character: 0, + }); + const affectedEnd = document.offsetAt({ + line: lastEditPosition.line, + character: document.getLineLength(lastEditPosition.line), + }); + const predictedParts: string[] = []; + let consumed = affectedStart; + for (const edit of edits) { + predictedParts.push( + document.getTextSlice(consumed, edit.start), + edit.text + ); + consumed = edit.end; + } + predictedParts.push(document.getTextSlice(consumed, affectedEnd)); + const predictedLines = predictedParts.join('').split(/\r\n|\r|\n/); + const affectedEndLine = + firstEditPosition.line + predictedLines.length - 1; + const lineDelta = + predictedLines.length - + (lastEditPosition.line - firstEditPosition.line + 1); + const newCursor = response.newCursor; + if ( + !Number.isInteger(newCursor.line) || + !Number.isInteger(newCursor.character) || + newCursor.line < 0 || + newCursor.character < 0 + ) { + return; + } + if ( + newCursor.line >= firstEditPosition.line && + newCursor.line <= affectedEndLine + ) { + const line = + predictedLines[newCursor.line - firstEditPosition.line]; + if ( + newCursor.character > line.length || + splitsSurrogatePair(line, newCursor.character) + ) { + return; + } + } else { + const originalLine = + newCursor.line < firstEditPosition.line + ? newCursor.line + : newCursor.line - lineDelta; + if ( + originalLine < 0 || + originalLine >= document.lineCount || + newCursor.character > document.getLineLength(originalLine) + ) { + return; + } + const originalOffset = document.offsetAt({ + line: originalLine, + character: newCursor.character, + }); + if ( + splitsSurrogatePair( + document.charAt(originalOffset - 1) + + document.charAt(originalOffset), + 1 + ) + ) { + return; + } + } + + const responseEdits = edits.map((edit) => ({ + range: { + start: document.positionAt(edit.start), + end: document.positionAt(edit.end), + }, + newText: edit.text, + })); + // One overlay owns each source line so masked suffixes are redrawn as + // the exact post-edit text. + const renderEdits: TextEdit[] = []; + for (let index = 0; index < responseEdits.length; index++) { + const edit = responseEdits[index]; + let groupEnd = index; + let groupEndLine = edit.range.end.line; + while ( + responseEdits[groupEnd + 1]?.range.start.line === groupEndLine + ) { + groupEnd++; + groupEndLine = responseEdits[groupEnd].range.end.line; + } + if (groupEnd === index) { + renderEdits.push(edit); + continue; + } + + const groupEndCharacter = document.getLineLength(groupEndLine); + const renderEnd = document.offsetAt({ + line: groupEndLine, + character: groupEndCharacter, + }); + const newText: string[] = []; + let consumed = edits[index].start; + for (let groupIndex = index; groupIndex <= groupEnd; groupIndex++) { + const groupEdit = edits[groupIndex]; + newText.push( + document.getTextSlice(consumed, groupEdit.start), + groupEdit.text + ); + consumed = groupEdit.end; + } + newText.push(document.getTextSlice(consumed, renderEnd)); + renderEdits.push({ + range: { + start: { ...edit.range.start }, + end: { + line: groupEndLine, + character: groupEndCharacter, + }, + }, + newText: newText.join(''), + }); + index = groupEnd; + } + this.#editPrediction = { + document, + version: request.version, + cursorOffset, + rendered: false, + response: { + edits: responseEdits, + newCursor: { ...newCursor }, + }, + renderEdits, + }; + this.#updateSelections(this.#selections); + }) + .catch(() => {}) + .finally(() => { + if (this.#editPredictionAbortController === controller) { + this.#editPredictionAbortController = undefined; + } + }); + }, EDIT_PREDICTION_DEBOUNCE_MS); + } + + #recordEditPredictionHistory( + change: TextDocumentChange, + source: 'user' | 'prediction' + ): void { + const textDocument = this.#textDocument; + const path = this.#fileInfo?.name; + const transaction = getTextDocumentChangeTransaction(change); + if ( + textDocument === undefined || + path === undefined || + transaction === undefined || + !this.#includesEditPredictionPath(path) + ) { + return; + } + this.#editPredictionHistory = recordEditPrediction( + this.#editPredictionHistory, + path, + textDocument, + transaction, + source + ); + } + + #acceptEditPrediction(visible: boolean): boolean { + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const selection = this.#selections?.[0]; + if ( + !visible || + prediction === undefined || + !prediction.rendered || + textDocument === undefined || + prediction.document !== textDocument || + prediction.version !== textDocument.version || + this.#selections?.length !== 1 || + selection === undefined || + !isCollapsedSelection(selection) || + textDocument.offsetAt(getCaretPosition(selection)) !== + prediction.cursorOffset + ) { + return false; + } + + const { edits, newCursor } = prediction.response; + this.#cancelEditPrediction(true); + const change = textDocument.applyEdits( + edits.map((edit) => ({ + range: { + start: { ...edit.range.start }, + end: { ...edit.range.end }, + }, + newText: edit.newText, + })), + true, + this.#selections, + undefined, + true + ); + if (change === undefined) { + this.#scheduleEditPrediction(); + return false; + } + + const cursor = textDocument.normalizePosition(newCursor); + const nextSelections: EditorSelection[] = [ + { start: cursor, end: cursor, direction: DirectionNone }, + ]; + textDocument.setLastUndoSelectionsAfter(nextSelections); + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change), + { editSource: 'prediction' } + ); + return true; + } + + #renderEditPrediction(renderCtx: { + fragment: DocumentFragment; + elements: Map; + }): void { + const prediction = this.#editPrediction; + const textDocument = this.#textDocument; + const contentElement = this.#contentElement; + contentElement?.style.removeProperty('padding-block-end'); + if (prediction !== undefined) { + prediction.rendered = false; + } + if ( + prediction === undefined || + prediction.document !== textDocument || + prediction.version !== textDocument?.version || + (this.#options.editPrediction?.mode === 'subtle' && + !this.#editPredictionAltPressed) + ) { + return; + } + + const isWrap = this.#isWrap; + for ( + let editIndex = 0; + editIndex < prediction.renderEdits.length; + editIndex++ + ) { + const edit = prediction.renderEdits[editIndex]; + const { start, end } = edit.range; + const isDeletion = edit.newText.length === 0; + const isReplacement = comparePosition(start, end) !== 0; + const lineLength = textDocument.getLineLength(start.line); + const isMidLineInsertion = !isReplacement && start.character < lineLength; + if (isReplacement) { + const elementCount = renderCtx.elements.size; + this.#renderSelection( + renderCtx, + isDeletion ? 'editPredictionDeletion' : 'editPredictionReplacement', + { start, end } + ); + prediction.rendered ||= renderCtx.elements.size > elementCount; + } else if (isMidLineInsertion) { + // Hide the in-flow suffix so ghost text never collides with it. + this.#renderSelection(renderCtx, 'editPredictionInsertion', { + start, + end: { line: start.line, character: lineLength }, + }); + } + + if (isDeletion || !this.#isLineVisible(start.line)) { + continue; + } + + const [anchorLeft, anchorWrapLine] = this.#getCharX( + start.line, + start.character + ); + const lineLeft = this.#getCharX(start.line, 0)[0]; + const anchorTop = + this.#getLineY(start.line) + anchorWrapLine * this.#metrics.lineHeight; + const key = `editPrediction-${editIndex}`; + let element = this.#overlayElements?.get(key); + if (element !== undefined) { + this.#overlayElements?.delete(key); + element.replaceChildren(); + } else { + element = h( + 'span', + { + ariaHidden: 'true', + contentEditable: 'false', + dataset: 'editPrediction', + }, + renderCtx.fragment + ); + } + if (isReplacement) { + element.dataset.replacement = ''; + const lineElement = this.#getLineElement(start.line); + if (lineElement !== undefined) { + element.style.setProperty( + '--diffs-edit-prediction-bg', + getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg') + ); + } + } else { + delete element.dataset.replacement; + element.style.removeProperty('--diffs-edit-prediction-bg'); + } + if (isWrap) { + element.dataset.wrap = ''; + element.style.width = `calc(100cqw - ${lineLeft}px)`; + } else { + delete element.dataset.wrap; + element.style.width = 'max-content'; + } + const lines = edit.newText.split(/\r\n|\r|\n/); + // Redraw the suffix when wrapping cannot relocate it. + let insertionSuffix: Node | undefined; + if (isMidLineInsertion && !isWrap) { + const sourceLine = this.#getLineElement(start.line); + if (sourceLine === undefined) { + insertionSuffix = document.createTextNode( + textDocument.getLineText(start.line).slice(start.character) + ); + } else { + const [suffixNode, suffixOffset] = getSelectionAnchor( + sourceLine, + start.character + ); + const suffixRange = document.createRange(); + suffixRange.selectNodeContents(sourceLine); + suffixRange.setStart( + suffixNode, + clampDomOffset(suffixNode, suffixOffset) + ); + insertionSuffix = suffixRange.cloneContents(); + if (insertionSuffix.firstChild?.textContent === '') { + insertionSuffix.firstChild.remove(); + } + } + } + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const lineText = lines[lineIndex]; + const suffix = + lineIndex === lines.length - 1 ? insertionSuffix : undefined; + const isEmpty = lineText.length === 0 && suffix === undefined; + const line = h( + 'span', + { + dataset: isEmpty + ? ['editPredictionLine', 'empty'] + : 'editPredictionLine', + textContent: isEmpty ? '\u200b' : lineText, + }, + element + ); + if (suffix !== undefined) { + const suffixElement = h( + 'span', + { + dataset: 'editPredictionSuffix', + }, + line + ); + suffixElement.append(suffix); + } + if (lineIndex === 0 && anchorLeft !== lineLeft) { + line.style.paddingInlineStart = `${anchorLeft - lineLeft}px`; + } + } + element.style.transform = `translateX(${lineLeft}px) translateY(${anchorTop}px)`; + renderCtx.elements.set(key, element); + prediction.rendered = true; + } + } + + #updateSelections( + selections: EditorSelection[], + updateEditPrediction = true + ) { this.__postponeBgTokenizeToNextFrame(); + const previousSelections = this.#selections; + let selectionsChanged = previousSelections?.length !== selections.length; + if (!selectionsChanged && previousSelections !== undefined) { + for (let i = 0; i < selections.length; i++) { + const previous = previousSelections[i]; + const next = selections[i]; + if ( + previous.direction !== next.direction || + comparePosition(previous.start, next.start) !== 0 || + comparePosition(previous.end, next.end) !== 0 + ) { + selectionsChanged = true; + break; + } + } + } + if (selectionsChanged && updateEditPrediction) { + this.#cancelEditPrediction(true); + } + this.#syncEditPredictionSpacers(); + this.#primaryCaretElement = undefined; this.#setEditorActiveLineSafe(null); @@ -4106,6 +4910,9 @@ export class Editor implements DiffsEditor { this.#overlayElements?.clear(); this.#selectionAction?.cleanup(); this.#selectionAction = undefined; + if (selectionsChanged && updateEditPrediction) { + this.#scheduleEditPrediction(); + } return; } @@ -4242,12 +5049,42 @@ export class Editor implements DiffsEditor { } } + this.#renderEditPrediction(renderCtx); + this.#overlayElement?.appendChild(fragment); this.#overlayElements?.forEach((el) => el.remove()); this.#overlayElements?.clear(); this.#overlayElements = renderCtx.elements; + let predictionBottom: number | undefined; + for (const element of renderCtx.elements.values()) { + if (element.dataset.editPrediction !== undefined) { + const bottom = element.getBoundingClientRect().bottom; + predictionBottom = + predictionBottom === undefined + ? bottom + : Math.max(predictionBottom, bottom); + } + } + const contentElement = + predictionBottom === undefined ? undefined : this.#contentElement; + const codeElement = contentElement?.parentElement; + if ( + predictionBottom !== undefined && + contentElement !== undefined && + codeElement != null + ) { + const codeRect = codeElement.getBoundingClientRect(); + if (codeRect.height > 0 && predictionBottom > codeRect.bottom) { + contentElement.style.paddingBlockEnd = `${Math.ceil( + predictionBottom - codeRect.bottom + )}px`; + } + } this.#updateSelectionActionPopover(); + if (selectionsChanged && updateEditPrediction) { + this.#scheduleEditPrediction(); + } } #renderSelection( @@ -4255,7 +5092,7 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, range: Range, extraDataset?: string ) { @@ -4352,7 +5189,7 @@ export class Editor implements DiffsEditor { startChar: number, endChar: number, isLastLine: boolean, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, extraDataset?: string ) { const wrapOffsets = this.#wrapLineTextOrWholeLine(line); @@ -4448,7 +5285,7 @@ export class Editor implements DiffsEditor { width: number; }; }, - type: 'selection' | 'match' | 'marker' | 'bracketMatch', + type: OverlayRangeType, line: number, wrapLine: number, left: number, @@ -4606,6 +5443,20 @@ export class Editor implements DiffsEditor { rangeEl.style.width = `${width}px`; rangeEl.style.transform = `translateX(${left}px) translateY(${y}px)`; + if ( + type === 'editPredictionInsertion' || + type === 'editPredictionReplacement' + ) { + const lineElement = this.#getLineElement(line); + if (lineElement !== undefined) { + rangeEl.style.setProperty( + '--diffs-edit-prediction-bg', + getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg') + ); + } + } else { + rangeEl.style.removeProperty('--diffs-edit-prediction-bg'); + } if (rounded) { addRadiusStyle(rangeEl); } @@ -5226,8 +6077,18 @@ export class Editor implements DiffsEditor { change: TextDocumentChange, newSelections?: EditorSelection[], newLineAnnotations?: DiffLineAnnotation[], - options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } + options?: { + skipSearchRefresh?: boolean; + skipFocus?: boolean; + editSource?: 'user' | 'prediction'; + } ) { + const editPredictionWasEnabled = this.#options.editPrediction !== undefined; + if (editPredictionWasEnabled) { + this.#cancelEditPrediction(true); + this.#recordEditPredictionHistory(change, options?.editSource ?? 'user'); + } + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { @@ -5374,7 +6235,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 @@ -5398,6 +6259,9 @@ export class Editor implements DiffsEditor { this.focus({ preventScroll: true }); } } + if (this.#options.editPrediction !== undefined) { + this.#scheduleEditPrediction(editPredictionWasEnabled); + } } #applyChangeToLineAnnotations( @@ -5826,3 +6690,40 @@ export class Editor implements DiffsEditor { return this.#getLineElement(line) !== undefined; } } + +function assertCacheKey(file: Partial): string { + if (typeof file.cacheKey !== 'string' || file.cacheKey.length === 0) { + throw new Error( + `Editor persistState requires a non-empty file.cacheKey for "${file.name}". Provide a unique, stable cacheKey for every editable file.` + ); + } + return file.cacheKey; +} + +function isValidEditPredictionPosition( + document: TextDocument, + position: Position | undefined +): position is Position { + return ( + position !== undefined && + Number.isInteger(position.line) && + Number.isInteger(position.character) && + position.line >= 0 && + position.line < document.lineCount && + position.character >= 0 && + position.character <= document.getLineLength(position.line) + ); +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + const previous = text.charCodeAt(offset - 1); + const next = text.charCodeAt(offset); + return ( + offset > 0 && + offset < text.length && + previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ); +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 0738a6253..30a3ed44b 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -16,6 +16,7 @@ import { } from './editStack'; import { PieceTable } from './pieceTable'; import type { SearchParams } from './searchPanel'; +import { setTextDocumentChangeTransaction } from './textDocumentChangeTransaction'; export type { Position, Range, TextEdit } from '../types'; @@ -69,7 +70,7 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack: EditStack; - #eol: string; + #eol: '\n' | '\r\n' | '\r'; constructor( uri: string, @@ -114,7 +115,7 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get eol(): string { + get eol(): '\n' | '\r\n' | '\r' { return this.#eol; } @@ -285,6 +286,10 @@ export class TextDocument { } else { this.#editStack.push(entry); } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.forwardEdits, + inverseEdits: entry.inverseEdits, + }); return change; } @@ -311,6 +316,10 @@ export class TextDocument { if (change === undefined) { return undefined; } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.inverseEdits, + inverseEdits: entry.forwardEdits, + }); this.#version = entry.versionBefore; const selections = entry.selectionsBefore?.slice(); return [ @@ -332,6 +341,10 @@ export class TextDocument { if (change === undefined) { return undefined; } + setTextDocumentChangeTransaction(change, { + appliedEdits: entry.forwardEdits, + inverseEdits: entry.inverseEdits, + }); this.#version = entry.versionAfter; const selections = entry.selectionsAfter?.slice(); return [ diff --git a/packages/diffs/src/editor/textDocumentChangeTransaction.ts b/packages/diffs/src/editor/textDocumentChangeTransaction.ts new file mode 100644 index 000000000..116ff6705 --- /dev/null +++ b/packages/diffs/src/editor/textDocumentChangeTransaction.ts @@ -0,0 +1,28 @@ +import type { ResolvedTextEdit } from '../types'; +import type { TextDocumentChange } from './textDocument'; + +// Keeps prediction-only edit metadata off the public TextDocumentChange shape. +export interface TextDocumentChangeTransaction { + /** Edits applied to the document state before this change. */ + readonly appliedEdits: readonly ResolvedTextEdit[]; + /** Edits that restore the document state before this change. */ + readonly inverseEdits: readonly ResolvedTextEdit[]; +} + +const transactions = new WeakMap< + TextDocumentChange, + TextDocumentChangeTransaction +>(); + +export function getTextDocumentChangeTransaction( + change: TextDocumentChange +): TextDocumentChangeTransaction | undefined { + return transactions.get(change); +} + +export function setTextDocumentChangeTransaction( + change: TextDocumentChange, + transaction: TextDocumentChangeTransaction +): void { + transactions.set(change, transaction); +} diff --git a/packages/diffs/src/editor/utils.ts b/packages/diffs/src/editor/utils.ts index 9c188a446..3e29af3c3 100644 --- a/packages/diffs/src/editor/utils.ts +++ b/packages/diffs/src/editor/utils.ts @@ -37,6 +37,15 @@ export function h( return el; } +export function isPromise(value: T | Promise): value is Promise { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ); +} + export function addEventListener( el: HTMLElement, event: K, diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index e796d1b77..94123bde1 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -307,7 +307,8 @@ } } - [data-line] span { + [data-line] span, + [data-edit-prediction-suffix] span { color: light-dark( var(--diffs-token-light, var(--diffs-light)), var(--diffs-token-dark, var(--diffs-dark)) diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 9b479f52d..38632a7d0 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -36,6 +36,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 @@ -138,6 +147,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(); @@ -257,9 +295,11 @@ describe('Editor persisted state lifecycle', () => { throw new Error('unexpected persisted.ts write'); } writes.push(state); - return gate.promise.then(() => { - states.set(cacheKey, state); - }); + return foreignPromise( + gate.promise.then(() => { + states.set(cacheKey, state); + }) + ); }, }; const editor = new Editor({ diff --git a/packages/diffs/test/editorPrediction.test.ts b/packages/diffs/test/editorPrediction.test.ts new file mode 100644 index 000000000..d801fbfd7 --- /dev/null +++ b/packages/diffs/test/editorPrediction.test.ts @@ -0,0 +1,1425 @@ +import { afterAll, describe, expect, jest, spyOn, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; +import { DEFAULT_THEMES } from '../src/constants'; +import { + Editor, + type EditorOptions, + type EditPredictContext, + type EditPredictProvider, + type EditPredictRequest, + type EditPredictResponse, +} from '../src/editor/editor'; +import { TextDocument } from '../src/editor/textDocument'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import { installDom, wait, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +const FILE_NAME = 'src/edit.ts'; +const EDIT_PREDICTION_DEBOUNCE_MS = 300; +const PREDICT_TIMEOUT = 2_000; + +type Surface = 'File' | 'FileDiff'; + +interface PredictionCall { + context: EditPredictContext; + request: EditPredictRequest; +} + +interface PredictionFixture { + cleanup(): Promise; + container: HTMLElement; + content: HTMLElement; + editor: Editor; +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function findEditableContent(container: HTMLElement): HTMLElement | undefined { + return Array.from( + container.shadowRoot?.querySelectorAll('[data-content]') ?? [] + ).find( + (element) => + element.contentEditable === 'true' || + element.getAttribute('contenteditable') === 'true' + ); +} + +async function createPredictionFixture({ + contents, + editorOptions, + name = FILE_NAME, + surface = 'File', +}: { + contents: string; + editorOptions: EditorOptions; + name?: string; + surface?: Surface; +}): Promise { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const editor = new Editor(editorOptions); + let cleanUpSurface: () => void; + + if (surface === 'File') { + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + file.render({ + file: { name, contents }, + fileContainer: container, + forceRender: true, + }); + editor.edit(file); + cleanUpSurface = () => file.cleanUp(); + } else { + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'split', + theme: DEFAULT_THEMES, + }); + fileDiff.render({ + oldFile: { name, contents: contents.replaceAll('value', 'previous') }, + newFile: { name, contents }, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + cleanUpSurface = () => fileDiff.cleanUp(); + } + + await waitFor(() => findEditableContent(container) !== undefined, { + timeout: 3_000, + }); + const content = findEditableContent(container); + if (content === undefined) { + throw new Error(`${surface} did not become editable`); + } + + return { + async cleanup() { + editor.cleanUp(); + cleanUpSurface(); + await wait(0); + dom.cleanup(); + }, + container, + content, + editor, + }; +} + +function setCaret( + editor: Editor, + line: number, + character: number +): void { + const position = { line, character }; + editor.setSelections([{ start: position, end: position, direction: 'none' }]); +} + +function dispatchTextInput(content: HTMLElement, data: string): InputEvent { + const view = content.ownerDocument.defaultView; + if (view == null) { + throw new Error('editor content is not attached to a window'); + } + const event = new view.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + data, + inputType: 'insertText', + }); + content.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + return event; +} + +function dispatchKey( + content: HTMLElement, + key: string, + init: KeyboardEventInit = {}, + type: 'keydown' | 'keyup' = 'keydown' +): KeyboardEvent { + const view = content.ownerDocument.defaultView; + if (view == null) { + throw new Error('editor content is not attached to a window'); + } + const event = new view.KeyboardEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + key, + ...init, + }); + content.dispatchEvent(event); + return event; +} + +function predictionElements(container: HTMLElement): HTMLElement[] { + return Array.from( + container.shadowRoot?.querySelectorAll( + '[data-edit-prediction]' + ) ?? [] + ); +} + +function hasVisiblePrediction(container: HTMLElement): boolean { + return predictionElements(container).some((element) => { + const style = getComputedStyle(element); + return ( + element.hidden === false && + style.display !== 'none' && + style.opacity !== '0' && + style.visibility !== 'hidden' + ); + }); +} + +async function expectCallCount( + calls: PredictionCall[], + count: number +): Promise { + await waitFor(() => calls.length >= count, { + timeout: PREDICT_TIMEOUT, + }); + expect(calls).toHaveLength(count); +} + +describe('Editor edit prediction', () => { + test('debounces typed input and builds a small-document request', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc\r\ndef', + editorOptions: { editPrediction: { provider } }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 3); + dispatchTextInput(fixture.content, 'X'); + + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); + expect(calls).toHaveLength(0); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); + + expect(calls[0].request).toMatchObject({ + cursorOffsetInExcerpt: 4, + editableRange: { start: 0, end: 9 }, + eol: '\r\n', + excerptStartLine: 0, + excerptText: 'abcX\r\ndef', + path: FILE_NAME, + version: 1, + }); + expect(calls[0].context.signal.aborted).toBe(false); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + + test('uses the document EOL when the excerpt has no line break', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 1, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: `${'界'.repeat(2_000)}\r\nshort`, + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 1, 0); + dispatchKey(fixture.content, 'ArrowRight'); + await expectCallCount(calls, 1); + + expect(calls[0].request.excerptText).toBe('short'); + expect(calls[0].request.eol).toBe('\r\n'); + } finally { + await fixture.cleanup(); + } + }); + + test('bounds editable and context ranges around the cursor', async () => { + const calls: PredictionCall[] = []; + const contents = Array.from( + { length: 600 }, + (_, line) => `const value${line} = ${line};` + ).join('\n'); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 300, character: 6 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 300, 5); + dispatchKey(fixture.content, 'ArrowRight'); + await expectCallCount(calls, 1); + + const request = calls[0].request; + expect(request.excerptStartLine).toBeGreaterThan(0); + expect(request.excerptText.length).toBeLessThan(contents.length); + expect(request.editableRange.start).toBeGreaterThan(0); + expect(request.editableRange.end).toBeLessThan( + request.excerptText.length + ); + expect(request.cursorOffsetInExcerpt).toBeGreaterThan( + request.editableRange.start + ); + expect(request.cursorOffsetInExcerpt).toBeLessThan( + request.editableRange.end + ); + } finally { + await fixture.cleanup(); + } + }); + + test('does not materialize the full document for prediction requests or history', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 60, character: 7 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: Array.from( + { length: 120 }, + (_, line) => `const value${line} = ${line};` + ).join('\n'), + editorOptions: {}, + }); + const getText = spyOn(TextDocument.prototype, 'getText'); + try { + fixture.editor.setOptions({ editPrediction: { provider } }); + setCaret(fixture.editor, 60, 5); + fixture.editor.applyEdits([ + { + range: { + start: { line: 60, character: 5 }, + end: { line: 60, character: 5 }, + }, + newText: 'X', + }, + ]); + await expectCallCount(calls, 1); + + expect( + getText.mock.calls.filter(([range]) => range === undefined) + ).toHaveLength(0); + expect(calls[0].request.editHistory[0]?.diff).toContain( + '+constX value60 = 60;' + ); + expect(calls[0].request.excerptStartLine).toBeGreaterThan(0); + } finally { + getText.mockRestore(); + await fixture.cleanup(); + } + }); + + test('keeps pathological long-line requests within 128 KiB or skips them', async () => { + const calls: PredictionCall[] = []; + const contents = '界'.repeat(50_000); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 25_001 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + name: 'pathological.txt', + }); + + try { + setCaret(fixture.editor, 0, 25_000); + dispatchKey(fixture.content, 'ArrowRight'); + await wait(400); + + expect(calls.length).toBeLessThanOrEqual(1); + if (calls[0] !== undefined) { + expect( + new TextEncoder().encode(JSON.stringify(calls[0].request)).byteLength + ).toBeLessThanOrEqual(128 * 1024); + } + expect(fixture.editor.getText()).toBe(contents); + } finally { + await fixture.cleanup(); + } + }); + + test('debounces prediction after a cursor-key movement', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc', + editorOptions: { editPrediction: { provider } }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 0); + const event = dispatchKey(fixture.content, 'ArrowRight'); + expect(event.defaultPrevented).toBe(true); + + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS - 1); + expect(calls).toHaveLength(0); + jest.advanceTimersByTime(1); + expect(calls).toHaveLength(1); + expect(calls[0].request).toMatchObject({ + cursorOffsetInExcerpt: 1, + excerptText: 'abc', + version: 0, + }); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + + for (const surface of ['File', 'FileDiff'] as const) { + test(`${surface} eagerly renders and accepts a prediction atomically`, async () => { + const calls: PredictionCall[] = []; + const changes: string[] = []; + const typedText = 'const value = 1'; + const predictedText = 'const answer = 1;\nconsole.log(answer);'; + const newCursor = { line: 1, character: 7 }; + const response: EditPredictResponse = { + edits: [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + }, + newText: 'answer', + }, + { + range: { + start: { line: 0, character: typedText.length }, + end: { line: 0, character: typedText.length }, + }, + newText: ';\nconsole.log(answer);', + }, + ], + newCursor, + }; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve(response); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'const value = ', + editorOptions: { + editPrediction: { provider }, + onChange(file) { + changes.push(file.contents); + }, + }, + surface, + }); + + try { + setCaret(fixture.editor, 0, 'const value = '.length); + dispatchTextInput(fixture.content, '1'); + await expectCallCount(calls, 1); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + expect(predictionElements(fixture.container).length).toBeGreaterThan(0); + expect(fixture.editor.getText()).toBe(typedText); + expect(changes).toEqual([typedText]); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe(predictedText); + expect(fixture.editor.getState().selections).toEqual([ + { start: newCursor, end: newCursor, direction: 0 }, + ]); + expect(changes).toEqual([typedText, predictedText]); + await waitFor( + () => predictionElements(fixture.container).length === 0, + { timeout: PREDICT_TIMEOUT } + ); + expect(predictionElements(fixture.container)).toHaveLength(0); + expect( + fixture.container.shadowRoot?.querySelectorAll( + '[data-edit-prediction-spacer]' + ) + ).toHaveLength(0); + + await expectCallCount(calls, 2); + expect(calls[1].request.editHistory.at(-1)?.source).toBe('prediction'); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '-const value = 1' + ); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '+const answer = 1;' + ); + expect(calls[1].request.editHistory.at(-1)?.diff).toContain( + '+console.log(answer);' + ); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe(typedText); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} masks and preserves the suffix for a mid-line insertion`, async () => { + const contents = 'function value(items: CartItem[]): number {'; + const insertion = ', discount?: number'; + const character = 'function value(items: CartItem[]'.length; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character }, + end: { line: 0, character }, + }, + newText: insertion, + }, + ], + newCursor: { line: 0, character: character + insertion.length }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + const sourceLine = + fixture.content.querySelector('[data-line="1"]'); + await waitFor( + () => + Array.from(sourceLine?.children ?? []).some( + (token) => + Number((token as HTMLElement).dataset.char) >= character + ), + { timeout: PREDICT_TIMEOUT } + ); + const sourceSuffixTokens = Array.from(sourceLine?.children ?? []) + .map((token) => token as HTMLElement) + .flatMap((token) => { + const text = token.textContent ?? ''; + const start = Number(token.dataset.char); + return start + text.length <= character + ? [] + : [ + { + text: text.slice(Math.max(0, character - start)), + dark: token.style.getPropertyValue('--diffs-token-dark'), + light: token.style.getPropertyValue('--diffs-token-light'), + }, + ]; + }); + expect( + sourceSuffixTokens.some( + ({ dark, light }) => dark !== '' && light !== '' + ) + ).toBe(true); + + setCaret(fixture.editor, 0, character); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const prediction = predictionElements(fixture.container)[0]; + expect(prediction.dataset.replacement).toBeUndefined(); + expect( + fixture.container.shadowRoot?.querySelector( + '[data-edit-prediction-insertion-range]' + ) + ).not.toBeNull(); + expect( + prediction.querySelector('[data-edit-prediction-suffix]')?.textContent + ).toBe('): number {'); + expect( + Array.from( + prediction.querySelector('[data-edit-prediction-suffix]') + ?.children ?? [] + ).map((token) => ({ + text: token.textContent, + dark: (token as HTMLElement).style.getPropertyValue( + '--diffs-token-dark' + ), + light: (token as HTMLElement).style.getPropertyValue( + '--diffs-token-light' + ), + })) + ).toEqual(sourceSuffixTokens); + expect( + prediction.querySelector('[data-edit-prediction-line]')?.textContent + ).toBe(', discount?: number): number {'); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe( + 'function value(items: CartItem[], discount?: number): number {' + ); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} composes multiple same-line insertions in the preview`, async () => { + const contents = 'alpha value gamma'; + const firstCharacter = 'alpha '.length; + const secondCharacter = 'alpha value '.length; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: firstCharacter }, + end: { line: 0, character: firstCharacter }, + }, + newText: 'one ', + }, + { + range: { + start: { line: 0, character: secondCharacter }, + end: { line: 0, character: secondCharacter }, + }, + newText: 'two ', + }, + ], + newCursor: { line: 0, character: 25 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + setCaret(fixture.editor, 0, firstCharacter); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const predictions = predictionElements(fixture.container); + expect(predictions).toHaveLength(1); + expect( + predictions[0].querySelector('[data-edit-prediction-line]') + ?.textContent + ).toBe('one value two gamma'); + expect(fixture.editor.getText()).toBe(contents); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('alpha one value two gamma'); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} previews edits sharing a cross-line boundary`, async () => { + const contents = 'abc\ndef value'; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 1, character: 0 }, + }, + newText: 'C', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: 'E', + }, + ], + newCursor: { line: 0, character: 5 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + + try { + setCaret(fixture.editor, 0, 2); + await waitFor( + () => predictionElements(fixture.container).length === 1, + { + timeout: PREDICT_TIMEOUT, + } + ); + + expect( + predictionElements(fixture.container).map( + (prediction) => prediction.textContent + ) + ).toEqual(['CdEf value']); + expect(fixture.editor.getText()).toBe(contents); + + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('abCdEf value'); + } finally { + await fixture.cleanup(); + } + }); + + test(`${surface} reserves numberless rows for multiline ghost text`, async () => { + const contents = 'const value = 1;\nnext();\nend();'; + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 1, character: 7 }, + end: { line: 1, character: 7 }, + }, + newText: '\nghostOne();\nghostTwo();', + }, + ], + newCursor: { line: 3, character: 11 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents, + editorOptions: { editPrediction: { provider } }, + surface, + }); + const gutter = fixture.content.parentElement?.querySelector( + ':scope > [data-gutter]' + ); + const lineNumbers = () => + Array.from( + fixture.content.querySelectorAll(':scope > [data-line]') + ).map((element) => element.dataset.line); + const gutterNumbers = () => + Array.from( + gutter?.querySelectorAll( + ':scope > [data-column-number]' + ) ?? [] + ).map((element) => element.dataset.columnNumber); + const initialLineNumbers = lineNumbers(); + const initialGutterNumbers = gutterNumbers(); + + try { + setCaret(fixture.editor, 1, 7); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const prediction = predictionElements(fixture.container)[0]; + const ghostLines = Array.from( + prediction.querySelectorAll( + '[data-edit-prediction-line]' + ) + ); + expect(ghostLines).toHaveLength(3); + expect( + ghostLines.every( + (line) => line.closest('[data-line], [data-column-number]') === null + ) + ).toBe(true); + expect(lineNumbers()).toEqual(initialLineNumbers); + expect(gutterNumbers()).toEqual(initialGutterNumbers); + + const anchorLine = + fixture.content.querySelector('[data-line="2"]'); + const anchorGutter = gutter?.querySelector( + '[data-column-number="2"]' + ); + for (const element of [anchorLine, anchorGutter]) { + expect(element?.dataset.editPredictionSpacer).toBe(''); + expect( + element?.style.getPropertyValue( + '--diffs-edit-prediction-spacer-height' + ) + ).toBe('2lh'); + } + + dispatchKey(fixture.content, 'ArrowLeft'); + expect( + fixture.container.shadowRoot?.querySelectorAll( + '[data-edit-prediction-spacer]' + ) + ).toHaveLength(0); + expect(lineNumbers()).toEqual(initialLineNumbers); + expect(gutterNumbers()).toEqual(initialGutterNumbers); + } finally { + await fixture.cleanup(); + } + }); + } + + test('reserves and clears space for a multiline FileDiff prediction at EOF', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '\nnext', + }, + ], + newCursor: { line: 1, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'value', + editorOptions: { editPrediction: { provider } }, + surface: 'FileDiff', + }); + const elementPrototype = + fixture.content.ownerDocument.defaultView!.HTMLElement.prototype; + const getBoundingClientRect = elementPrototype.getBoundingClientRect; + elementPrototype.getBoundingClientRect = function () { + if (this.dataset.code !== undefined) { + return { + bottom: 20, + height: 20, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON() {}, + }; + } + if (this.dataset.editPrediction !== undefined) { + return { + bottom: 40, + height: 40, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON() {}, + }; + } + return getBoundingClientRect.call(this); + }; + + try { + setCaret(fixture.editor, 0, 5); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + expect(fixture.content.style.paddingBlockEnd).toBe('20px'); + + dispatchKey(fixture.content, 'ArrowLeft'); + expect(fixture.content.style.paddingBlockEnd).toBe(''); + } finally { + elementPrototype.getBoundingClientRect = getBoundingClientRect; + await fixture.cleanup(); + } + }); + + test('typing aborts an in-flight prediction and ignores its response', async () => { + const calls: PredictionCall[] = []; + const pending = createDeferred(); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return pending.promise; + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + expect(calls[0].context.signal.aborted).toBe(false); + + dispatchTextInput(fixture.content, 'c'); + expect(calls[0].context.signal.aborted).toBe(true); + pending.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: ' stale', + }, + ], + newCursor: { line: 0, character: 8 }, + }); + await wait(0); + await wait(0); + + expect(fixture.editor.getText()).toBe('abc'); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('cursor movement aborts an in-flight prediction', async () => { + const calls: PredictionCall[] = []; + const pending = createDeferred(); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return pending.promise; + }, + }; + const fixture = await createPredictionFixture({ + contents: 'abc', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + dispatchTextInput(fixture.content, 'd'); + await expectCallCount(calls, 1); + + dispatchKey(fixture.content, 'ArrowLeft'); + expect(calls[0].context.signal.aborted).toBe(true); + pending.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 4 }, + }, + newText: ' stale', + }, + ], + newCursor: { line: 0, character: 10 }, + }); + await wait(0); + + expect(fixture.editor.getText()).toBe('abcd'); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('rejects a prediction range that splits a surrogate pair', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 3 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: '😀x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + await expectCallCount(calls, 1); + await wait(0); + expect(predictionElements(fixture.container)).toHaveLength(0); + } finally { + await fixture.cleanup(); + } + }); + + test('Escape discards a prediction without changing the document', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + + const escape = dispatchKey(fixture.content, 'Escape'); + expect(escape.defaultPrevented).toBe(true); + expect(predictionElements(fixture.container)).toHaveLength(0); + expect(fixture.editor.getText()).toBe('a'); + } finally { + await fixture.cleanup(); + } + }); + + test('an empty response leaves Tab available to the editor', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + await expectCallCount(calls, 1); + await wait(0); + expect(predictionElements(fixture.container)).toHaveLength(0); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).not.toBe('a'); + } finally { + await fixture.cleanup(); + } + }); + + test('Shift+Tab runs outdent instead of accepting a prediction', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 4 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: ' a', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 3); + await expectCallCount(calls, 1); + await waitFor(() => predictionElements(fixture.container).length > 0, { + timeout: PREDICT_TIMEOUT, + }); + + const tab = dispatchKey(fixture.content, 'Tab', { shiftKey: true }); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('a'); + expect(fixture.editor.getText()).not.toContain('!'); + } finally { + await fixture.cleanup(); + } + }); + + test('coalesces nearby user edits and caps history at ten entries', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + const character = request.cursorOffsetInExcerpt; + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character }, + end: { line: 0, character }, + }, + newText: 'p', + }, + ], + newCursor: { line: 0, character: character + 1 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'a'); + await expectCallCount(calls, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 2); + + expect(calls[1].request.editHistory).toHaveLength(1); + expect(calls[1].request.editHistory[0]?.source).toBe('user'); + expect(calls[1].request.editHistory[0]?.diff).toContain('+xab'); + + for (let count = 3; count <= 12; count++) { + if (count % 2 === 1) { + await waitFor( + () => predictionElements(fixture.container).length > 0, + { timeout: PREDICT_TIMEOUT } + ); + expect(dispatchKey(fixture.content, 'Tab').defaultPrevented).toBe( + true + ); + } else { + dispatchTextInput(fixture.content, 'u'); + } + await expectCallCount(calls, count); + } + + expect(calls[11].request.editHistory.map(({ source }) => source)).toEqual( + [ + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + 'prediction', + 'user', + ] + ); + } finally { + await fixture.cleanup(); + } + }); + + test('removes an immediately undone user edit and records its redo', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'x', + editorOptions: { editPrediction: { provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'a'); + await expectCallCount(calls, 1); + expect(calls[0].request.editHistory[0]?.diff).toContain('+xa'); + + fixture.editor.undo(); + await expectCallCount(calls, 2); + expect(calls[1].request.editHistory).toHaveLength(0); + + fixture.editor.redo(); + await expectCallCount(calls, 3); + expect(calls[2].request.editHistory).toHaveLength(1); + expect(calls[2].request.editHistory[0]?.diff).toContain('+xa'); + } finally { + await fixture.cleanup(); + } + }); + + test('subtle predictions are visible only while Alt is held', async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 3 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { mode: 'subtle', provider }, + }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + await wait(0); + + expect(hasVisiblePrediction(fixture.container)).toBe(false); + expect(fixture.editor.getText()).toBe('ab'); + + dispatchKey(fixture.content, 'Alt', { altKey: true }); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + expect(hasVisiblePrediction(fixture.container)).toBe(true); + expect(fixture.editor.getText()).toBe('ab'); + + dispatchKey(fixture.content, 'Alt', {}, 'keyup'); + await waitFor(() => !hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + expect(hasVisiblePrediction(fixture.container)).toBe(false); + + const tab = dispatchKey(fixture.content, 'Tab'); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('ab '); + expect(fixture.editor.getText()).not.toContain('!'); + } finally { + await fixture.cleanup(); + } + }); + + test('accepts a subtle prediction with Alt+Tab', async () => { + const provider: EditPredictProvider = { + predict() { + return Promise.resolve({ + edits: [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { mode: 'subtle', provider }, + }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchKey(fixture.content, 'Alt', { altKey: true }); + await waitFor(() => hasVisiblePrediction(fixture.container), { + timeout: PREDICT_TIMEOUT, + }); + + const tab = dispatchKey(fixture.content, 'Tab', { altKey: true }); + expect(tab.defaultPrevented).toBe(true); + expect(fixture.editor.getText()).toBe('a!'); + } finally { + await fixture.cleanup(); + } + }); + + const filterCases: Array<{ + allowed: boolean; + name: string; + options: Omit< + NonNullable['editPrediction']>, + 'provider' + >; + }> = [ + { + allowed: true, + name: 'exact-string include allows the path', + options: { include: [FILE_NAME] }, + }, + { + allowed: true, + name: 'regular-expression include allows the path', + options: { include: [/\.ts$/] }, + }, + { + allowed: true, + name: 'glob include allows the path', + options: { include: ['**/*.ts'] }, + }, + { + allowed: false, + name: 'an unmatched include blocks the path', + options: { include: ['src/other.ts'] }, + }, + { + allowed: false, + name: 'exact-string exclude overrides an include', + options: { include: [FILE_NAME], exclude: [FILE_NAME] }, + }, + { + allowed: false, + name: 'regular-expression exclude overrides an include', + options: { include: [FILE_NAME], exclude: [/edit\.ts$/] }, + }, + ]; + + for (const { allowed, name, options } of filterCases) { + test(name, async () => { + const calls: PredictionCall[] = []; + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: 2 }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { + editPrediction: { ...options, provider }, + }, + }); + + try { + jest.useFakeTimers(); + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + jest.advanceTimersByTime(EDIT_PREDICTION_DEBOUNCE_MS); + expect(calls).toHaveLength(allowed ? 1 : 0); + } finally { + jest.useRealTimers(); + await fixture.cleanup(); + } + }); + } + + test('reuses a frozen global regular-expression include', async () => { + const calls: PredictionCall[] = []; + const include = Object.freeze(/\.ts$/g); + const provider: EditPredictProvider = { + predict(request, context) { + calls.push({ context, request }); + return Promise.resolve({ + edits: [], + newCursor: { line: 0, character: request.cursorOffsetInExcerpt }, + }); + }, + }; + const fixture = await createPredictionFixture({ + contents: 'a', + editorOptions: { editPrediction: { include: [include], provider } }, + }); + + try { + setCaret(fixture.editor, 0, 1); + dispatchTextInput(fixture.content, 'b'); + await expectCallCount(calls, 1); + dispatchTextInput(fixture.content, 'c'); + await expectCallCount(calls, 2); + expect(include.lastIndex).toBe(0); + } finally { + await fixture.cleanup(); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10f02ccc..15158223a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ catalogs: '@typescript/native-preview': specifier: 7.0.0-dev.20260622.1 version: 7.0.0-dev.20260622.1 + '@vercel/firewall': + specifier: 1.2.1 + version: 1.2.1 '@vitejs/plugin-react': specifier: 5.0.3 version: 5.0.3 @@ -483,6 +486,9 @@ importers: '@shikijs/transformers': specifier: 4.4.1 version: 4.4.1 + '@vercel/firewall': + specifier: 'catalog:' + version: 1.2.1 '@vscode/web-custom-data': specifier: 'catalog:' version: 0.6.3 @@ -2905,6 +2911,10 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vercel/firewall@1.2.1': + resolution: {integrity: sha512-WlxjEPpf+GWYMontNNZqZjvLL40ziGwy9swwI9da/L1fB/syAO1S2fMtlBXkp4EGVF6nlXSKmwvkUd6YPWJbbg==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.0.3': resolution: {integrity: sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8196,6 +8206,8 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@vercel/firewall@1.2.1': {} + '@vitejs/plugin-react@5.0.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4356474b3..686083eb7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -91,6 +91,7 @@ catalog: '@types/node': '20.19.41' '@types/react': '19.2.7' '@types/react-dom': '19.2.3' + '@vercel/firewall': '1.2.1' '@vscode/vsce': '3.2.2' '@typescript/native-preview': '7.0.0-dev.20260622.1' '@vscode/web-custom-data': '0.6.3'