diff --git a/apps/demo/editor.html b/apps/demo/editor.html
new file mode 100644
index 000000000..9bcc39df2
--- /dev/null
+++ b/apps/demo/editor.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ PierreJS R&D
+
+
+
+
+
+
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 163412f4e..90c03ebf5 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -8,8 +8,9 @@
"build:deps": "bun run build:deps:diffs",
"build:deps:diffs": "output=$(cd ../../packages/diffs && bun run build 2>&1) && echo '[diffs] Successfully cleaned and built.' || (echo \"$output\" >&2 && exit 1)",
"build-types": "bun run build:deps && tsgo --build",
- "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:vite\" --names \"diffs,vite\" --prefix-colors \"blue,green\"",
+ "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:deps:trees\" \"bun run dev:vite\" --names \"diffs,trees,vite\" --prefix-colors \"blue,green,gray\"",
"dev:deps:diffs": "(cd ../../packages/diffs && bun run dev)",
+ "dev:deps:trees": "(cd ../../packages/trees && bun run dev)",
"dev:vite": "vite --host --clearScreen=false",
"preview": "vite preview",
"start": "vite preview",
@@ -18,6 +19,7 @@
},
"dependencies": {
"@pierre/diffs": "workspace:*",
+ "@pierre/trees": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:",
"shiki": "catalog:"
diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts
new file mode 100644
index 000000000..b17ce45e0
--- /dev/null
+++ b/apps/demo/src/editor.ts
@@ -0,0 +1,120 @@
+import {
+ DEFAULT_THEMES,
+ Editor,
+ type FileContents,
+ VirtualizedFile,
+ Virtualizer,
+} from '@pierre/diffs';
+import { FileTree, type GitStatusEntry } from '@pierre/trees';
+
+import { createWorkerAPI } from './utils/createWorkerAPI';
+import './style.css';
+
+const API = {
+ // get git status
+ getGitStatus: () => {
+ return fetch(`/git-status/packages/diffs`).then(
+ (res) => res.json() as unknown as GitStatusEntry[]
+ );
+ },
+
+ // get paths
+ getPaths: () => {
+ return fetch('/fs/packages/diffs').then(
+ (res) => res.json() as unknown as string[]
+ );
+ },
+
+ // read file from disk
+ readFile: (path: string) => {
+ return fetch(`/fs/packages/diffs/${path}`).then((res) => res.text());
+ },
+
+ // write file to disk
+ writeFile: (path: string, contents: string) => {
+ return fetch(`/fs/packages/diffs/${path}`, {
+ method: 'POST',
+ body: contents,
+ });
+ },
+};
+
+const recentFile = localStorage.getItem('diffs-editor:recentFile');
+const fileTreeContainer = document.getElementById('file-tree-container')!;
+const editorContainer = document.getElementById('editor-container')!;
+const editor = new Editor();
+const virtualizer = new Virtualizer();
+const poolManager = (() => {
+ const manager = createWorkerAPI({
+ theme: DEFAULT_THEMES,
+ langs: ['typescript', 'tsx'],
+ preferredHighlighter: 'shiki-wasm',
+ useTokenTransformer: true,
+ });
+ void manager.initialize().then(() => {
+ console.log('WorkerPoolManager initialized, with:', manager.getStats());
+ });
+
+ // @ts-expect-error bcuz
+ window.__POOL = manager;
+ return manager;
+})();
+const fileInstance = new VirtualizedFile(
+ {
+ unsafeCSS: /* CSS */ `
+ [data-diffs-header] {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+ `,
+ },
+ virtualizer,
+ undefined,
+ poolManager
+);
+const [paths, gitStatus] = await Promise.all([
+ API.getPaths(),
+ API.getGitStatus(),
+]);
+const fileTree = new FileTree({
+ paths,
+ gitStatus,
+ search: true,
+ onSelectionChange: (selectedPaths) => {
+ if (selectedPaths.length === 1) {
+ const filename = selectedPaths[0];
+ if (!filename.endsWith('/')) {
+ void openDocument(filename);
+ }
+ }
+ },
+});
+
+async function openDocument(filename: string) {
+ const file: FileContents = {
+ name: filename,
+ contents: await API.readFile(filename),
+ };
+ fileInstance.render({
+ file,
+ containerWrapper: editorContainer,
+ });
+ editorContainer.scrollTo({ left: 0, top: 0 });
+ localStorage.setItem('diffs-editor:recentFile', filename);
+}
+
+function onFileChange(file: FileContents) {
+ console.log('writeFile', file.name);
+ // await API.writeFile(file.name, file.contents);
+ // fileTree.setGitStatus(await API.getGitStatus());
+}
+
+virtualizer.setup(editorContainer, editorContainer);
+fileTree.render({ fileTreeContainer });
+editor.edit(fileInstance, (file) => void onFileChange(file));
+
+if (recentFile !== null && paths.includes(recentFile)) {
+ fileTree.focusPath(recentFile);
+ void openDocument(recentFile);
+}
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 853a8417c..f799e6899 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -2,6 +2,7 @@ import {
DEFAULT_THEMES,
DIFFS_TAG_NAME,
type DiffsThemeNames,
+ Editor,
File,
type FileContents,
FileDiff,
@@ -206,7 +207,8 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
overflow: wrap ? 'wrap' : 'scroll',
renderAnnotation: renderDiffAnnotation,
renderHeaderMetadata() {
- return createCollapsedToggle(
+ return createToggle(
+ 'Collapse',
instance?.options.collapsed ?? false,
(checked) => {
instance?.setOptions({
@@ -673,18 +675,21 @@ if (renderFileButton != null) {
virtualizer?.setup(globalThis.document);
const wrap = getWrapped();
+ const editor = new Editor();
const fileContainer = document.createElement(DIFFS_TAG_NAME);
wrapper.appendChild(fileContainer);
let instance:
| File
| VirtualizedFile;
+ let isEditing = false;
const options: FileOptions = {
overflow: wrap ? 'wrap' : 'scroll',
theme: DEMO_THEME,
themeType: getThemeType(),
renderAnnotation,
renderCustomMetadata() {
- return createCollapsedToggle(
+ const collapsedToggle = createToggle(
+ 'Collapse',
instance?.options.collapsed ?? false,
(checked) => {
instance?.setOptions({
@@ -696,6 +701,39 @@ if (renderFileButton != null) {
}
}
);
+ const editableToggle = createToggle(
+ 'Editable',
+ isEditing,
+ (checked) => {
+ if (checked) {
+ isEditing = true;
+ editor.edit(instance, (file, lineAnnotations) => {
+ console.log('change', file, lineAnnotations);
+ });
+ editor.setSelections([
+ {
+ start: {
+ line: 0,
+ character: 1000, // will be normalized to the end of the line(< 1000 chars)
+ },
+ end: {
+ line: 0,
+ character: 1000, // will be normalized to the end of the line(< 1000 chars)
+ },
+ direction: 'none',
+ },
+ ]);
+ } else {
+ isEditing = false;
+ editor.cleanUp();
+ }
+ }
+ );
+ const div = document.createElement('div');
+ div.style.display = 'flex';
+ div.style.gap = '8px';
+ div.append(collapsedToggle, editableToggle);
+ return div;
},
// Line selection stuff
@@ -883,7 +921,8 @@ cleanButton?.addEventListener('click', () => {
cleanupInstances(container);
});
-function createCollapsedToggle(
+function createToggle(
+ labelText: string,
checked: boolean,
onChange: (checked: boolean) => void
): HTMLElement {
@@ -896,7 +935,7 @@ function createCollapsedToggle(
});
label.dataset.collapser = '';
label.appendChild(input);
- label.append(' Collapse');
+ label.appendChild(document.createTextNode(` ${labelText}`));
return label;
}
diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css
index fdf80dc2b..a26126987 100644
--- a/apps/demo/src/style.css
+++ b/apps/demo/src/style.css
@@ -240,3 +240,48 @@ diffs-container {
align-items: center;
gap: 4px;
}
+
+[data-icon-sprite] {
+ display: none;
+}
+
+#editor {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ grid-template-rows: 1fr;
+ gap: 10px;
+ background-color: light-dark(white, black);
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
+
+#editor[data-show-sidebar] {
+ grid-template-columns: 280px 1fr 280px;
+}
+
+#file-tree {
+ background-color: light-dark(#f8f8f8, #141415);
+}
+
+#file-tree h1 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ padding: 4px 16px 12px;
+}
+
+#file-tree h1 svg {
+ width: 20px;
+ height: 20px;
+}
+
+#editor-container {
+ overflow-y: auto;
+ overscroll-behavior: none;
+}
+
+#editor-container diffs-container {
+ margin-top: 0;
+}
diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts
index c4526a4ec..2b7143805 100644
--- a/apps/demo/vite.config.ts
+++ b/apps/demo/vite.config.ts
@@ -1,10 +1,16 @@
+import type { GitStatus, GitStatusEntry } from '@pierre/trees';
import react from '@vitejs/plugin-react';
import fs from 'fs';
import type { IncomingMessage, ServerResponse } from 'http';
+import { execFileSync } from 'node:child_process';
import path, { resolve } from 'path';
+import { Readable } from 'stream';
+import { ReadableStream } from 'stream/web';
import type { Plugin, PreviewServer, ViteDevServer } from 'vite';
import { createLogger, defineConfig, type Logger } from 'vite';
+const projectDir = resolve(__dirname, '../../');
+
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -48,6 +54,112 @@ function makeFilteredLogger(folder: string): Logger {
};
}
+function readProjectDirSync(dir: string, basePath: string = dir): string[] {
+ const fullPath = path.join(projectDir, dir);
+ const entries = fs.readdirSync(fullPath, { withFileTypes: true });
+ return entries
+ .map((entry) => {
+ if (
+ entry.name.startsWith('.') ||
+ entry.name === 'dist' ||
+ entry.name === 'node_modules'
+ ) {
+ return [];
+ }
+ if (entry.isDirectory()) {
+ return readProjectDirSync(path.join(dir, entry.name), basePath);
+ }
+ const relPath = path.join(dir, entry.name);
+ return path.relative(basePath, relPath);
+ })
+ .flat(Infinity) as string[];
+}
+
+function unquoteGitPath(segment: string): string {
+ if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
+ return segment.slice(1, -1).replace(/\\(.)/g, '$1');
+ }
+ return segment;
+}
+
+function getGitStatus(repoRoot: string, pathspec: string): GitStatusEntry[] {
+ const args = ['-C', repoRoot, 'status', '--porcelain=v1', '-uall'];
+ if (pathspec.length > 0) {
+ args.push('--', pathspec);
+ }
+ const out = execFileSync('git', args, {
+ encoding: 'utf8',
+ maxBuffer: 50 * 1024 * 1024,
+ });
+ const entries: GitStatusEntry[] = [];
+ for (const rawLine of out.split('\n')) {
+ const line = rawLine.replace(/\r$/, '');
+ if (line.length === 0) {
+ continue;
+ }
+ if (line.startsWith('??')) {
+ const p = unquoteGitPath(line.slice(3).trimStart());
+ if (p.length > 0) {
+ entries.push({ path: p, status: 'untracked' });
+ }
+ continue;
+ }
+ if (line.length < 4 || line[2] !== ' ') {
+ continue;
+ }
+ const x = line[0];
+ const y = line[1];
+ let rest = line.slice(3);
+ const renameSep = ' -> ';
+ const renameIdx = rest.includes(renameSep)
+ ? rest.lastIndexOf(renameSep)
+ : -1;
+ if (renameIdx >= 0 && (x === 'R' || y === 'R' || x === 'C' || y === 'C')) {
+ const newPath = unquoteGitPath(
+ rest.slice(renameIdx + renameSep.length).trim()
+ );
+ if (newPath.length > 0) {
+ entries.push({ path: newPath, status: 'renamed' });
+ }
+ continue;
+ }
+ rest = rest.trimEnd();
+ let filePath = unquoteGitPath(rest);
+ if (filePath.length === 0) {
+ continue;
+ }
+ if (filePath.startsWith(pathspec + '/')) {
+ filePath = filePath.slice(pathspec.length + 1);
+ }
+ const letter =
+ y !== ' ' && y !== '.' ? y : x !== ' ' && x !== '.' ? x : null;
+ let status: GitStatus | null = null;
+ switch (letter) {
+ case 'M':
+ status = 'modified';
+ break;
+ case 'A':
+ status = 'added';
+ break;
+ case 'D':
+ status = 'deleted';
+ break;
+ case 'R':
+ case 'C':
+ status = 'renamed';
+ break;
+ case 'U':
+ case 'T':
+ status = 'modified';
+ break;
+ }
+ if (status != null) {
+ entries.push({ path: filePath, status });
+ }
+ }
+ return entries;
+}
+
export default defineConfig(() => {
const htmlPlugin = (): Plugin => ({
name: 'html-fallback',
@@ -105,8 +217,146 @@ export default defineConfig(() => {
},
});
+ const editorDevPlugin = (): Plugin => ({
+ name: 'editor-dev',
+ configureServer(server: ViteDevServer) {
+ const handleRoutes = async (
+ req: IncomingMessage,
+ res: ServerResponse,
+ next: () => void
+ ) => {
+ if (req.url === '/editor') {
+ const htmlPath = resolve(__dirname, 'editor.html');
+ try {
+ const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
+ const html = await server.transformIndexHtml(
+ '/editor',
+ htmlContent
+ );
+ res.setHeader('Content-Type', 'text/html');
+ res.end(html);
+ return;
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end(
+ +'Error transforming HTML:' +
+ (e instanceof Error ? e.message : String(e))
+ );
+ }
+ }
+
+ const pathname = req.url?.split('?')[0] ?? '';
+ if (pathname === '/git-status' || pathname.startsWith('/git-status/')) {
+ if (req.method !== 'GET') {
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
+ res.end('Method not allowed');
+ return;
+ }
+ try {
+ const encoded =
+ pathname === '/git-status'
+ ? ''
+ : pathname.slice('/git-status/'.length);
+ const rel = decodeURIComponent(encoded);
+ const absTarget = path.resolve(projectDir, rel);
+ const rootResolved = path.resolve(projectDir);
+ const isUnderRoot =
+ absTarget === rootResolved ||
+ absTarget.startsWith(rootResolved + path.sep);
+ if (isUnderRoot !== true) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ res.end('Path outside repository root');
+ return;
+ }
+ const pathspec = rel.split(path.sep).join('/');
+ const entries: GitStatusEntry[] = getGitStatus(
+ projectDir,
+ pathspec
+ );
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(entries));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end(e instanceof Error ? e.message : String(e));
+ }
+ return;
+ }
+
+ if (pathname.startsWith('/fs/')) {
+ const reqPath = pathname.slice(4);
+ try {
+ switch (req.method) {
+ case 'GET':
+ {
+ const stat = fs.lstatSync(path.join(projectDir, reqPath));
+ if (stat.isDirectory()) {
+ const enties = readProjectDirSync(reqPath);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(enties));
+ } else {
+ const stream = fs.createReadStream(
+ path.join(projectDir, reqPath)
+ );
+ res.setHeader('Content-Type', 'text/plain');
+ for await (const chunk of stream) {
+ res.write(chunk);
+ }
+ res.end();
+ }
+ }
+ break;
+
+ case 'POST':
+ {
+ const stream = new ReadableStream({
+ start(controller) {
+ req.on('data', (chunk) => {
+ controller.enqueue(chunk);
+ });
+ req.on('end', () => {
+ controller.close();
+ });
+ },
+ });
+ const writer = fs.createWriteStream(
+ path.join(projectDir, reqPath)
+ );
+ Readable.fromWeb(stream).pipe(writer);
+ res.setHeader('Content-Type', 'text/plain');
+ res.end('File created');
+ }
+ break;
+
+ case 'DELETE':
+ {
+ fs.unlinkSync(path.join(projectDir, reqPath));
+ res.setHeader('Content-Type', 'text/plain');
+ res.end('File deleted');
+ }
+ break;
+
+ default: {
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
+ res.end('Method not allowed');
+ }
+ }
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end(e instanceof Error ? e.message : String(e));
+ }
+ return;
+ }
+
+ next();
+ };
+
+ // oxlint-disable-next-line typescript/no-misused-promises
+ server.middlewares.use('/', handleRoutes);
+ },
+ });
+
return {
- plugins: [react(), htmlPlugin()],
+ plugins: [react(), htmlPlugin(), editorDevPlugin()],
customLogger: makeFilteredLogger('packages/diffs'),
build: {
rollupOptions: {
@@ -115,5 +365,8 @@ export default defineConfig(() => {
},
},
},
+ server: {
+ hmr: !process.env.NO_HMR,
+ },
};
});
diff --git a/bun.lock b/bun.lock
index 530368ab0..5b9c863e2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -28,6 +28,7 @@
"version": "0.0.0",
"dependencies": {
"@pierre/diffs": "workspace:*",
+ "@pierre/trees": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:",
"shiki": "catalog:",
diff --git a/packages/diffs/package.json b/packages/diffs/package.json
index e3e2a7aa4..96b9c17f7 100644
--- a/packages/diffs/package.json
+++ b/packages/diffs/package.json
@@ -32,6 +32,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+ "./editor": {
+ "types": "./dist/editor/index.d.ts",
+ "import": "./dist/editor/index.js"
+ },
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js"
diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts
index db6bff62b..49f383c70 100644
--- a/packages/diffs/src/components/File.ts
+++ b/packages/diffs/src/components/File.ts
@@ -24,7 +24,10 @@ import { SVGSpriteSheet } from '../sprite';
import type {
AppliedThemeStyleCache,
BaseCodeOptions,
+ DiffsEditableComponent,
+ DiffsEditor,
FileContents,
+ HighlightedToken,
LineAnnotation,
PrePropertiesConfig,
RenderFileMetadata,
@@ -52,8 +55,6 @@ import { setPreNodeProperties } from '../utils/setWrapperNodeProps';
import type { WorkerPoolManager } from '../worker';
import { DiffsContainerLoaded } from './web-components';
-const EMPTY_STRINGS: string[] = [];
-
export interface FileRenderProps {
file: FileContents;
fileContainer?: HTMLElement;
@@ -121,7 +122,9 @@ interface HydrationSetup {
let instanceId = -1;
-export class File {
+export class File<
+ LAnnotation = undefined,
+> implements DiffsEditableComponent {
static LoadedCustomComponent: boolean = DiffsContainerLoaded;
readonly __id: string = `file:${++instanceId}`;
@@ -159,6 +162,8 @@ export class File {
protected file: FileContents | undefined;
protected renderRange: RenderRange | undefined;
+ protected editor: DiffsEditor | undefined;
+
constructor(
public options: FileOptions = { theme: DEFAULT_THEMES },
private workerManager?: WorkerPoolManager | undefined,
@@ -177,6 +182,23 @@ export class File {
this.workerManager?.subscribeToThemeChanges(this);
}
+ public setEditor(editor: DiffsEditor): void {
+ this.editor?.cleanUp();
+ if (this.fileContainer != null && this.file != null) {
+ editor.emitRender(
+ this.fileContainer,
+ this.file,
+ this.lineAnnotations,
+ this.renderRange
+ );
+ }
+ this.editor = editor;
+ }
+
+ public removeEditor(): void {
+ this.editor = undefined;
+ }
+
private handleHighlightRender = (): void => {
this.rerender();
};
@@ -269,6 +291,10 @@ export class File {
this.unsafeCSSStyle = undefined;
this.appliedUnsafeCSS = undefined;
this.placeHolder = undefined;
+
+ // Clean up the editor
+ this.editor?.cleanUp();
+ this.editor = undefined;
}
public hydrate(props: FileHydrateProps): void {
@@ -371,12 +397,37 @@ export class File {
this.resizeManager.setup(this.pre, overflow === 'wrap');
}
- public getOrCreateLineCache(
+ public getOrCreateLineOffSets(
file: FileContents | undefined = this.file
- ): string[] {
+ ): number[] {
return file != null
- ? this.fileRenderer.getOrCreateLineCache(file)
- : EMPTY_STRINGS;
+ ? this.fileRenderer.getOrCreateLineOffsets(file)
+ : // empty string
+ [0];
+ }
+
+ public getLineCount(file: FileContents | undefined = this.file): number {
+ return file != null ? this.fileRenderer.getLineCount(file) : 0;
+ }
+
+ public emitDirtyLines(
+ themeType: 'dark' | 'light',
+ lines: Map>
+ ): void {
+ this.fileRenderer.emitDirtyLines(themeType, lines);
+ }
+
+ public emitLineCountChange(
+ lineCount: number,
+ newLineAnnotations?: LineAnnotation[]
+ ): void {
+ this.fileRenderer.emitLineCountChange(lineCount, newLineAnnotations);
+ if (newLineAnnotations != null) {
+ this.annotationCache.forEach(({ element }) => element.remove());
+ this.annotationCache.clear();
+ this.lineAnnotations = newLineAnnotations;
+ this.rerender();
+ }
}
public render({
@@ -510,6 +561,12 @@ export class File {
this.resizeManager.setup(pre, overflow === 'wrap');
this.renderAnnotations();
this.renderGutterUtility();
+ this.editor?.emitRender(
+ fileContainer,
+ file,
+ this.lineAnnotations,
+ nextRenderRange
+ );
} catch (error: unknown) {
if (disableErrorHandling) {
throw error;
diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts
index f4b1ec494..c4d20cb9a 100644
--- a/packages/diffs/src/components/VirtualizedFile.ts
+++ b/packages/diffs/src/components/VirtualizedFile.ts
@@ -1,11 +1,12 @@
import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants';
import type {
FileContents,
+ LineAnnotation,
RenderRange,
RenderWindow,
VirtualFileMetrics,
} from '../types';
-import { iterateOverFile } from '../utils/iterateOverFile';
+import { areFilesEqual } from '../utils/areFilesEqual';
import type { WorkerPoolManager } from '../worker';
import { File, type FileOptions, type FileRenderProps } from './File';
import type { Virtualizer } from './Virtualizer';
@@ -175,7 +176,7 @@ export class VirtualizedFile<
overflow = 'scroll',
} = this.options;
const { diffHeaderHeight, fileGap, lineHeight } = this.metrics;
- const lines = this.getOrCreateLineCache(this.file);
+ const lineCount = this.getLineCount(this.file);
// Header or initial padding
if (!disableFileHeader) {
@@ -188,18 +189,15 @@ export class VirtualizedFile<
}
if (overflow === 'scroll' && this.lineAnnotations.length === 0) {
- this.height += this.getOrCreateLineCache(this.file).length * lineHeight;
+ this.height += lineCount * lineHeight;
} else {
- iterateOverFile({
- lines,
- callback: ({ lineIndex }) => {
- this.height += this.getLineHeight(lineIndex, false);
- },
- });
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) {
+ this.height += this.getLineHeight(lineIndex, false);
+ }
}
// Bottom padding
- if (lines.length > 0) {
+ if (lineCount > 0) {
this.height += fileGap;
}
@@ -241,14 +239,25 @@ export class VirtualizedFile<
}
}
+ override emitLineCountChange(
+ lineCount: number,
+ newLineAnnotations?: LineAnnotation[]
+ ): void {
+ super.emitLineCountChange(lineCount, newLineAnnotations);
+ this.heightCache.clear();
+ this.computeApproximateSize();
+ this.renderRange = undefined;
+ }
+
override render({
fileContainer,
file,
...props
}: FileRenderProps): boolean {
const { isSetup } = this;
+ const fileChanged = this.file == null || !areFilesEqual(this.file, file);
- this.file ??= file;
+ this.file = file;
fileContainer = this.getOrCreateFileContainerNode(fileContainer);
@@ -270,6 +279,16 @@ export class VirtualizedFile<
this.isSetup = true;
} else {
this.top ??= this.virtualizer.getOffsetInScrollContainer(fileContainer);
+ if (fileChanged) {
+ this.heightCache.clear();
+ this.computeApproximateSize();
+ this.renderRange = undefined;
+ this.virtualizer.instanceChanged(this);
+ this.isVisible = this.virtualizer.isInstanceVisible(
+ this.top,
+ this.height
+ );
+ }
}
if (!this.isVisible) {
@@ -283,7 +302,7 @@ export class VirtualizedFile<
windowSpecs
);
return super.render({
- file: this.file,
+ file,
fileContainer,
renderRange,
...props,
@@ -298,8 +317,7 @@ export class VirtualizedFile<
const { disableFileHeader = false, overflow = 'scroll' } = this.options;
const { diffHeaderHeight, fileGap, hunkLineCount, lineHeight } =
this.metrics;
- const lines = this.getOrCreateLineCache(file);
- const lineCount = lines.length;
+ const lineCount = this.getLineCount(file);
const fileHeight = this.height;
const headerRegion = disableFileHeader ? fileGap : diffHeaderHeight;
@@ -380,50 +398,45 @@ export class VirtualizedFile<
let centerHunk: number | undefined;
let overflowCounter: number | undefined;
- iterateOverFile({
- lines,
- callback: ({ lineIndex }) => {
- const isAtHunkBoundary = currentLine % hunkLineCount === 0;
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) {
+ const isAtHunkBoundary = currentLine % hunkLineCount === 0;
- if (isAtHunkBoundary) {
- hunkOffsets.push(absoluteLineTop - (fileTop + headerRegion));
+ if (isAtHunkBoundary) {
+ hunkOffsets.push(absoluteLineTop - (fileTop + headerRegion));
- if (overflowCounter != null) {
- if (overflowCounter <= 0) {
- return true;
- }
- overflowCounter--;
+ if (overflowCounter != null) {
+ if (overflowCounter <= 0) {
+ break;
}
+ overflowCounter--;
}
+ }
- const lineHeight = this.getLineHeight(lineIndex, false);
- const currentHunk = Math.floor(currentLine / hunkLineCount);
-
- // Track visible region
- if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
- firstVisibleHunk ??= currentHunk;
- }
+ const lineHeight = this.getLineHeight(lineIndex, false);
+ const currentHunk = Math.floor(currentLine / hunkLineCount);
- // Track which hunk contains the viewport center
- if (absoluteLineTop + lineHeight > viewportCenter) {
- centerHunk ??= currentHunk;
- }
+ // Track visible region
+ if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
+ firstVisibleHunk ??= currentHunk;
+ }
- // Start overflow when we are out of the viewport at a hunk boundary
- if (
- overflowCounter == null &&
- absoluteLineTop >= bottom &&
- isAtHunkBoundary
- ) {
- overflowCounter = overflowHunks;
- }
+ // Track which hunk contains the viewport center
+ if (absoluteLineTop + lineHeight > viewportCenter) {
+ centerHunk ??= currentHunk;
+ }
- currentLine++;
- absoluteLineTop += lineHeight;
+ // Start overflow when we are out of the viewport at a hunk boundary
+ if (
+ overflowCounter == null &&
+ absoluteLineTop >= bottom &&
+ isAtHunkBoundary
+ ) {
+ overflowCounter = overflowHunks;
+ }
- return false;
- },
- });
+ currentLine++;
+ absoluteLineTop += lineHeight;
+ }
// No visible lines found
if (firstVisibleHunk == null) {
diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts
new file mode 100644
index 000000000..0e14542ff
--- /dev/null
+++ b/packages/diffs/src/editor/constants.ts
@@ -0,0 +1,68 @@
+export const TOKENIZE_TIME_LIMIT = 100;
+export const TOKENIZE_MAX_LINE_LENGTH = 10000;
+export const BGTOKENIZER_LINES_PRE_TOKENIZE = 50;
+
+const DEBUG_SELECTION = true;
+
+export const EDITOR_CSS: string = /* CSS */ `
+ ::selection {
+ background-color: ${DEBUG_SELECTION ? 'rgba(255, 0, 0, 0.1)' : 'transparent'};
+ }
+ @keyframes blinking {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+ [data-code],
+ [data-content] {
+ position: relative;
+ }
+ [data-content] {
+ background-color: transparent;
+ caret-color: var(--diffs-bg-caret);
+ outline: none;
+ }
+ @media (min-width: 480px) {
+ [data-content] {
+ caret-color: ${DEBUG_SELECTION ? 'red' : 'transparent'};
+ }
+ }
+ [data-line] {
+ cursor: text;
+ }
+ [data-line]:not([data-selected-line]) {
+ background-color: transparent;
+ }
+ [data-caret], [data-selection-range] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ line-height: var(--diffs-line-height);
+ pointer-events: none;
+ }
+ [data-caret] {
+ width: 2px;
+ height: 1lh;
+ background-color: var(--diffs-bg-caret);
+ animation: blinking 1.2s infinite;
+ animation-delay: 0.6s;
+ visibility: hidden;
+ }
+ [data-selection-range] {
+ height: 1lh;
+ z-index: -10;
+ background-color: var(--diffs-line-bg);
+ opacity: 0.5;
+ }
+ [data-editor-overlay] {
+ display: contents;
+ }
+ @media (min-width: 480px) {
+ [data-content]:focus ~ [data-editor-overlay] [data-caret] {
+ visibility: visible;
+ }
+ }
+ [data-content]:focus ~ [data-editor-overlay] [data-selection-range] {
+ opacity: 1;
+ }
+`;
diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts
new file mode 100644
index 000000000..059bd4c5a
--- /dev/null
+++ b/packages/diffs/src/editor/editStack.ts
@@ -0,0 +1,346 @@
+import type { LineAnnotation } from '../types';
+import type { EditorSelection } from './editorSelection';
+import type { ResolvedTextEdit, TextDocument } from './textDocument';
+
+/** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */
+const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100;
+
+/** An entry in the edit stack. */
+export interface EditStackEntry {
+ /** Forward offset edits from the entry's base text to its final text. */
+ forwardEdits: ResolvedTextEdit[];
+ /** Inverse offset edits from the entry's final text back to its base text. */
+ inverseEdits: ResolvedTextEdit[];
+ /** Document version before the entry is applied. */
+ versionBefore: number;
+ /** Document version after the entry is applied. */
+ versionAfter: number;
+ /** Selection before the transaction. */
+ selectionsBefore?: EditorSelection[];
+ /** Selection after the transaction. */
+ selectionsAfter?: EditorSelection[];
+ /** Line annotations before the transaction. */
+ lineAnnotationsBefore?: LineAnnotation[];
+ /** Line annotations after the transaction. */
+ lineAnnotationsAfter?: LineAnnotation[];
+}
+
+/** Options for the edit stack. */
+export interface EditStackOptions {
+ /** The maximum number of entries to keep in the undo stack. */
+ maxEntries?: number;
+}
+
+/** A stack of edit entries. */
+export class EditStack {
+ #undoStack: EditStackEntry[] = [];
+ #redoStack: EditStackEntry[] = [];
+ #maxEntries: number;
+
+ constructor(options?: EditStackOptions) {
+ this.#maxEntries = Math.max(
+ 1,
+ options?.maxEntries ?? DEFAULT_EDIT_STACK_MAX_ENTRIES
+ );
+ }
+
+ get canUndo(): boolean {
+ return this.#undoStack.length > 0;
+ }
+
+ get canRedo(): boolean {
+ return this.#redoStack.length > 0;
+ }
+
+ /** Clears both the undo and redo stacks. */
+ clear(): void {
+ this.#undoStack.length = 0;
+ this.#redoStack.length = 0;
+ }
+
+ /** Clears the redo stack. */
+ clearRedo(): void {
+ this.#redoStack.length = 0;
+ }
+
+ /** Pushes a new entry onto the undo stack. */
+ push(entry: EditStackEntry): void {
+ this.#undoStack.push(entry);
+ this.clearRedo();
+ if (this.#undoStack.length > this.#maxEntries) {
+ this.#undoStack.shift();
+ }
+ }
+
+ /** Sets the selections after the last undo entry. */
+ setLastUndoSelectionsAfter(selections: EditorSelection[]): void {
+ const lastEntry = this.#undoStack[this.#undoStack.length - 1];
+ if (lastEntry !== undefined) {
+ lastEntry.selectionsAfter = selections.map((selection) => ({
+ ...selection,
+ }));
+ }
+ }
+
+ /** Sets the line annotations after the last undo entry. */
+ setLastUndoLineAnnotationsAfter(
+ lineAnnotations: LineAnnotation[]
+ ): void {
+ const lastEntry = this.#undoStack[this.#undoStack.length - 1];
+ if (lastEntry !== undefined) {
+ lastEntry.lineAnnotationsAfter = lineAnnotations.slice();
+ }
+ }
+
+ /** Returns the last undo entry, or `undefined` if empty. */
+ peekUndo(): EditStackEntry | undefined {
+ return this.#undoStack[this.#undoStack.length - 1];
+ }
+
+ /** Replaces the last undo entry with the given entry. */
+ replaceLastUndo(entry: EditStackEntry): void {
+ if (this.#undoStack.length === 0) {
+ this.push(entry);
+ return;
+ }
+ this.#undoStack[this.#undoStack.length - 1] = entry;
+ this.clearRedo();
+ }
+
+ /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */
+ popUndoToRedo(): EditStackEntry | void {
+ const entry = this.#undoStack.pop();
+ if (entry !== undefined) {
+ this.#redoStack.push(entry);
+ return entry;
+ }
+ }
+
+ /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */
+ popRedoToUndo(): EditStackEntry | void {
+ const entry = this.#redoStack.pop();
+ if (entry !== undefined) {
+ this.#undoStack.push(entry);
+ return entry;
+ }
+ }
+}
+
+export function createEditStackEntry(
+ textDocument: TextDocument,
+ resolvedEdits: ResolvedTextEdit[],
+ versionBefore: number,
+ versionAfter: number,
+ selectionsBefore?: EditorSelection[],
+ selectionsAfter?: EditorSelection[],
+ lineAnnotationsBefore?: LineAnnotation[],
+ lineAnnotationsAfter?: LineAnnotation[]
+): EditStackEntry {
+ const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start);
+ const inverseEdits: ResolvedTextEdit[] = [];
+ for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) {
+ const edit = forwardEdits[i];
+ const replacedText = textDocument.getTextSlice(edit.start, edit.end);
+ const startAfterEdit = edit.start + offsetDelta;
+ inverseEdits.push({
+ start: startAfterEdit,
+ end: startAfterEdit + edit.text.length,
+ text: replacedText,
+ });
+ offsetDelta += edit.text.length - (edit.end - edit.start);
+ }
+ return {
+ forwardEdits: forwardEdits.map((edit) => ({ ...edit })),
+ inverseEdits: inverseEdits,
+ versionBefore,
+ versionAfter,
+ selectionsBefore: selectionsBefore?.map((selection) => ({
+ ...selection,
+ })),
+ selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })),
+ lineAnnotationsBefore: lineAnnotationsBefore?.slice(),
+ lineAnnotationsAfter: lineAnnotationsAfter?.slice(),
+ };
+}
+
+/** Determines if the change matches following modes:
+ * - 'insert': simple typing
+ * - 'backspace': backward delete
+ * - 'delete': forward delete
+ */
+export function shouldCoalesceEditStackEntry(
+ previousEntry: EditStackEntry | undefined,
+ nextEntry: EditStackEntry
+): boolean {
+ if (
+ previousEntry === undefined ||
+ previousEntry.forwardEdits.length === 0 ||
+ previousEntry.forwardEdits.length !== previousEntry.inverseEdits.length ||
+ previousEntry.forwardEdits.length !== nextEntry.forwardEdits.length ||
+ nextEntry.forwardEdits.length !== nextEntry.inverseEdits.length
+ ) {
+ return false;
+ }
+ let mode: 'insert' | 'backspace' | 'delete' | undefined;
+ for (let i = 0; i < previousEntry.forwardEdits.length; i++) {
+ const previousForward = previousEntry.forwardEdits[i];
+ const previousInverse = previousEntry.inverseEdits[i];
+ const nextForward = nextEntry.forwardEdits[i];
+ const nextInverse = nextEntry.inverseEdits[i];
+ const mappedNextStart = mapOffsetAfterForwardBatchToBefore(
+ nextForward.start,
+ previousEntry.forwardEdits
+ );
+ const previousWasInsert =
+ previousForward.start <= previousForward.end &&
+ previousForward.text.length > 0 &&
+ !previousForward.text.includes('\n') &&
+ !previousInverse.text.includes('\n');
+ const nextIsInsert =
+ nextForward.start === nextForward.end &&
+ nextForward.text.length > 0 &&
+ nextInverse.text.length === 0;
+ if (previousWasInsert && nextIsInsert) {
+ const expectedMappedNextStart = previousForward.end;
+ // Allow continuing typing after replacing a selection (e.g. "hello" -> "w")
+ // while still requiring that the cursor extension maps inside the same base range.
+ if (mappedNextStart !== expectedMappedNextStart) {
+ return false;
+ }
+ mode ??= 'insert';
+ if (mode !== 'insert') {
+ return false;
+ }
+ continue;
+ }
+ const previousWasDelete =
+ previousForward.text.length === 0 &&
+ previousForward.end > previousForward.start &&
+ previousInverse.text.length > 0;
+ const nextIsDelete =
+ nextForward.text.length === 0 &&
+ nextForward.end > nextForward.start &&
+ nextInverse.text.length > 0;
+ if (previousWasDelete && nextIsDelete) {
+ if (mappedNextStart === previousForward.end) {
+ mode ??= 'delete';
+ if (mode !== 'delete') {
+ return false;
+ }
+ continue;
+ }
+ if (
+ mappedNextStart + (nextForward.end - nextForward.start) !==
+ previousForward.start
+ ) {
+ return false;
+ }
+ mode ??= 'backspace';
+ if (mode !== 'backspace') {
+ return false;
+ }
+ continue;
+ }
+ return false;
+ }
+ return mode !== undefined;
+}
+
+/** Coalesce edit stack entries for simple typing and single-character deletes. */
+export function coalesceEditStackEntries(
+ previousEntry: EditStackEntry,
+ nextEntry: EditStackEntry
+): EditStackEntry {
+ const forwardEdits: ResolvedTextEdit[] = [];
+ const replacedTexts: string[] = [];
+ for (let i = 0; i < previousEntry.forwardEdits.length; i++) {
+ const previousForward = previousEntry.forwardEdits[i];
+ const previousInverse = previousEntry.inverseEdits[i];
+ const nextForward = nextEntry.forwardEdits[i];
+ const nextInverse = nextEntry.inverseEdits[i];
+ const mappedNextStart = mapOffsetAfterForwardBatchToBefore(
+ nextForward.start,
+ previousEntry.forwardEdits
+ );
+
+ if (previousForward.text.length > 0) {
+ forwardEdits.push({
+ start: previousForward.start,
+ end: previousForward.end,
+ text: previousForward.text + nextForward.text,
+ });
+ replacedTexts.push(previousInverse.text);
+ continue;
+ }
+
+ if (mappedNextStart === previousForward.end) {
+ forwardEdits.push({
+ start: previousForward.start,
+ end: mappedNextStart + (nextForward.end - nextForward.start),
+ text: '',
+ });
+ replacedTexts.push(previousInverse.text + nextInverse.text);
+ continue;
+ }
+
+ forwardEdits.push({
+ start: Math.min(previousForward.start, mappedNextStart),
+ end: previousForward.end,
+ text: '',
+ });
+ replacedTexts.push(nextInverse.text + previousInverse.text);
+ }
+
+ return {
+ forwardEdits,
+ inverseEdits: buildInverseEditsFromReplacedTexts(
+ forwardEdits,
+ replacedTexts
+ ),
+ versionBefore: previousEntry.versionBefore,
+ versionAfter: nextEntry.versionAfter,
+ selectionsBefore: previousEntry.selectionsBefore?.slice(),
+ selectionsAfter: nextEntry.selectionsAfter?.slice(),
+ lineAnnotationsBefore: previousEntry.lineAnnotationsBefore?.slice(),
+ lineAnnotationsAfter: nextEntry.lineAnnotationsAfter?.slice(),
+ };
+}
+
+function buildInverseEditsFromReplacedTexts(
+ forwardEdits: readonly ResolvedTextEdit[],
+ replacedTexts: readonly string[]
+): ResolvedTextEdit[] {
+ const inverseEdits: ResolvedTextEdit[] = [];
+ for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) {
+ const edit = forwardEdits[i];
+ const startAfterEdit = edit.start + offsetDelta;
+ inverseEdits.push({
+ start: startAfterEdit,
+ end: startAfterEdit + edit.text.length,
+ text: replacedTexts[i],
+ });
+ offsetDelta += edit.text.length - (edit.end - edit.start);
+ }
+ return inverseEdits;
+}
+
+function mapOffsetAfterForwardBatchToBefore(
+ offsetAfter: number,
+ forwardEdits: readonly ResolvedTextEdit[]
+): number {
+ let offset = offsetAfter;
+ for (const edit of forwardEdits) {
+ const oldLength = edit.end - edit.start;
+ const newLength = edit.text.length;
+ const delta = newLength - oldLength;
+ if (offset < edit.start) {
+ continue;
+ }
+ if (offset >= edit.start + newLength) {
+ offset -= delta;
+ continue;
+ }
+ offset = edit.start + Math.min(offset - edit.start, oldLength);
+ }
+ return offset;
+}
diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts
new file mode 100644
index 000000000..9eb671e0d
--- /dev/null
+++ b/packages/diffs/src/editor/editorCommand.ts
@@ -0,0 +1,55 @@
+import { isMacLike, isPrimaryModifier } from './platform';
+
+export type EditorCommand =
+ | 'indent'
+ | 'outdent'
+ | 'undo'
+ | 'redo'
+ | 'selectAll'
+ | 'findNextMatch'
+ | 'moveCursorToDocStart'
+ | 'moveCursorToDocEnd';
+
+const SHORTCUTS: Partial> = {
+ a: 'selectAll',
+ d: 'findNextMatch',
+};
+
+export function resolveEditorCommandFromKeyboardEvent(
+ event: KeyboardEvent,
+ isMac: boolean = isMacLike()
+): EditorCommand | undefined {
+ const hasPrimaryModifier = isPrimaryModifier(event, isMac);
+ const { shiftKey, altKey, key } = event;
+ if (altKey) {
+ return undefined;
+ }
+
+ const normalizedKey = key.length === 1 ? key.toLowerCase() : key;
+
+ if (!hasPrimaryModifier && normalizedKey === 'Tab') {
+ return shiftKey ? 'outdent' : 'indent';
+ }
+
+ if (!hasPrimaryModifier) {
+ return undefined;
+ }
+
+ if (normalizedKey === 'z') {
+ return shiftKey ? 'redo' : 'undo';
+ }
+
+ if (!isMac && normalizedKey === 'y') {
+ return 'redo';
+ }
+
+ if (normalizedKey === 'Home' || (isMac && normalizedKey === 'ArrowUp')) {
+ return 'moveCursorToDocStart';
+ }
+
+ if (normalizedKey === 'End' || (isMac && normalizedKey === 'ArrowDown')) {
+ return 'moveCursorToDocEnd';
+ }
+
+ return SHORTCUTS[normalizedKey];
+}
diff --git a/packages/diffs/src/editor/editorLineAnnotations.ts b/packages/diffs/src/editor/editorLineAnnotations.ts
new file mode 100644
index 000000000..83890d9fa
--- /dev/null
+++ b/packages/diffs/src/editor/editorLineAnnotations.ts
@@ -0,0 +1,54 @@
+import type { LineAnnotation } from '../types';
+import type { TextDocumentChange } from './textDocument';
+
+export function applyDocumentChangeToLineAnnotations(
+ change: TextDocumentChange,
+ lineAnnotations: LineAnnotation[]
+): LineAnnotation[] {
+ if (change.lineDelta === 0) {
+ return lineAnnotations;
+ }
+
+ const startCharacter = change.startCharacter;
+ const removedLineCount = Math.max(0, -change.lineDelta);
+ const deletedStartLine =
+ removedLineCount === 0
+ ? undefined
+ : change.startLine + (startCharacter === 0 ? 0 : 1);
+ const deletedEndLine =
+ deletedStartLine === undefined
+ ? undefined
+ : deletedStartLine + removedLineCount;
+ const shiftFromLine =
+ removedLineCount > 0
+ ? change.startLine + removedLineCount
+ : change.startLine + (startCharacter === 0 ? 0 : 1);
+ const nextLineAnnotations: LineAnnotation[] = [];
+
+ let changed = false;
+ for (const annotation of lineAnnotations) {
+ const line = annotation.lineNumber - 1;
+ if (
+ deletedStartLine !== undefined &&
+ deletedEndLine !== undefined &&
+ line >= deletedStartLine &&
+ line < deletedEndLine
+ ) {
+ changed = true;
+ continue;
+ }
+
+ if (line >= shiftFromLine) {
+ nextLineAnnotations.push({
+ ...annotation,
+ lineNumber: line + change.lineDelta + 1,
+ });
+ changed = true;
+ continue;
+ }
+
+ nextLineAnnotations.push(annotation);
+ }
+
+ return changed ? nextLineAnnotations : lineAnnotations;
+}
diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts
new file mode 100644
index 000000000..82bf64983
--- /dev/null
+++ b/packages/diffs/src/editor/editorSelection.ts
@@ -0,0 +1,969 @@
+import type { LineAnnotation } from '../types';
+import type {
+ Position,
+ Range,
+ ResolvedTextEdit,
+ TextDocument,
+ TextDocumentChange,
+ TextEdit,
+} from './textDocument';
+
+export const DirectionBackward = -1;
+export const DirectionNone = 0;
+export const DirectionForward = 1;
+
+export type SelectionDirection =
+ | typeof DirectionBackward
+ | typeof DirectionNone
+ | typeof DirectionForward;
+
+export interface EditorSelection extends Range {
+ direction: SelectionDirection;
+}
+
+/**
+ * Converts a selection from a web selection to an editor selection.
+ */
+export function convertSelection(
+ range: StaticRange,
+ direction: SelectionDirection = DirectionNone
+): EditorSelection | undefined {
+ const start = boundaryToPosition(range.startContainer, range.startOffset);
+ const end = boundaryToPosition(range.endContainer, range.endOffset);
+ if (start === null || end === null) {
+ return undefined;
+ }
+ return {
+ start,
+ end,
+ direction,
+ };
+}
+
+/**
+ * Resolves the indent edits for a selection.
+ */
+export function resolveIndentEdits(
+ textDocument: TextDocument,
+ selection: EditorSelection,
+ tabSize: number,
+ outdent: boolean
+): [edits: TextEdit[], nextSelection: EditorSelection] {
+ if (textDocument === undefined) {
+ return [[], selection];
+ }
+ const { start, end } = selection;
+ const edits: TextEdit[] = [];
+ let newSelection: EditorSelection = { ...selection };
+ let endLine = end.line;
+ if (start.line < end.line && end.character === 0) {
+ endLine--;
+ }
+ for (let line = start.line; line <= endLine; line++) {
+ const lineText = textDocument.getLineText(line);
+ if (lineText === undefined) {
+ continue;
+ }
+ const indentUnit = lineText.startsWith('\t') ? '\t' : ' '.repeat(tabSize);
+ let deleteLength = 0;
+ let newText = indentUnit;
+ if (outdent) {
+ if (lineText.startsWith('\t')) {
+ deleteLength = 1;
+ } else if (lineText.startsWith(' ')) {
+ const leadingSpacesLength =
+ lineText.length - lineText.trimStart().length;
+ deleteLength = Math.min(indentUnit.length, leadingSpacesLength);
+ }
+ if (deleteLength === 0) {
+ continue;
+ }
+ newText = '';
+ }
+ edits.push({
+ range: {
+ start: { line, character: 0 },
+ end: { line, character: deleteLength },
+ },
+ newText,
+ });
+ const delta = newText.length - deleteLength;
+ if (line === start.line) {
+ newSelection = {
+ ...newSelection,
+ start: {
+ ...start,
+ character: Math.max(0, start.character + delta),
+ },
+ };
+ }
+ if (line === end.line) {
+ newSelection = {
+ ...newSelection,
+ end: {
+ ...end,
+ character: Math.max(0, end.character + delta),
+ },
+ };
+ }
+ }
+ return [edits, newSelection];
+}
+
+/**
+ * Maps the cursor move to all selections.
+ * TODO(@ije): use move cursor commands
+ */
+export function mapCursorMove(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ nextPosition: Position
+): EditorSelection[] {
+ const primarySelection = selections[selections.length - 1];
+ if (primarySelection === undefined) {
+ return [];
+ }
+ const deltaOffset =
+ textDocument.offsetAt(nextPosition) -
+ textDocument.offsetAt(primarySelection.start);
+ const deltaLine = nextPosition.line - primarySelection.start.line;
+ const movedOneChar = deltaOffset === 1 || deltaOffset === -1;
+ const newSelections: EditorSelection[] = [];
+ for (const selection of selections) {
+ let newPosition = nextPosition;
+ if (selection !== primarySelection) {
+ if (deltaLine === 0 || movedOneChar) {
+ newPosition = textDocument.positionAt(
+ textDocument.offsetAt(selection.start) + deltaOffset
+ );
+ } else {
+ newPosition = {
+ line: clamp(
+ selection.start.line + deltaLine,
+ 0,
+ textDocument.lineCount - 1
+ ),
+ character: selection.start.character,
+ };
+ }
+ }
+ const newSelection: EditorSelection = {
+ start: newPosition,
+ end: newPosition,
+ direction: DirectionNone,
+ };
+ const previousSelection = newSelections.at(-1);
+ if (
+ previousSelection === undefined ||
+ comparePosition(previousSelection.start, newSelection.start) !== 0
+ ) {
+ newSelections.push(newSelection);
+ }
+ }
+ return newSelections;
+}
+
+/**
+ * Maps the selection shift to all selections.
+ */
+export function mapSelectionShift(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ selectionShift: EditorSelection
+): EditorSelection[] {
+ const primarySelection = selections[selections.length - 1];
+ if (primarySelection === undefined) {
+ return [];
+ }
+ const [primaryAnchorOffset, primaryFocusOffset] =
+ getSelectionAnchorAndFocusOffsets(textDocument, primarySelection);
+ const [shiftAnchorOffset, shiftFocusOffset] =
+ getSelectionAnchorAndFocusOffsets(textDocument, selectionShift);
+ const anchorDelta = shiftAnchorOffset - primaryAnchorOffset;
+ const focusDelta = shiftFocusOffset - primaryFocusOffset;
+ const mappedSelections: EditorSelection[] = [];
+ for (const selection of selections) {
+ const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets(
+ textDocument,
+ selection
+ );
+ const mappedOffsets = createSelectionFromAnchorAndFocusOffsets(
+ textDocument,
+ anchorOffset + anchorDelta,
+ focusOffset + focusDelta
+ );
+ const newSelection =
+ !isCollapsedSelection(mappedOffsets) &&
+ selectionShift.direction !== DirectionNone
+ ? { ...mappedOffsets, direction: selectionShift.direction }
+ : mappedOffsets;
+ const previousSelection = mappedSelections.at(-1);
+ if (
+ previousSelection !== undefined &&
+ selectionIntersects(previousSelection, newSelection)
+ ) {
+ Object.assign(
+ previousSelection,
+ createSelectionFrom(previousSelection, newSelection)
+ );
+ } else {
+ mappedSelections.push(newSelection);
+ }
+ }
+ return mappedSelections;
+}
+
+/**
+ * Applies a text change to a selection.
+ */
+export function applyTextChangeToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ edit: ResolvedTextEdit,
+ lineAnnotations?: LineAnnotation[],
+ tabSize = 2
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ const primarySelection = selections[selections.length - 1];
+ if (primarySelection === undefined) {
+ return { nextSelections: [] };
+ }
+ const primaryStartOffset = textDocument.offsetAt(primarySelection.start);
+ const primaryEndOffset = textDocument.offsetAt(primarySelection.end);
+ const ordered = selections
+ .map((selection, index) => ({
+ selection,
+ index,
+ start: textDocument.offsetAt(selection.start),
+ end: textDocument.offsetAt(selection.end),
+ isPrimary: index === selections.length - 1,
+ }))
+ .sort((a, b) => {
+ const startOrder = a.start - b.start;
+ if (startOrder !== 0) {
+ return startOrder;
+ }
+ const endOrder = a.end - b.end;
+ if (endOrder !== 0) {
+ return endOrder;
+ }
+ return a.index - b.index;
+ });
+ const adjustedChange = normalizeLeadingIndentForChange(
+ textDocument,
+ edit,
+ primarySelection,
+ tabSize
+ );
+ const edits: TextEdit[] = [];
+ const nextSelectionOffsets: Array<[number, number]> = Array.from({
+ length: selections.length,
+ });
+ let offsetDelta = 0;
+ let mergedGroup:
+ | {
+ start: number;
+ end: number;
+ indices: number[];
+ }
+ | undefined;
+ const finalizeMergedGroup = () => {
+ if (mergedGroup === undefined) {
+ return;
+ }
+ const newText = expandSingleNewlineInsert(
+ textDocument,
+ adjustedChange.text,
+ mergedGroup.start
+ );
+ edits.push({
+ range: {
+ start: textDocument.positionAt(mergedGroup.start),
+ end: textDocument.positionAt(mergedGroup.end),
+ },
+ newText,
+ });
+ const nextOffsets: [number, number] = [
+ mergedGroup.start + offsetDelta + newText.length,
+ mergedGroup.start + offsetDelta + newText.length,
+ ];
+ for (const index of mergedGroup.indices) {
+ nextSelectionOffsets[index] = nextOffsets;
+ }
+ offsetDelta += newText.length - (mergedGroup.end - mergedGroup.start);
+ mergedGroup = undefined;
+ };
+ for (const entry of ordered) {
+ const startOffset = Math.max(
+ 0,
+ entry.start + (adjustedChange.start - primaryStartOffset)
+ );
+ const endOffset = Math.max(
+ startOffset,
+ entry.end + (adjustedChange.end - primaryEndOffset)
+ );
+ if (mergedGroup !== undefined && startOffset < mergedGroup.end) {
+ mergedGroup.end = Math.max(mergedGroup.end, endOffset);
+ mergedGroup.indices.push(entry.index);
+ continue;
+ }
+ finalizeMergedGroup();
+ mergedGroup = {
+ start: startOffset,
+ end: endOffset,
+ indices: [entry.index],
+ };
+ }
+ finalizeMergedGroup();
+
+ const change = textDocument.applyEdits(
+ edits,
+ true,
+ selections,
+ undefined,
+ lineAnnotations
+ );
+ const nextSelections = nextSelectionOffsets.map((offsets) =>
+ createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets)
+ );
+ textDocument.setLastUndoSelectionsAfter(nextSelections);
+
+ return { nextSelections, change };
+}
+
+/**
+ * Applies a text replace to a selection.
+ */
+export function applyTextReplaceToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ texts: string[],
+ lineAnnotations?: LineAnnotation[]
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ if (selections.length !== texts.length) {
+ throw new Error(
+ 'Selection text replacements must match the selection count'
+ );
+ }
+ const ordered = selections
+ .map((selection, index) => ({
+ index,
+ start: textDocument.offsetAt(selection.start),
+ end: textDocument.offsetAt(selection.end),
+ text: texts[index],
+ }))
+ .sort((a, b) => {
+ const startOrder = a.start - b.start;
+ if (startOrder !== 0) {
+ return startOrder;
+ }
+ const endOrder = a.end - b.end;
+ if (endOrder !== 0) {
+ return endOrder;
+ }
+ return a.index - b.index;
+ });
+ const edits: TextEdit[] = [];
+ const nextSelectionOffsets: number[] = Array.from({
+ length: selections.length,
+ });
+ let offsetDelta = 0;
+ let previousEditEnd = -1;
+ for (const entry of ordered) {
+ if (entry.start < previousEditEnd) {
+ throw new Error('Overlapping multi-selection edits are not supported');
+ }
+ previousEditEnd = entry.end;
+ const newText = expandSingleNewlineInsert(
+ textDocument,
+ entry.text,
+ entry.start
+ );
+ edits.push({
+ range: {
+ start: textDocument.positionAt(entry.start),
+ end: textDocument.positionAt(entry.end),
+ },
+ newText,
+ });
+ nextSelectionOffsets[entry.index] =
+ entry.start + offsetDelta + newText.length;
+ offsetDelta += newText.length - (entry.end - entry.start);
+ }
+
+ const change = textDocument.applyEdits(
+ edits,
+ true,
+ selections,
+ undefined,
+ lineAnnotations
+ );
+ const nextSelections = nextSelectionOffsets.map((offset) =>
+ createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset)
+ );
+ textDocument.setLastUndoSelectionsAfter(nextSelections);
+ return { nextSelections, change };
+}
+
+/**
+ * Checks if a selection is collapsed.
+ */
+export function isCollapsedSelection(selection: EditorSelection): boolean {
+ return (
+ selection.start.line === selection.end.line &&
+ selection.start.character === selection.end.character
+ );
+}
+
+/**
+ * Checks whether selections `a` and `b` intersect.
+ */
+export function selectionIntersects(
+ a: EditorSelection,
+ b: EditorSelection
+): boolean {
+ const aCollapsed = isCollapsedSelection(a);
+ const bCollapsed = isCollapsedSelection(b);
+ if (aCollapsed && bCollapsed) {
+ return comparePosition(a.start, b.start) === 0;
+ }
+ if (aCollapsed) {
+ return (
+ comparePosition(b.start, a.start) <= 0 &&
+ comparePosition(a.start, b.end) <= 0
+ );
+ }
+ if (bCollapsed) {
+ return (
+ comparePosition(a.start, b.start) <= 0 &&
+ comparePosition(b.start, a.end) <= 0
+ );
+ }
+ return (
+ comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0
+ );
+}
+
+/**
+ * Compares two positions.
+ */
+export function comparePosition(a: Position, b: Position): number {
+ if (a.line !== b.line) {
+ return a.line - b.line;
+ }
+ return a.character - b.character;
+}
+
+/**
+ * Creates a selection from anchor and focus offsets.
+ */
+export function createSelectionFromAnchorAndFocusOffsets(
+ textDocument: TextDocument,
+ anchorOffset: number,
+ focusOffset: number
+): EditorSelection {
+ const direction =
+ anchorOffset === focusOffset
+ ? DirectionNone
+ : anchorOffset < focusOffset
+ ? DirectionForward
+ : DirectionBackward;
+ const start = Math.min(anchorOffset, focusOffset);
+ const end = Math.max(anchorOffset, focusOffset);
+ return {
+ start: textDocument.positionAt(start),
+ end: textDocument.positionAt(end),
+ direction,
+ };
+}
+
+/**
+ * Creates a selection from a anchor and focus selection.
+ */
+export function createSelectionFrom(
+ anchorSelection: EditorSelection,
+ focusSelection: EditorSelection
+): EditorSelection {
+ const anchor =
+ anchorSelection.direction === DirectionBackward
+ ? anchorSelection.end
+ : anchorSelection.start;
+ const currentStartOrder = comparePosition(anchor, focusSelection.start);
+ const currentEndOrder = comparePosition(anchor, focusSelection.end);
+ let focus = focusSelection.end;
+ if (currentStartOrder <= 0) {
+ focus = focusSelection.end;
+ } else if (currentEndOrder >= 0) {
+ focus = focusSelection.start;
+ } else {
+ // When the original anchor sits inside `current`, keep whichever edge
+ // stayed at the anchor so drag direction remains stable.
+ const anchorAtStart = currentStartOrder === 0;
+ focus = anchorAtStart ? focusSelection.end : focusSelection.start;
+ }
+ const anchorVsFocus = comparePosition(anchor, focus);
+ const direction: SelectionDirection =
+ anchorVsFocus === 0
+ ? DirectionNone
+ : anchorVsFocus < 0
+ ? DirectionForward
+ : DirectionBackward;
+ const selectionStart = anchorVsFocus <= 0 ? anchor : focus;
+ const selectionEnd = anchorVsFocus <= 0 ? focus : anchor;
+ return {
+ start: selectionStart,
+ end: selectionEnd,
+ direction,
+ };
+}
+
+/**
+ * Extends or shrinks the selection `original` using the endpoints of `target`, \
+ * matching contenteditable shift + click extend behavior.
+ */
+export function extendSelection(
+ original: EditorSelection,
+ target: EditorSelection
+): EditorSelection {
+ const leftExtended = comparePosition(target.start, original.start) < 0;
+ const rightExtended = comparePosition(target.end, original.end) > 0;
+
+ if (leftExtended && !rightExtended) {
+ return {
+ start: target.start,
+ end: original.end,
+ direction: DirectionBackward,
+ };
+ }
+
+ if (rightExtended && !leftExtended) {
+ return {
+ start: original.start,
+ end: target.end,
+ direction: DirectionForward,
+ };
+ }
+
+ if (original.direction === DirectionBackward) {
+ return {
+ start: target.start,
+ end: original.end,
+ direction:
+ comparePosition(target.start, original.end) === 0
+ ? DirectionNone
+ : DirectionBackward,
+ };
+ }
+
+ return {
+ start: original.start,
+ end: target.end,
+ direction:
+ comparePosition(original.start, target.end) === 0
+ ? DirectionNone
+ : DirectionForward,
+ };
+}
+
+/**
+ * Finds the next matching word and updates the selections.
+ */
+export function findNexMatch(
+ textDocument: TextDocument,
+ selections: EditorSelection[]
+): EditorSelection[] | undefined {
+ const texts = selections.map((s) => textDocument.getText(s));
+ const needle = texts[0];
+ if (needle.length === 0 || texts.some((t) => t !== needle)) {
+ return undefined;
+ }
+
+ const occupied = selections.map(
+ (s) =>
+ [textDocument.offsetAt(s.start), textDocument.offsetAt(s.end)] as [
+ number,
+ number,
+ ]
+ );
+ const nextOffset = textDocument.findNextNonOverlappingSubstring(
+ needle,
+ occupied
+ );
+ if (nextOffset === undefined) {
+ return undefined;
+ }
+ const added = createSelectionFromAnchorAndFocusOffsets(
+ textDocument,
+ nextOffset,
+ nextOffset + needle.length
+ );
+ return [...selections, added];
+}
+
+export function getDocumentFullSelection(
+ textDocument: TextDocument
+): EditorSelection {
+ const lastLine = textDocument.lineCount - 1;
+ const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0;
+ return {
+ start: { line: 0, character: 0 },
+ end: { line: lastLine, character: lastCharacter },
+ direction: DirectionForward,
+ };
+}
+
+export function getDocumentBoundarySelection(
+ textDocument: TextDocument,
+ atEnd: boolean
+): EditorSelection {
+ const line = atEnd ? textDocument.lineCount - 1 : 0;
+ const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0;
+ const start = { line, character };
+ return {
+ start: start,
+ end: start,
+ direction: DirectionForward,
+ };
+}
+
+export function getSelectionText(
+ textDocument: TextDocument,
+ selections: EditorSelection[]
+): string {
+ return [...selections]
+ .sort((a, b) => {
+ const startOrder = comparePosition(a.start, b.start);
+ if (startOrder !== 0) {
+ return startOrder;
+ }
+ return comparePosition(a.end, b.end);
+ })
+ .map((selection) => {
+ if (isCollapsedSelection(selection)) {
+ return textDocument.getLineText(selection.start.line, false);
+ }
+ return textDocument.getText(selection);
+ })
+ .join('\n');
+}
+
+/**
+ * Gets the text node and offset for a selection.
+ */
+export function getSelectionTextNode(
+ lineElement: HTMLElement,
+ character: number
+): [Node, number] {
+ if (lineElement.childElementCount > 0) {
+ for (const child of lineElement.children) {
+ if (child.hasAttribute('data-char')) {
+ const char = Number(child.getAttribute('data-char'));
+ const textNode = child.firstChild;
+ if (
+ textNode !== null &&
+ textNode.nodeType === /* Node.TEXT_NODE */ 3 &&
+ character >= char &&
+ character <= char + (textNode as Text).textContent.length
+ ) {
+ return [textNode, character - char];
+ }
+ }
+ }
+ }
+ const textNode = lineElement.firstChild;
+ if (textNode !== null && textNode.nodeType === /* Node.TEXT_NODE */ 3) {
+ return [textNode, character];
+ }
+ throw new Error('No text node found');
+}
+
+/**
+ * Expands a zero-width selection to the word-like segment that contains the caret.
+ */
+export function expandCollapsedSelectionToWord(
+ textDocument: TextDocument,
+ selection: EditorSelection
+): EditorSelection {
+ const { line, character } = selection.start;
+ const lineText = textDocument.getLineText(line);
+ const ch = Math.max(0, Math.min(character, lineText.length));
+ const span = expandCollapsedLineWord(lineText, ch);
+ if (span === undefined) {
+ return selection;
+ }
+ return {
+ start: { line, character: span.start },
+ end: { line, character: span.end },
+ direction: DirectionForward,
+ };
+}
+
+function expandCollapsedLineWord(
+ lineText: string,
+ character: number
+): { start: number; end: number } | undefined {
+ const segmenter = new Intl.Segmenter(undefined, {
+ granularity: 'word',
+ });
+ for (const seg of segmenter.segment(lineText)) {
+ if (seg.isWordLike !== true) {
+ continue;
+ }
+ const lo = seg.index;
+ const hi = lo + seg.segment.length;
+ if (character >= lo && character < hi) {
+ return { start: lo, end: hi };
+ }
+ }
+ for (const seg of segmenter.segment(lineText)) {
+ if (seg.isWordLike !== true) {
+ continue;
+ }
+ const lo = seg.index;
+ const hi = lo + seg.segment.length;
+ if (lo >= character) {
+ return { start: lo, end: hi };
+ }
+ }
+ let best: { start: number; end: number } | undefined;
+ for (const seg of segmenter.segment(lineText)) {
+ if (seg.isWordLike !== true) {
+ continue;
+ }
+ const lo = seg.index;
+ const hi = lo + seg.segment.length;
+ if (hi <= character) {
+ best = { start: lo, end: hi };
+ }
+ }
+ return best;
+}
+
+function getSelectionAnchorAndFocusOffsets(
+ textDocument: TextDocument,
+ selection: EditorSelection
+): [anchorOffset: number, focusOffset: number] {
+ const isBackward = selection.direction === DirectionBackward;
+ return [
+ textDocument.offsetAt(isBackward ? selection.end : selection.start),
+ textDocument.offsetAt(isBackward ? selection.start : selection.end),
+ ];
+}
+
+// When the user inserts a lone line break, copy the current line's indentation onto the new line.
+function expandSingleNewlineInsert(
+ textDocument: TextDocument,
+ insertText: string,
+ insertStartOffset: number
+): string {
+ if (insertText !== '\n' && insertText !== '\r\n') {
+ return insertText;
+ }
+ const line = textDocument.positionAt(insertStartOffset).line;
+ const lineText = textDocument.getLineText(line);
+ let indentLen = 0;
+ for (; indentLen < lineText.length; indentLen++) {
+ const ch = lineText[indentLen];
+ if (ch !== ' ' && ch !== '\t') {
+ break;
+ }
+ }
+ if (indentLen === 0) {
+ return insertText;
+ }
+ return '\n' + lineText.slice(0, indentLen);
+}
+
+// Expands a backspace over leading spaces into one soft-tab width so mixed hard/soft indentation
+// behaves like the explicit outdent command.
+function normalizeLeadingIndentForChange(
+ textDocument: TextDocument,
+ change: ResolvedTextEdit,
+ primarySelection: EditorSelection,
+ tabSize: number
+): ResolvedTextEdit {
+ if (
+ change.text !== '' ||
+ change.start !== change.end - 1 ||
+ primarySelection.start.line !== primarySelection.end.line ||
+ primarySelection.start.character !== primarySelection.end.character
+ ) {
+ return change;
+ }
+ const caretPosition = textDocument.positionAt(change.end);
+ if (caretPosition.character === 0) {
+ return change;
+ }
+ const primaryOffset = textDocument.offsetAt(primarySelection.start);
+ if (change.end !== primaryOffset) {
+ return change;
+ }
+ const lineText = textDocument.getLineText(caretPosition.line);
+ const leadingText = lineText.slice(0, caretPosition.character);
+ if (/[^ \t]/.test(leadingText)) {
+ return change;
+ }
+ if (lineText[caretPosition.character - 1] === '\t') {
+ return change;
+ }
+ const softTabStart = Math.max(0, caretPosition.character - tabSize);
+ const softTabText = lineText.slice(softTabStart, caretPosition.character);
+ if (softTabText.length === tabSize && /^ +$/.test(softTabText)) {
+ return {
+ ...change,
+ start: change.end - softTabText.length,
+ };
+ }
+ return change;
+}
+
+function boundaryToPosition(node: Node, offset: number): Position | null {
+ if (node.nodeType === 3) {
+ const parent = node.parentElement;
+ if (parent === null) {
+ return null;
+ }
+ if (parent.tagName === 'DIV') {
+ const childIndex = Array.prototype.indexOf.call(parent.childNodes, node);
+ const position = getPositionWithinPre(parent, childIndex);
+ return position === null
+ ? null
+ : {
+ ...position,
+ character:
+ position.character + getTextOffset(node.textContent, offset),
+ };
+ }
+ if (parent.tagName === 'SPAN') {
+ const pre = parent.parentElement;
+ if (pre === null || pre.tagName !== 'DIV') {
+ return null;
+ }
+ const line = getLineIndex(pre);
+ const base = getCharacterIndex(parent);
+ if (line !== undefined && base !== undefined) {
+ return { line, character: base + offset };
+ }
+ }
+ const preChild = getDirectPreChild(node);
+ if (preChild !== null) {
+ return getPositionWithinPre(preChild.pre, preChild.childIndex);
+ }
+ return null;
+ }
+ if (node.nodeType === 1) {
+ const el = node as HTMLElement;
+ if (el.tagName === 'DIV') {
+ return getPositionWithinPre(el, offset);
+ }
+ if (el.tagName === 'BR') {
+ const pre = el.parentElement;
+ if (pre === null || pre.tagName !== 'DIV') {
+ return null;
+ }
+ const line = getLineIndex(pre);
+ if (line !== undefined) {
+ return { line, character: 0 };
+ }
+ }
+ if (el.tagName === 'SPAN') {
+ const pre = el.parentElement;
+ if (pre === null || pre.tagName !== 'DIV') {
+ return null;
+ }
+ const line = getLineIndex(pre);
+ const base = getCharacterIndex(el);
+ if (line !== undefined && base !== undefined) {
+ let character = base;
+ for (let i = 0; i < offset; i++) {
+ character += el.childNodes[i]?.textContent?.length ?? 0;
+ }
+ return { line, character };
+ }
+ }
+ const preChild = getDirectPreChild(el);
+ if (preChild !== null) {
+ return getPositionWithinPre(preChild.pre, preChild.childIndex);
+ }
+ }
+ return null;
+}
+
+function getPositionWithinPre(
+ pre: HTMLElement,
+ offset: number
+): Position | null {
+ const line = getLineIndex(pre);
+ if (line === undefined) {
+ return null;
+ }
+ let character = 0;
+ for (let i = 0; i < offset; i++) {
+ const c = pre.childNodes[i];
+ if (c?.nodeType === 3) {
+ character += getTextOffset(c.textContent, c.textContent?.length ?? 0);
+ continue;
+ }
+ if (c?.nodeType === 1 && (c as HTMLElement).tagName === 'SPAN') {
+ const span = c as HTMLElement;
+ const o = getCharacterIndex(span);
+ if (o === undefined) {
+ continue;
+ }
+ const len = span.textContent?.length ?? 0;
+ character = o + len;
+ }
+ }
+ return { line, character };
+}
+
+function getDirectPreChild(
+ node: Node
+): { pre: HTMLElement; childIndex: number } | null {
+ let current =
+ node.nodeType === 1 ? (node as HTMLElement) : node.parentElement;
+ while (current !== null && current.parentElement !== null) {
+ if (current.parentElement.tagName === 'DIV') {
+ return {
+ pre: current.parentElement,
+ childIndex: Array.prototype.indexOf.call(
+ current.parentElement.childNodes,
+ current
+ ),
+ };
+ }
+ current = current.parentElement;
+ }
+ return null;
+}
+
+function getLineIndex(el: HTMLElement): number | undefined {
+ const { lineIndex } = el.dataset;
+ return lineIndex !== undefined ? parseInt(lineIndex) : undefined;
+}
+
+function getCharacterIndex(el: HTMLElement): number | undefined {
+ const { char } = el.dataset;
+ return char !== undefined ? parseInt(char) : undefined;
+}
+
+function getTextOffset(
+ text: string | null | undefined,
+ offset: number
+): number {
+ const value = text ?? '';
+ const lineBreakIndex = value.search(/[\r\n]/);
+ return Math.min(
+ offset,
+ lineBreakIndex === -1 ? value.length : lineBreakIndex
+ );
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(value, max));
+}
diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts
new file mode 100644
index 000000000..a6b9b0055
--- /dev/null
+++ b/packages/diffs/src/editor/editorUtils.ts
@@ -0,0 +1,108 @@
+export function createElement(
+ tagName: K,
+ props: {
+ id?: string;
+ class?: string;
+ style?: string | Partial;
+ dataset?: DOMStringMap | string[] | string;
+ children?: (Node | string)[];
+ textContent?: string;
+ html?: string;
+ } = {},
+ parent?: Element | ShadowRoot | DocumentFragment
+): HTMLElementTagNameMap[K] {
+ const el = document.createElement(tagName);
+ const {
+ id,
+ class: className,
+ style,
+ dataset,
+ textContent,
+ html,
+ children,
+ } = props;
+ if (id) {
+ el.id = id;
+ }
+ if (className !== undefined) {
+ el.className = className;
+ }
+ if (style !== undefined) {
+ if (typeof style === 'string') {
+ el.style.cssText = style;
+ } else {
+ Object.assign(el.style, style);
+ }
+ }
+ if (dataset !== undefined) {
+ if (typeof dataset === 'string') {
+ el.dataset[dataset] = '';
+ } else if (Array.isArray(dataset)) {
+ dataset.forEach((key) => {
+ el.dataset[key] = '';
+ });
+ } else {
+ Object.assign(el.dataset, dataset);
+ }
+ }
+ if (textContent !== undefined) {
+ el.textContent = textContent;
+ }
+ if (html !== undefined) {
+ el.innerHTML = html;
+ }
+ if (parent !== undefined) {
+ parent.appendChild(el);
+ }
+ if (children !== undefined) {
+ el.replaceChildren(...children);
+ }
+ return el;
+}
+
+export function addEventListener(
+ el: HTMLElement,
+ event: K,
+ listener: (this: HTMLElement, evt: HTMLElementEventMap[K]) => void,
+ options?: AddEventListenerOptions
+): () => void;
+export function addEventListener(
+ el: Document,
+ event: K,
+ listener: (this: Document, evt: DocumentEventMap[K]) => void,
+ options?: AddEventListenerOptions
+): () => void;
+export function addEventListener(
+ el: Window,
+ event: K,
+ listener: (this: Window, evt: WindowEventMap[K]) => void,
+ options?: AddEventListenerOptions
+): () => void;
+export function addEventListener(
+ el: HTMLElement | Document | ShadowRoot | Window,
+ event: string,
+ listener: EventListener,
+ options?: AddEventListenerOptions
+) {
+ el.addEventListener(event, listener, options);
+ return () => el.removeEventListener(event, listener);
+}
+
+export function extend(obj: T, attrs: Partial): T {
+ return Object.assign(obj, attrs);
+}
+
+export function debounce void>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeout: ReturnType;
+ return function (this: ThisType, ...args: Parameters) {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func.apply(this, args), wait);
+ };
+}
+
+export function round(value: number, precision: number = 1000): number {
+ return Math.round(value * precision) / precision;
+}
diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts
new file mode 100644
index 000000000..2cf427d9b
--- /dev/null
+++ b/packages/diffs/src/editor/index.ts
@@ -0,0 +1,1843 @@
+import { type IGrammar, INITIAL, type StateStack } from 'shiki/textmate';
+
+import type { File } from '../components/File';
+import { DEFAULT_THEMES } from '../constants';
+import {
+ type EditorCommand,
+ resolveEditorCommandFromKeyboardEvent,
+} from '../editor/editorCommand';
+import type { EditorSelection } from '../editor/editorSelection';
+import {
+ applyTextChangeToSelections,
+ applyTextReplaceToSelections,
+ comparePosition,
+ convertSelection,
+ createSelectionFrom,
+ DirectionBackward,
+ DirectionForward,
+ DirectionNone,
+ expandCollapsedSelectionToWord,
+ extendSelection,
+ findNexMatch,
+ getDocumentBoundarySelection,
+ getDocumentFullSelection,
+ getSelectionText,
+ getSelectionTextNode,
+ isCollapsedSelection,
+ mapCursorMove,
+ mapSelectionShift,
+ resolveIndentEdits,
+ selectionIntersects,
+} from '../editor/editorSelection';
+import {
+ addEventListener,
+ createElement,
+ debounce,
+ extend,
+ round,
+} from '../editor/editorUtils';
+import {
+ TextDocument,
+ type TextDocumentChange,
+ type TextEdit,
+} from '../editor/textDocument';
+import { getHighlighterIfLoaded } from '../highlighter/shared_highlighter';
+import { areThemesAttached } from '../highlighter/themes/areThemesAttached';
+import type {
+ DiffsEditableComponent,
+ DiffsEditor,
+ DiffsEditorSelection,
+ DiffsHighlighter,
+ FileContents,
+ HighlightedToken,
+ LineAnnotation,
+ RenderRange,
+} from '../types';
+import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName';
+import {
+ EDITOR_CSS,
+ TOKENIZE_MAX_LINE_LENGTH,
+ TOKENIZE_TIME_LIMIT,
+} from './constants';
+import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations';
+import { isPrimaryModifier } from './platform';
+import { BackgroundTokenizer, tokenizeLine } from './tokenzier';
+
+export class Editor implements DiffsEditor {
+ #disposes?: (() => void)[];
+ #onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void;
+
+ // css properties
+ #charWidth = -1;
+ #lineHeight = 20;
+ #tabSize = 2;
+ #wrap = false;
+
+ // file
+ #component?: DiffsEditableComponent;
+ #fileContents?: FileContents;
+ #lineAnnotations?: LineAnnotation[];
+ #textDocument?: TextDocument;
+
+ // highlighter
+ #highlighter?: DiffsHighlighter;
+ #currentTheme?: string;
+ #colorMap?: string[];
+ #renderRange?: RenderRange;
+ #backgroundTokenizer?: BackgroundTokenizer;
+
+ // cache
+ #stateStackCache?: StateStack[];
+ #lineYCache = new Map();
+ #wrapLineOffsetsCache = new Map();
+ #lastCharX?: [line: number, character: number, x: number, wrapLine: number];
+
+ // dom elements
+ #fileContainer?: HTMLElement;
+ #contentElement?: HTMLElement;
+ #contentElementDisposes?: (() => void)[];
+ #styleElement?: HTMLStyleElement;
+ #overlayElement?: HTMLElement;
+ #selectionElements?: Map;
+ #measureCtx?: CanvasRenderingContext2D;
+ #contentResizeObserver?: ResizeObserver;
+ #lastContentWidth = -1;
+
+ // state
+ #shouldIgnoreSelectionChange = false;
+ #isMouseDown = false;
+ #shiftKeyPressed = false;
+ #selectionStart: EditorSelection | undefined;
+ #reservedSelections?: EditorSelection[];
+ #selections?: EditorSelection[];
+
+ #prebuildStateStackCache = debounce(async () => {
+ const textDocument = this.#textDocument;
+ const highlighter = this.#highlighter;
+ if (textDocument === undefined || highlighter === undefined) {
+ return;
+ }
+
+ if (!highlighter.getLoadedLanguages().includes(textDocument.languageId)) {
+ await highlighter.loadLanguage(textDocument.languageId);
+ }
+
+ const grammar = highlighter.getLanguage(textDocument.languageId);
+ if (grammar === undefined) {
+ return;
+ }
+
+ const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {};
+ const endLine = Math.min(
+ totalLines === Infinity ? Infinity : startingLine + totalLines,
+ textDocument.lineCount
+ );
+
+ this.#buildStateStackCache(textDocument, grammar, endLine);
+ }, 500);
+
+ #emitChange = debounce(
+ (
+ fileContents: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => {
+ this.#onChange?.(fileContents, lineAnnotations);
+ },
+ 500
+ );
+
+ edit(
+ component: DiffsEditableComponent,
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void
+ ): () => void {
+ this.#component = component;
+ this.#onChange = onChange;
+ this.#initialize();
+ if (component.options.useTokenTransformer !== true) {
+ // Tell the component to use token transformer that adds
+ // `data-char` attribute to the tokens
+ component.options.useTokenTransformer = true;
+ component.setOptions(component.options);
+ component.rerender();
+ }
+ component.setEditor(this);
+ return () => this.cleanUp();
+ }
+
+ setSelections(selections: DiffsEditorSelection[]): void {
+ const textDocument = this.#textDocument;
+ if (textDocument !== undefined) {
+ const resolvedSelections = selections.map(
+ (selection) => {
+ const start = textDocument.normalizePosition(selection.start);
+ const end = textDocument.normalizePosition(selection.end);
+ const direction =
+ selection.direction === 'none'
+ ? DirectionNone
+ : selection.direction === 'backward'
+ ? DirectionBackward
+ : DirectionForward;
+ return { direction, start, end };
+ }
+ );
+ this.#updateSelections(resolvedSelections, true);
+ this.#contentElement?.focus();
+ }
+ }
+
+ cleanUp(): void {
+ this.#disposes?.forEach((dispose) => dispose());
+ this.#disposes = undefined;
+ this.#onChange = undefined;
+
+ this.#component?.setSelectedLines(null);
+ this.#component?.removeEditor();
+ this.#component = undefined;
+ this.#fileContents = undefined;
+ this.#lineAnnotations = undefined;
+ this.#textDocument = undefined;
+
+ this.#highlighter = undefined;
+ this.#currentTheme = undefined;
+ this.#colorMap = undefined;
+ this.#renderRange = undefined;
+ this.#backgroundTokenizer?.stop();
+ this.#backgroundTokenizer = undefined;
+
+ this.#stateStackCache = undefined;
+ this.#lineYCache.clear();
+ this.#wrapLineOffsetsCache.clear();
+ this.#lastCharX = undefined;
+
+ this.#fileContainer = undefined;
+ this.#contentElement?.removeAttribute('contentEditable');
+ this.#contentElement = undefined;
+ this.#contentElementDisposes?.forEach((dispose) => dispose());
+ this.#contentElementDisposes = undefined;
+ this.#styleElement?.remove();
+ this.#styleElement = undefined;
+ this.#overlayElement?.remove();
+ this.#overlayElement = undefined;
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#selectionElements = undefined;
+ this.#measureCtx = undefined;
+ this.#contentResizeObserver?.disconnect();
+ this.#contentResizeObserver = undefined;
+ this.#lastContentWidth = -1;
+
+ this.#shouldIgnoreSelectionChange = false;
+ this.#selectionStart = undefined;
+ this.#selections = undefined;
+ this.#reservedSelections = undefined;
+ }
+
+ emitRender(
+ fileContainer: HTMLElement,
+ fileContents: FileContents,
+ lineAnnotations: LineAnnotation[] | undefined,
+ renderRange: RenderRange | undefined
+ ): void {
+ const shadowRoot =
+ fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' });
+ const contentEl =
+ shadowRoot.querySelector('div[data-content]') ?? undefined;
+ if (contentEl === undefined) {
+ throw new Error('Could not edit the file.');
+ }
+
+ this.#wrap = this.#component?.options.overflow === 'wrap';
+ this.#highlighter ??= areThemesAttached(
+ this.#component?.options.theme ?? DEFAULT_THEMES
+ )
+ ? getHighlighterIfLoaded()
+ : undefined;
+
+ if (this.#fileContainer !== fileContainer) {
+ this.#fileContainer = fileContainer;
+ if (this.#styleElement !== undefined) {
+ shadowRoot.appendChild(this.#styleElement);
+ }
+ }
+
+ if (this.#contentElement !== contentEl) {
+ this.#contentElement = extend(contentEl, {
+ contentEditable: 'true',
+ role: 'textbox',
+ ariaMultiLine: 'true',
+ autocapitalize: 'off',
+ writingSuggestions: 'off',
+ autocorrect: false,
+ spellcheck: false,
+ translate: false,
+ });
+ if (this.#overlayElement !== undefined) {
+ contentEl.after(this.#overlayElement);
+ }
+ this.#contentElementDisposes?.forEach((dispose) => dispose());
+ this.#contentElementDisposes = [
+ addEventListener(
+ contentEl,
+ 'keydown',
+ (e) => {
+ const command = resolveEditorCommandFromKeyboardEvent(e);
+ if (command !== undefined) {
+ e.preventDefault();
+ this.#runCommand(command);
+ }
+ },
+ { passive: false }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'copy',
+ (e) => {
+ e.preventDefault();
+ e.clipboardData?.setData('text', this.#getSelectionText());
+ },
+ { passive: false }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'cut',
+ (e) => {
+ e.preventDefault();
+ e.clipboardData?.setData('text', this.#getSelectionText());
+ this.#replaceSelectionText('');
+ },
+ { passive: false }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'paste',
+ (e) => {
+ e.preventDefault();
+ const text = e.clipboardData?.getData('text');
+ if (text !== undefined) {
+ // TODO(@ije): Add support of multiple selections paste
+ // TODO(@ije): normalize the pasted text with textDocument.EOF
+ this.#replaceSelectionText(text);
+ }
+ },
+ { passive: false }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'beforeinput',
+ (e) => {
+ e.preventDefault();
+ this.#handleInput(e.inputType, e.data);
+ },
+ { passive: false }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'compositionstart',
+ () => {
+ this.#shouldIgnoreSelectionChange = true;
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'compositionend',
+ (e) => {
+ this.#shouldIgnoreSelectionChange = false;
+ this.#handleInput('insertText', e.data);
+ },
+ { passive: true }
+ ),
+ ];
+
+ this.#contentResizeObserver?.disconnect();
+ this.#contentResizeObserver = new ResizeObserver(() => {
+ this.#handleLayoutResize();
+ });
+ this.#contentResizeObserver.observe(contentEl);
+ if (contentEl.parentElement !== null) {
+ this.#contentResizeObserver.observe(contentEl.parentElement);
+ }
+ }
+
+ // measure the font width, line height, and tab size
+ // purge the lineY cache if the line height or line annotations change
+ const style = getComputedStyle(contentEl);
+ const { fontSize, fontFamily, tabSize, lineHeight } = style;
+ let lineHeighPx = 20;
+ if (lineHeight.endsWith('px')) {
+ lineHeighPx = Number(lineHeight.slice(0, -2));
+ } else if (fontSize.endsWith('px')) {
+ lineHeighPx = round(
+ Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2))
+ );
+ }
+ this.#lastCharX = undefined;
+ this.#lineHeight = lineHeighPx;
+ this.#tabSize = Number(tabSize);
+ this.#wrap = this.#component?.options.overflow === 'wrap';
+ this.#lastContentWidth = this.#getContentWidth();
+ this.#measureCtx ??=
+ document.createElement('canvas').getContext('2d') ?? undefined;
+ const font = fontSize + ' ' + fontFamily;
+ if (
+ this.#measureCtx !== undefined &&
+ (this.#measureCtx.font !== font || this.#charWidth === -1)
+ ) {
+ this.#measureCtx.font = font;
+ this.#charWidth = round(this.#measureCtx.measureText('0').width);
+ }
+
+ if (
+ this.#textDocument === undefined ||
+ this.#fileContents === undefined ||
+ this.#fileContents.name !== fileContents.name ||
+ this.#fileContents.lang !== fileContents.lang ||
+ this.#fileContents.contents !== fileContents.contents
+ ) {
+ this.#fileContents = fileContents;
+ this.#textDocument = new TextDocument(
+ fileContents.name,
+ fileContents.contents,
+ fileContents.lang ?? getFiletypeFromFileName(fileContents.name)
+ );
+ this.#stateStackCache = undefined;
+ this.#shouldIgnoreSelectionChange = false;
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#component?.setSelectedLines(null);
+ this.#selectionElements = undefined;
+ this.#selections = undefined;
+ this.#reservedSelections = undefined;
+ }
+
+ this.#lineYCache.clear();
+ this.#wrapLineOffsetsCache.clear();
+ this.#lastCharX = undefined;
+
+ this.#lineAnnotations = lineAnnotations;
+ this.#renderRange = renderRange;
+ this.#prebuildStateStackCache();
+
+ if (this.#selections !== undefined && this.#selections.length > 0) {
+ this.#updateSelections(this.#selections, true);
+ }
+
+ if (renderRange !== undefined) {
+ console.log(
+ '[diffs] render file:',
+ fileContents.name,
+ 'RenderRange:',
+ renderRange.startingLine +
+ '-' +
+ Math.min(
+ renderRange.startingLine + renderRange.totalLines,
+ this.#textDocument.lineCount
+ ),
+ 'of',
+ this.#textDocument.lineCount,
+ 'lines'
+ );
+ }
+ }
+
+ #initialize(): void {
+ this.#styleElement = createElement('style', {
+ dataset: 'editorCss',
+ textContent: EDITOR_CSS,
+ });
+
+ this.#overlayElement = createElement('div', {
+ dataset: 'editorOverlay',
+ });
+
+ this.#disposes = [
+ addEventListener(
+ document,
+ 'selectionchange',
+ () => {
+ if (this.#shouldIgnoreSelectionChange) {
+ return;
+ }
+
+ const shadowRoot = this.#contentElement?.getRootNode();
+ if (shadowRoot === undefined || !(shadowRoot instanceof ShadowRoot)) {
+ return;
+ }
+
+ const selectionRaw = document.getSelection();
+ const composedRange = selectionRaw?.getComposedRanges({
+ shadowRoots: [shadowRoot],
+ })?.[0];
+
+ if (
+ composedRange === undefined ||
+ !this.#rangeBelongsToEditor(composedRange)
+ ) {
+ return;
+ }
+
+ let selection = convertSelection(composedRange, DirectionNone);
+ if (selection === undefined) {
+ return;
+ }
+
+ if (
+ this.#isMouseDown &&
+ this.#shiftKeyPressed &&
+ this.#selections !== undefined &&
+ this.#selections.length > 0
+ ) {
+ const primarySelection = this.#selections.at(-1)!;
+ // before shift + click, the window selection has been cleared,
+ // so we need to set the window selection manually with the new
+ // selection
+ this.#updateSelections(
+ [extendSelection(primarySelection, selection)],
+ true
+ );
+ return;
+ }
+
+ if (this.#isMouseDown) {
+ if (this.#selectionStart !== undefined) {
+ selection = createSelectionFrom(this.#selectionStart, selection);
+ } else {
+ this.#selectionStart = selection;
+ }
+ } else if (this.#selectionStart !== undefined) {
+ selection.direction = createSelectionFrom(
+ this.#selectionStart,
+ selection
+ ).direction;
+ }
+
+ if (this.#reservedSelections !== undefined) {
+ this.#updateSelections([
+ ...this.#reservedSelections.filter(
+ (reservedSelection) =>
+ !selectionIntersects(reservedSelection, selection)
+ ),
+ selection,
+ ]);
+ } else {
+ if (
+ this.#isMouseDown ||
+ this.#selections === undefined ||
+ this.#selections.length === 0 ||
+ this.#textDocument === undefined
+ ) {
+ this.#updateSelections([selection]);
+ } else {
+ // The selection change is triggered by the keyboard
+ // For example, moving the cursor by arrow keys.
+ if (isCollapsedSelection(selection)) {
+ this.#updateSelections(
+ mapCursorMove(
+ this.#textDocument,
+ this.#selections,
+ selection.start
+ )
+ );
+ } else {
+ // shift key is pressed when moving the cursor by
+ const newSelections = mapSelectionShift(
+ this.#textDocument,
+ this.#selections,
+ selection
+ );
+ const hasMergedSelections =
+ newSelections.length !== this.#selections.length;
+ this.#updateSelections(newSelections, false);
+ if (hasMergedSelections) {
+ this.#updateWindowSelection(newSelections.at(-1)!);
+ }
+ }
+ }
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ document,
+ 'mousedown',
+ (e) => {
+ const target = e.composedPath()[0];
+ if (target === undefined || !(target instanceof HTMLElement)) {
+ return;
+ }
+ const { tagName, dataset } = target;
+ if (
+ !(
+ (tagName === 'DIV' && dataset.line !== undefined) ||
+ (tagName === 'SPAN' && dataset.char !== undefined)
+ )
+ ) {
+ return;
+ }
+
+ this.#isMouseDown = true;
+ this.#selectionStart = undefined;
+ if (e.button === 0 && isPrimaryModifier(e)) {
+ this.#reservedSelections = this.#selections?.map((selection) => ({
+ ...selection,
+ }));
+ }
+ if (e.shiftKey) {
+ window.getSelection()?.empty();
+ this.#shiftKeyPressed = true;
+ } else {
+ this.#selections = undefined;
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ document,
+ 'mouseup',
+ () => {
+ this.#isMouseDown = false;
+ this.#shiftKeyPressed = false;
+ this.#selectionStart = undefined;
+ this.#reservedSelections = undefined;
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ document,
+ 'keydown',
+ (e) => {
+ if (e.key === 'Shift') {
+ this.#selectionStart = this.#selections?.at(-1);
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ document,
+ 'keyup',
+ (e) => {
+ if (e.key === 'Shift') {
+ this.#selectionStart = undefined;
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ window,
+ 'resize',
+ () => {
+ this.#handleLayoutResize();
+ },
+ { passive: true }
+ ),
+ ];
+ }
+
+ #runCommand(command: EditorCommand) {
+ const textDocument = this.#textDocument;
+ if (textDocument === undefined) {
+ return;
+ }
+
+ switch (command) {
+ case 'selectAll':
+ this.#updateSelections([getDocumentFullSelection(textDocument)]);
+ break;
+
+ case 'findNextMatch': {
+ const selections = this.#selections;
+ const textDocument = this.#textDocument;
+ if (selections === undefined || textDocument === undefined) {
+ break;
+ }
+ const hasCollapsed = selections.some(isCollapsedSelection);
+ if (hasCollapsed) {
+ const expanded: EditorSelection[] = selections.map((sel) => {
+ if (isCollapsedSelection(sel)) {
+ return expandCollapsedSelectionToWord(textDocument, sel);
+ }
+ return sel;
+ });
+ this.#updateSelections(expanded, true);
+ } else {
+ const nextMatch = findNexMatch(textDocument, selections);
+ if (nextMatch !== undefined) {
+ this.#updateSelections(nextMatch, true);
+ }
+ }
+ break;
+ }
+
+ case 'indent':
+ case 'outdent':
+ if (this.#selections !== undefined) {
+ const edits: TextEdit[] = [];
+ const nextSelections: EditorSelection[] = [];
+ for (const selection of this.#selections) {
+ const startLine = selection.start.line;
+ const outdent = command === 'outdent';
+ if (startLine !== selection.end.line || outdent) {
+ const ret = resolveIndentEdits(
+ textDocument,
+ selection,
+ this.#tabSize,
+ outdent
+ );
+ edits.push(...ret[0]);
+ nextSelections.push(ret[1]);
+ } else {
+ const lineChar0 = textDocument.charAt({
+ line: startLine,
+ character: 0,
+ });
+ this.#replaceSelectionText(
+ lineChar0 === '\t' ? '\t' : ' '.repeat(this.#tabSize)
+ );
+ }
+ }
+ if (edits.length > 0) {
+ const change = textDocument.applyEdits(
+ edits,
+ true,
+ this.#selections,
+ nextSelections
+ );
+ if (change !== undefined) {
+ this.#applyChange(change, nextSelections);
+ }
+ }
+ }
+ break;
+
+ case 'moveCursorToDocStart':
+ case 'moveCursorToDocEnd':
+ {
+ const atEnd = command === 'moveCursorToDocEnd';
+ const anchor = createElement('span');
+ const root = this.#contentElement?.getRootNode() as
+ | Element
+ | undefined;
+ this.#updateSelections(
+ [getDocumentBoundarySelection(textDocument, atEnd)],
+ true
+ );
+ if (root !== undefined) {
+ if (atEnd) {
+ root.appendChild(anchor);
+ } else {
+ root.prepend(anchor);
+ }
+ anchor.scrollIntoView({ block: atEnd ? 'end' : 'start' });
+ requestAnimationFrame(() => {
+ anchor.remove();
+ });
+ }
+ }
+ break;
+
+ case 'undo':
+ if (this.#textDocument?.canUndo === true) {
+ const undoResult = this.#textDocument.undo();
+ if (undoResult !== undefined) {
+ this.#applyChange(...undoResult);
+ }
+ }
+ break;
+
+ case 'redo':
+ if (this.#textDocument?.canRedo === true) {
+ const redoResult = this.#textDocument.redo();
+ if (redoResult !== undefined) {
+ this.#applyChange(...redoResult);
+ }
+ }
+ break;
+ }
+ }
+
+ #handleLayoutResize() {
+ const contentWidth = this.#getContentWidth();
+ const widthChanged = contentWidth !== this.#lastContentWidth;
+ this.#lastContentWidth = contentWidth;
+ if (this.#wrap && widthChanged) {
+ this.#lineYCache.clear();
+ this.#lastCharX = undefined;
+ this.#wrapLineOffsetsCache.clear();
+ if (this.#selections !== undefined) {
+ this.#updateSelections(this.#selections);
+ }
+ }
+ }
+
+ #rerender(
+ change: TextDocumentChange,
+ nextLineAnnotations?: LineAnnotation[] | undefined
+ ) {
+ // cancel existing background tokenzier task
+ this.#backgroundTokenizer?.stop();
+
+ const highlighter = this.#highlighter;
+ const file = this.#component;
+ const fileContents = this.#fileContents;
+ const textDocument = this.#textDocument;
+ const contentEl = this.#contentElement;
+ const gutterEl = this.#contentElement?.previousElementSibling ?? undefined;
+ if (
+ highlighter === undefined ||
+ file === undefined ||
+ fileContents === undefined ||
+ textDocument === undefined ||
+ contentEl === undefined ||
+ gutterEl === undefined ||
+ !(gutterEl instanceof HTMLElement) ||
+ gutterEl.dataset.gutter === undefined
+ ) {
+ return;
+ }
+
+ const t = performance.now();
+ const grammar = highlighter.getLanguage(textDocument.languageId);
+ const themeType = this.#getThemeType();
+ const colorMap = this.#getThemeColorMap(themeType);
+ const stateStackCache = this.#buildStateStackCache(
+ textDocument,
+ grammar,
+ change.startLine
+ );
+
+ const { lineCount } = textDocument;
+ const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {};
+ const renderRangeEndLine =
+ totalLines === Infinity
+ ? lineCount
+ : Math.min(startingLine + totalLines, lineCount);
+
+ let line = change.startLine;
+ let state = stateStackCache[line];
+ let settled = false;
+ let dirtyLines: Map> = new Map();
+ for (; line < renderRangeEndLine; line++) {
+ const lineText = textDocument.getLineText(line);
+
+ stateStackCache[line] = state;
+
+ if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) {
+ console.warn(
+ `[diffs] Line(${line}) too long to tokenize: ${lineText.length}`
+ );
+ dirtyLines.set(line, [[0, '', lineText]]);
+ } else if (lineText === '' || lineText.trim() === '') {
+ dirtyLines.set(line, [[0, '', lineText === '' ? ' ' : lineText]]);
+ } else {
+ const result = tokenizeLine(
+ grammar,
+ colorMap,
+ lineText,
+ state,
+ TOKENIZE_TIME_LIMIT
+ );
+ dirtyLines.set(line, result.resolvedTokens);
+ state = result.ruleStack;
+ }
+
+ settled =
+ line >= change.endLine &&
+ change.lineDelta === 0 &&
+ stateStackCache[line + 1] !== undefined &&
+ state.equals(stateStackCache[line + 1]);
+ if (settled) {
+ break;
+ }
+ }
+ if (line < renderRangeEndLine) {
+ stateStackCache[line + 1] = state;
+ } else {
+ stateStackCache[line] = state;
+ }
+
+ // Invalidate layout caches touched by the edit.
+ // - line inserts/deletes shift line numbers, so clear from startLine onward
+ // - wrapped edits can change visual height, which shifts downstream line Y
+ if (change.lineDelta !== 0) {
+ for (const line of this.#lineYCache.keys()) {
+ if (line >= change.startLine) {
+ this.#lineYCache.delete(line);
+ }
+ }
+ }
+ if (this.#wrap) {
+ for (const line of this.#wrapLineOffsetsCache.keys()) {
+ if (line >= change.startLine) {
+ this.#wrapLineOffsetsCache.delete(line);
+ }
+ }
+ }
+
+ if (dirtyLines.size > 0) {
+ const children = contentEl.children;
+ const dirtyLineIndexes = new Set(dirtyLines.keys());
+
+ // update line elements that have been changed in the document
+ for (let i = change.startLine - startingLine; i < children.length; i++) {
+ if (dirtyLineIndexes.size === 0) {
+ break;
+ }
+ const child = children[i] as HTMLElement | undefined;
+ if (child?.dataset.lineIndex !== undefined) {
+ const lineIndex = Number(child.dataset.lineIndex);
+ if (dirtyLines.has(lineIndex)) {
+ const tokens = dirtyLines.get(lineIndex)!;
+ child.replaceChildren(
+ ...tokens.map(([char, fg, textContent]) => {
+ if (char === 0 && fg === '') {
+ return document.createTextNode(textContent);
+ }
+ return createElement('span', {
+ dataset: {
+ char: char.toString(),
+ },
+ style: `--diffs-token-${themeType}:${fg};`,
+ textContent: textContent,
+ });
+ })
+ );
+ dirtyLineIndexes.delete(lineIndex);
+ }
+ }
+ }
+
+ // create new line elements for new lines
+ if (dirtyLineIndexes.size > 0) {
+ for (const lineIndex of dirtyLineIndexes) {
+ const tokens = dirtyLines.get(lineIndex)!;
+ const lineNumber = String(lineIndex + 1);
+ createElement(
+ 'div',
+ {
+ dataset: {
+ line: lineNumber,
+ lineType: 'context',
+ lineIndex: lineIndex.toString(),
+ },
+ // oxlint-disable-next-line react/no-children-prop
+ children: tokens.map(([char, fg, textContent]) => {
+ if (char === 0 && fg === '') {
+ return document.createTextNode(textContent);
+ }
+ return createElement('span', {
+ dataset: {
+ char: char.toString(),
+ },
+ style: `--diffs-token-${themeType}:${fg};`,
+ textContent,
+ });
+ }),
+ },
+ contentEl
+ );
+ createElement(
+ 'div',
+ {
+ dataset: {
+ lineType: 'context',
+ columnNumber: lineNumber,
+ lineIndex: lineIndex.toString(),
+ },
+ // oxlint-disable-next-line react/no-children-prop
+ children: [
+ createElement('span', {
+ dataset: {
+ lineNumberContent: '',
+ },
+ textContent: lineNumber,
+ }),
+ ],
+ },
+ gutterEl
+ );
+ }
+ }
+ }
+
+ // remove line elements that have been deleted in the document
+ if (change.lineDelta < 0) {
+ for (const parent of [contentEl, gutterEl]) {
+ const children = parent.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ const child = children[i] as HTMLElement;
+ const { lineIndex, lineAnnotation } = child.dataset;
+ if (lineIndex !== undefined || lineAnnotation !== undefined) {
+ const lineIndexNum = Number(
+ lineAnnotation !== undefined
+ ? lineAnnotation.split(',')[1]
+ : lineIndex
+ );
+ if (lineIndexNum < change.lineCount) {
+ break;
+ }
+ child.remove();
+ }
+ }
+ }
+ }
+
+ file.emitDirtyLines(themeType, dirtyLines);
+ if (change.lineDelta !== 0) {
+ gutterEl.style.gridRow = 'span ' + gutterEl.children.length;
+ contentEl.style.gridRow = 'span ' + gutterEl.children.length;
+ file.emitLineCountChange(change.lineCount, nextLineAnnotations);
+ }
+
+ if (!settled && line < lineCount) {
+ requestAnimationFrame(() => {
+ this.#backgroundTokenizer = new BackgroundTokenizer({
+ grammar,
+ colorMap,
+ textDocument,
+ onTokenize: (lines) => {
+ file.emitDirtyLines(themeType, lines);
+ },
+ });
+ this.#backgroundTokenizer.scheduleTokenize(line, state);
+ });
+ // TODO(@ije): should add another background tokenzier for the other theme?
+ }
+
+ console.log(
+ `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`,
+ 'lastChange:',
+ change,
+ 'dirtyLines:',
+ dirtyLines.size,
+ settled ? '(settled)' : ''
+ );
+ }
+
+ #getThemeType(): 'dark' | 'light' {
+ const { themeType } = this.#component?.options ?? {};
+ if (themeType !== undefined && themeType !== 'system') {
+ return themeType;
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light';
+ }
+
+ #getThemeColorMap(themeType: 'dark' | 'light'): string[] {
+ if (this.#highlighter === undefined || this.#component === undefined) {
+ throw new Error('editor not initialized');
+ }
+ let themeName: string;
+ const { theme = DEFAULT_THEMES } = this.#component.options;
+ if (typeof theme === 'string') {
+ themeName = theme;
+ } else {
+ themeName = theme[themeType];
+ }
+ if (this.#currentTheme !== themeName || this.#colorMap === undefined) {
+ const ret = this.#highlighter.setTheme(themeName);
+ this.#colorMap = ret.colorMap;
+ this.#currentTheme = themeName;
+ }
+ return this.#colorMap;
+ }
+
+ #buildStateStackCache(
+ textDocument: TextDocument,
+ grammar: IGrammar,
+ endLine: number
+ ): StateStack[] {
+ const stateStackCache = (this.#stateStackCache ??= [INITIAL]);
+ const boundedEndLine = Math.min(
+ Math.max(0, endLine),
+ textDocument.lineCount
+ );
+ let line = Math.min(stateStackCache.length - 1, boundedEndLine);
+ let state = stateStackCache[line] ?? INITIAL;
+ for (; line < boundedEndLine; line++) {
+ stateStackCache[line] = state;
+ const lineText = textDocument.getLineText(line);
+ if (
+ lineText.length <= TOKENIZE_MAX_LINE_LENGTH &&
+ lineText !== '' &&
+ lineText.trim() !== ''
+ ) {
+ state = grammar.tokenizeLine2(
+ lineText,
+ state,
+ TOKENIZE_TIME_LIMIT
+ ).ruleStack;
+ }
+ }
+ stateStackCache[line] = state;
+ return stateStackCache;
+ }
+
+ #handleInput(inputType: string, data: string | null) {
+ switch (inputType) {
+ case 'insertText':
+ this.#replaceSelectionText(data ?? '');
+ break;
+ case 'deleteContentBackward':
+ this.#deleteSelectionText();
+ break;
+ case 'deleteContentForward':
+ this.#deleteSelectionText(true);
+ break;
+ case 'insertParagraph':
+ // TODO(@ije): use document.EOF instead of '\n'
+ this.#replaceSelectionText('\n');
+ break;
+ default:
+ console.warn(`[diffs] Unknown input type: ${inputType}`);
+ break;
+ }
+ }
+
+ #updateSelections(
+ selections: EditorSelection[],
+ updateWindowSelection: boolean = false
+ ) {
+ const primarySelection = selections.at(-1);
+ if (primarySelection === undefined) {
+ return;
+ }
+ this.#selections = selections;
+ this.#component?.setSelectedLines(null);
+ if (isCollapsedSelection(primarySelection)) {
+ const line = primarySelection.end.line + 1;
+ this.#component?.setSelectedLines({
+ start: line,
+ end: line,
+ });
+ }
+ const fragment = document.createDocumentFragment();
+ const renderCtx = {
+ fragment,
+ elements: new Map(),
+ };
+ selections.forEach((selection) => {
+ if (selections.length > 1 || !isCollapsedSelection(selection)) {
+ this.#renderSelection(renderCtx, selection);
+ }
+ this.#renderCaret(renderCtx, selection);
+ });
+ this.#overlayElement?.appendChild(fragment);
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#selectionElements = renderCtx.elements;
+ if (updateWindowSelection) {
+ this.#updateWindowSelection(primarySelection);
+ }
+ }
+
+ #updateWindowSelection(primarySelection: EditorSelection) {
+ const winSelection = window.getSelection();
+ if (winSelection === null) {
+ return;
+ }
+ let { start, end, direction } = primarySelection;
+ if (comparePosition(start, end) > 0) {
+ [start, end] = [end, start];
+ }
+ const startLineElement = this.#getLineElement(start.line);
+ const endLineElement = this.#getLineElement(end.line);
+ if (startLineElement === undefined || endLineElement === undefined) {
+ return;
+ }
+ let [anchorNode, anchorOffset] = getSelectionTextNode(
+ startLineElement,
+ start.character
+ );
+ let [focusNode, focusOffset] = getSelectionTextNode(
+ endLineElement,
+ end.character
+ );
+ if (direction === DirectionBackward) {
+ [anchorNode, anchorOffset, focusNode, focusOffset] = [
+ focusNode,
+ focusOffset,
+ anchorNode,
+ anchorOffset,
+ ];
+ }
+ this.#shouldIgnoreSelectionChange = true;
+ winSelection.setBaseAndExtent(
+ anchorNode,
+ anchorOffset,
+ focusNode,
+ focusOffset
+ );
+ setTimeout(() => {
+ this.#shouldIgnoreSelectionChange = false;
+ }, 0);
+ }
+
+ #renderSelection(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection
+ ) {
+ if (this.#textDocument === undefined) {
+ return;
+ }
+
+ const { start, end } = selection;
+
+ for (let ln = start.line; ln <= end.line; ln++) {
+ if (!this.#isLineVisible(ln)) {
+ continue;
+ }
+
+ const lineText = this.#textDocument.getLineText(ln);
+ const startChar = ln === start.line ? start.character : 0;
+ const endChar = ln === end.line ? end.character : lineText.length;
+
+ if (this.#wrap) {
+ const paddingInline = this.#charWidth; // 1ch, align to diff css: padding-inline: 1ch
+ const contentWidth = this.#getContentWidth();
+ const textWidth = 2 * paddingInline + this.#measureTextWidth(lineText);
+ if (textWidth > contentWidth) {
+ this.#renderWrappedSelection(
+ renderCtx,
+ selection,
+ ln,
+ lineText,
+ startChar,
+ endChar,
+ paddingInline
+ );
+ continue;
+ }
+ }
+
+ let left = 0;
+ let width = 0;
+ if (startChar === endChar && startChar === 0) {
+ left = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch)
+ width = ln === end.line ? 0 : this.#charWidth;
+ } else {
+ left = this.#getCharX(ln, startChar)[0];
+ width =
+ endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left;
+ }
+ this.#renderSelectionRange(
+ renderCtx,
+ selection,
+ ln,
+ 0,
+ startChar,
+ endChar,
+ width,
+ left
+ );
+ }
+ }
+
+ // Render the selection on a wrapped logical line by splitting it into one
+ // selection-range div per visual sub-line. For each wrap segment, we compute
+ // the intersection with the line's selection range and render the slice in
+ // segment-local coordinates so left/width line up with the visually wrapped
+ // text. Zero-width slices that fall on intermediate segment boundaries are
+ // skipped to avoid duplicate markers across consecutive visual lines.
+ #renderWrappedSelection(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection,
+ line: number,
+ lineText: string,
+ startChar: number,
+ endChar: number,
+ paddingInline: number
+ ) {
+ const wrapOffsets = this.#wrapLineText(line);
+ const segmentCount = wrapOffsets.length - 1;
+ const lastSegmentIndex = segmentCount - 1;
+ const offsetLeft = this.#getGutterWidth() + paddingInline;
+
+ for (let w = 0; w < segmentCount; w++) {
+ const segmentStart = wrapOffsets[w];
+ const segmentEnd = wrapOffsets[w + 1];
+ const wrapStartChar = Math.max(startChar, segmentStart);
+ const wrapEndChar = Math.min(endChar, segmentEnd);
+
+ // Selection range doesn't reach this visual segment.
+ if (wrapStartChar > wrapEndChar) {
+ continue;
+ }
+
+ // Zero-width slices on segment boundaries can appear on two consecutive
+ // segments (end of one, start of the next). Only render at the natural
+ // anchor positions: the very beginning of the first visual line, or the
+ // very end of the last visual line.
+ if (wrapStartChar === wrapEndChar) {
+ const isAtLineStart = wrapStartChar === 0 && w === 0;
+ const isAtLineEnd =
+ wrapEndChar === lineText.length && w === lastSegmentIndex;
+ if (!isAtLineStart && !isAtLineEnd) {
+ continue;
+ }
+ }
+
+ let segmentLeft: number;
+ let segmentWidth: number;
+ if (wrapStartChar === 0 && wrapEndChar === 0) {
+ // Empty range pinned to line start (e.g. multi-line selection ending
+ // with end.character === 0). Mirrors the non-wrap path.
+ segmentLeft = offsetLeft;
+ segmentWidth = line === selection.end.line ? 0 : paddingInline;
+ } else {
+ const prefixInSegment = lineText.slice(segmentStart, wrapStartChar);
+ const prefixAsciiWidth =
+ this.#getExpandedAsciiTextWidth(prefixInSegment);
+ segmentLeft =
+ offsetLeft +
+ (prefixAsciiWidth !== -1
+ ? prefixAsciiWidth
+ : this.#measureTextWidth(prefixInSegment));
+
+ if (wrapStartChar === wrapEndChar) {
+ segmentWidth = 0;
+ } else {
+ const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar);
+ const selectionAsciiWidth =
+ this.#getExpandedAsciiTextWidth(selectionInSegment);
+ segmentWidth =
+ selectionAsciiWidth !== -1
+ ? selectionAsciiWidth
+ : this.#measureTextWidth(selectionInSegment);
+ }
+ }
+
+ this.#renderSelectionRange(
+ renderCtx,
+ selection,
+ line,
+ w,
+ wrapStartChar,
+ wrapEndChar,
+ segmentWidth,
+ segmentLeft,
+ w === lastSegmentIndex
+ );
+ }
+ }
+
+ // Render one selection range div for a single visual line. `applyEolSpacing`
+ // controls whether the trailing one-character "line continuation" marker is
+ // appended at the end. For wrapped logical lines this must be false on every
+ // visual segment except the last one, since an intra-line wrap is not a real
+ // newline and shouldn't visually extend past the wrapped content.
+ #renderSelectionRange(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection,
+ ln: number,
+ wrapLine: number,
+ startChar: number,
+ endChar: number,
+ width: number,
+ left: number,
+ applyEolSpacing = true
+ ) {
+ const spacing =
+ !applyEolSpacing ||
+ selection.end.line === ln ||
+ (startChar === endChar && ln !== selection.start.line)
+ ? 0
+ : this.#charWidth;
+ const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`;
+ const cacheKey = 'selection-range-' + css;
+ const selectionEls = this.#selectionElements;
+
+ if (renderCtx.elements.has(cacheKey)) {
+ return;
+ }
+
+ let rangeEl: HTMLElement | undefined;
+ if (selectionEls?.has(cacheKey) === true) {
+ rangeEl = selectionEls.get(cacheKey)!;
+ selectionEls.delete(cacheKey);
+ } else {
+ rangeEl = createElement(
+ 'div',
+ {
+ dataset: 'selectionRange',
+ style: { cssText: css },
+ },
+ renderCtx.fragment
+ );
+ }
+
+ renderCtx.elements.set(cacheKey, rangeEl);
+ }
+
+ #renderCaret(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection
+ ) {
+ const { start, end, direction } = selection;
+ const isBackward = direction === DirectionBackward;
+ const line = isBackward ? start.line : end.line;
+ const character = isBackward ? start.character : end.character;
+ if (!this.#isLineVisible(line)) {
+ return;
+ }
+ const [left, wrapLine] = this.#getCharX(line, character);
+ const cacheKey = 'caret-' + line + '-' + character;
+ if (renderCtx.elements.has(cacheKey)) {
+ return;
+ }
+ const caretEl = createElement(
+ 'div',
+ {
+ dataset: 'caret',
+ style: {
+ transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`,
+ },
+ },
+ renderCtx.fragment
+ );
+ renderCtx.elements.set(cacheKey, caretEl);
+ }
+
+ #getSelectionText() {
+ const textDocument = this.#textDocument;
+ const selections = this.#selections;
+ if (textDocument === undefined || selections === undefined) {
+ return '';
+ }
+ return getSelectionText(textDocument, selections);
+ }
+
+ // replace the selection text
+ #replaceSelectionText(text: string | string[]) {
+ const selections = this.#selections;
+ if (selections === undefined) {
+ return;
+ }
+ const textDocument = this.#textDocument;
+ const primarySelection = selections.at(-1);
+ if (textDocument == null || primarySelection == null) {
+ return;
+ }
+ const lineAnnotations = this.#lineAnnotations;
+ const { nextSelections, change } =
+ Array.isArray(text) && text.length === selections.length
+ ? applyTextReplaceToSelections(
+ textDocument,
+ selections,
+ text,
+ lineAnnotations
+ )
+ : applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: textDocument.offsetAt(primarySelection.start),
+ end: textDocument.offsetAt(primarySelection.end),
+ text: Array.isArray(text) ? text.join('\n') : text,
+ },
+ lineAnnotations
+ );
+
+ if (change !== undefined) {
+ this.#applyChange(
+ change,
+ nextSelections,
+ this.#applyChangeToLineAnnotations(change)
+ );
+ }
+ }
+
+ #deleteSelectionText(forward: boolean = false) {
+ const selections = this.#selections;
+ const textDocument = this.#textDocument;
+ if (selections === undefined || textDocument === undefined) {
+ return;
+ }
+
+ const primarySelection = selections.at(-1);
+ if (primarySelection === undefined) {
+ return;
+ }
+
+ const edit = isCollapsedSelection(primarySelection)
+ ? (() => {
+ const offset = textDocument.offsetAt(primarySelection.start);
+ const nextOffset = forward
+ ? Math.min(textDocument.getText().length, offset + 1)
+ : Math.max(0, offset - 1);
+ return {
+ start: Math.min(offset, nextOffset),
+ end: Math.max(offset, nextOffset),
+ text: '',
+ };
+ })()
+ : {
+ start: textDocument.offsetAt(primarySelection.start),
+ end: textDocument.offsetAt(primarySelection.end),
+ text: '',
+ };
+
+ const { nextSelections, change } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ edit,
+ this.#lineAnnotations,
+ this.#tabSize
+ );
+
+ if (change !== undefined) {
+ this.#applyChange(
+ change,
+ nextSelections,
+ this.#applyChangeToLineAnnotations(change)
+ );
+ }
+ }
+
+ #applyChange(
+ change: TextDocumentChange,
+ selections?: EditorSelection[],
+ lineAnnotations?: LineAnnotation[]
+ ) {
+ const fileContents = this.#fileContents;
+ const textDocument = this.#textDocument;
+ const onChange = this.#onChange;
+ if (
+ fileContents !== undefined &&
+ textDocument !== undefined &&
+ onChange !== undefined
+ ) {
+ const { contents: _, ...file } = fileContents;
+ Object.defineProperty(file, 'contents', {
+ get() {
+ return textDocument.getText();
+ },
+ });
+ this.#emitChange(
+ file as FileContents,
+ lineAnnotations ?? this.#lineAnnotations
+ );
+ }
+ this.#selections = selections;
+ this.#rerender(change, lineAnnotations);
+ if (this.#selections !== undefined) {
+ // since we prevent the default input event,
+ // we need to update the window selection manually
+ this.#updateSelections(this.#selections, true);
+ }
+ }
+
+ #applyChangeToLineAnnotations(
+ change: TextDocumentChange
+ ): LineAnnotation[] | undefined {
+ if (this.#lineAnnotations !== undefined) {
+ const nextLineAnnotations =
+ applyDocumentChangeToLineAnnotations(
+ change,
+ this.#lineAnnotations
+ );
+ if (nextLineAnnotations !== this.#lineAnnotations) {
+ this.#textDocument?.setLastUndoLineAnnotationsAfter(
+ nextLineAnnotations
+ );
+ return nextLineAnnotations;
+ }
+ }
+ return undefined;
+ }
+
+ #getLineElement(line: number): HTMLElement | undefined {
+ const children = this.#contentElement?.children;
+ if (children === undefined) {
+ return undefined;
+ }
+ const { startingLine = 0 } = this.#renderRange ?? {};
+ for (let i = line - startingLine; i <= children.length; i++) {
+ const child = children[i] as HTMLElement | undefined;
+ if (
+ child !== undefined &&
+ child.dataset.lineIndex !== undefined &&
+ Number(child.dataset.lineIndex) === line
+ ) {
+ return child;
+ }
+ }
+ return undefined;
+ }
+
+ #getGutterWidth() {
+ const diffsColumnNumbertWidth =
+ this.#contentElement?.parentElement?.style.getPropertyValue(
+ '--diffs-column-number-width'
+ ) ?? '';
+ if (
+ diffsColumnNumbertWidth.length > 2 &&
+ diffsColumnNumbertWidth.endsWith('px')
+ ) {
+ return Number(diffsColumnNumbertWidth.slice(0, -2));
+ }
+ const gutterElement =
+ this.#contentElement?.previousElementSibling ?? undefined;
+ if (
+ gutterElement === undefined ||
+ !gutterElement.hasAttribute('data-gutter')
+ ) {
+ return 0;
+ }
+ return (gutterElement as HTMLElement).offsetWidth ?? 0;
+ }
+
+ #getContentWidth() {
+ const diffsColumnContentWidth =
+ this.#contentElement?.parentElement?.style.getPropertyValue(
+ '--diffs-column-content-width'
+ ) ?? '';
+ if (
+ diffsColumnContentWidth.length > 2 &&
+ diffsColumnContentWidth.endsWith('px')
+ ) {
+ return Number(diffsColumnContentWidth.slice(0, -2));
+ }
+ return this.#contentElement?.offsetWidth ?? 0;
+ }
+
+ // get line top position
+ #getLineY(line: number) {
+ const cachedY = this.#lineYCache.get(line);
+ if (cachedY !== undefined) {
+ return cachedY;
+ }
+
+ // cold(slow) path: measure line top position from DOM causes reflow
+ const y = this.#getLineElement(line)?.offsetTop ?? 0;
+ this.#lineYCache.set(line, y);
+ return y;
+ }
+
+ // Return the visual position for a character. Wrapped lines include the
+ // visual line index so carets can be placed on the correct row.
+ #getCharX(line: number, char: number): [x: number, wrapLine: number] {
+ if (
+ this.#lastCharX !== undefined &&
+ this.#lastCharX[0] === line &&
+ this.#lastCharX[1] === char
+ ) {
+ return [this.#lastCharX[2], this.#lastCharX[3]];
+ }
+
+ const lineText = this.#textDocument?.getLineText(line);
+ const offsetLeft = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch)
+ if (lineText === undefined || lineText.length === 0 || char <= 0) {
+ return [offsetLeft, 0];
+ }
+
+ const boundedCharacter = Math.min(char, lineText.length);
+ const textBeforeCharacter = lineText.slice(0, boundedCharacter);
+ const asciiWidth = this.#getExpandedAsciiTextWidth(textBeforeCharacter);
+
+ let left = 0;
+ let wrapLine = 0;
+ if (asciiWidth !== -1) {
+ left = offsetLeft + asciiWidth;
+ } else {
+ left = offsetLeft + this.#measureTextWidth(textBeforeCharacter);
+ }
+
+ if (this.#wrap) {
+ const contentWidth = this.#getContentWidth();
+ const width = 2 * offsetLeft + this.#measureTextWidth(lineText);
+ if (width > contentWidth) {
+ const wrapOffsets = this.#wrapLineText(line);
+ for (let w = 0; w + 1 < wrapOffsets.length; w++) {
+ const segmentStart = wrapOffsets[w];
+ const segmentEnd = wrapOffsets[w + 1];
+ if (boundedCharacter <= segmentEnd) {
+ wrapLine = w;
+ const prefixInSegment = lineText.slice(
+ segmentStart,
+ boundedCharacter
+ );
+ const segmentAsciiWidth =
+ this.#getExpandedAsciiTextWidth(prefixInSegment);
+ if (segmentAsciiWidth !== -1) {
+ left = offsetLeft + segmentAsciiWidth;
+ } else {
+ left = offsetLeft + this.#measureTextWidth(prefixInSegment);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ if (this.#lastCharX !== undefined) {
+ this.#lastCharX[0] = line;
+ this.#lastCharX[1] = char;
+ this.#lastCharX[2] = left;
+ this.#lastCharX[3] = wrapLine;
+ } else {
+ this.#lastCharX = [line, char, left, wrapLine];
+ }
+
+ return [left, wrapLine];
+ }
+
+ #getExpandedAsciiTextWidth(text: string) {
+ let columns = 0;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) > 127) {
+ return -1;
+ }
+ columns += text.charCodeAt(i) === /* '\t' */ 9 ? this.#tabSize : 1;
+ }
+ return columns * this.#charWidth;
+ }
+
+ #measureTextWidth(text: string) {
+ if (this.#measureCtx === undefined) {
+ throw new Error('Measure context not initialized');
+ }
+ const textWithExpandedTabs = text.replaceAll(
+ '\t',
+ ' '.repeat(this.#tabSize)
+ );
+ return this.#measureCtx.measureText(textWithExpandedTabs).width;
+ }
+
+ // Compute how a logical line of text is broken into visual lines when line
+ // wrapping is enabled.
+ #wrapLineText(line: number): Uint32Array {
+ const cachedOffsets = this.#wrapLineOffsetsCache.get(line);
+ if (cachedOffsets !== undefined) {
+ return cachedOffsets;
+ }
+
+ const lineText = this.#textDocument?.getLineText(line);
+ if (lineText === undefined || lineText.length === 0) {
+ const offsets = new Uint32Array([0]);
+ this.#wrapLineOffsetsCache.set(line, offsets);
+ return offsets;
+ }
+
+ const div = createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ width: '100%',
+ visibility: 'hidden',
+ pointerEvents: 'none',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ font: 'inherit',
+ paddingInline: '1ch',
+ tabSize: this.#tabSize.toString(),
+ },
+ textContent: lineText,
+ },
+ this.#contentElement
+ );
+ const textNode = div.firstChild as Text;
+ const range = document.createRange();
+ const starts: number[] = [];
+
+ try {
+ let lastTop = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lineText.length; i++) {
+ range.setStart(textNode, i);
+ range.setEnd(textNode, i + 1);
+
+ // A new visual line starts whenever the character's top edge moves
+ // below the previous character's top edge.
+ const { top } = range.getBoundingClientRect();
+ if (top > lastTop) {
+ starts.push(i);
+ lastTop = top;
+ }
+ }
+
+ const offsets = new Uint32Array(starts.length + 1);
+ for (let i = 0; i < starts.length; i++) {
+ offsets[i] = starts[i]!;
+ }
+ offsets[starts.length] = lineText.length;
+ this.#wrapLineOffsetsCache.set(line, offsets);
+ return offsets;
+ } finally {
+ div.remove();
+ }
+ }
+
+ // check if the web selection belongs to editor
+ #rangeBelongsToEditor({ startContainer, endContainer }: StaticRange) {
+ const contentEl = this.#contentElement;
+ if (contentEl === undefined) {
+ return false;
+ }
+ return (
+ contentEl.contains(startContainer) && contentEl.contains(endContainer)
+ );
+ }
+
+ // Check whether a line is visible in the currently rendered line window.
+ #isLineVisible(line: number): boolean {
+ const lineCount = this.#textDocument?.lineCount;
+ if (line < 0 || (lineCount !== undefined && line >= lineCount)) {
+ return false;
+ }
+ if (this.#renderRange === undefined) {
+ return true;
+ }
+ const { startingLine, totalLines } = this.#renderRange;
+ if (line < startingLine) {
+ return false;
+ }
+ if (totalLines === Infinity) {
+ return true;
+ }
+ return line < startingLine + totalLines;
+ }
+}
+
+export function edit(
+ file: File,
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void
+): void {
+ const editor = new Editor();
+ editor.edit(file, onChange);
+}
diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts
new file mode 100644
index 000000000..df5fbcedd
--- /dev/null
+++ b/packages/diffs/src/editor/pieceTable.ts
@@ -0,0 +1,770 @@
+import { computeLineOffsets } from '../utils/computeFileOffsets';
+import type { Position, Range } from './textDocument';
+
+// A piece is a segment of text that is either original or added.
+class Piece {
+ static Original = 0;
+ static Added = 1;
+
+ constructor(
+ public readonly source: number,
+ public readonly offset: number,
+ public readonly length: number
+ ) {}
+}
+
+// A text buffer is a string with its line offsets.
+class TextBuffer {
+ lineOffsets: number[];
+
+ constructor(public text: string) {
+ this.lineOffsets = computeLineOffsets(text);
+ }
+
+ // the append operation is efficient because it only appends
+ // elements to the lineOffsets array in the end
+ append(text: string): number {
+ const offset = this.text.length;
+ const appendedLineOffsets = computeLineOffsets(text);
+ for (let i = 1; i < appendedLineOffsets.length; i++) {
+ this.lineOffsets.push(offset + appendedLineOffsets[i]);
+ }
+ this.text += text;
+ return offset;
+ }
+}
+
+// A node in the piece tree, which is a red-black tree
+class PieceNode {
+ static Red = 0;
+ static Black = 1;
+
+ left: PieceNode | null = null;
+ right: PieceNode | null = null;
+ parent: PieceNode | null = null;
+
+ constructor(
+ public piece: Piece,
+ public color: number = PieceNode.Red,
+ public subtreeLength: number = piece.length
+ ) {}
+
+ updateSubtreeLength(): void {
+ this.subtreeLength =
+ (this.left?.subtreeLength ?? 0) +
+ this.piece.length +
+ (this.right?.subtreeLength ?? 0);
+ }
+}
+
+/**
+ * A piece table is a data structure that allows for efficient insertion and deletion of text.
+ * It is a tree of pieces, where each piece is a segment of text that is either original or added.
+ * The tree is balanced to ensure that the operations are efficient.
+ * Inspired by https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation
+ */
+export class PieceTable {
+ #original: TextBuffer;
+ #add = new TextBuffer('');
+ #root: PieceNode | null = null;
+ #length = 0;
+ #lineCount = 0;
+ #lastVisitedLine: [number, string] | null = null;
+
+ constructor(originalText: string) {
+ this.#original = new TextBuffer(originalText);
+ this.#setPieces([new Piece(Piece.Original, 0, originalText.length)]);
+ }
+
+ get lineCount(): number {
+ return this.#lineCount;
+ }
+
+ getText(range?: Range): string {
+ if (range === undefined) {
+ return this.#textFromPieces();
+ }
+ const start = this.offsetAt(range.start);
+ const end = this.offsetAt(range.end);
+ return this.getTextSlice(start, end);
+ }
+
+ getLineText(line: number, trimEOF = true): string {
+ if (this.#lastVisitedLine !== null && this.#lastVisitedLine[0] === line) {
+ return this.#lastVisitedLine[1];
+ }
+ const offset = this.#getLineOffset(line);
+ if (offset === undefined) {
+ throw new Error(`Line index out of range: ${line}`);
+ }
+ const text = this.getTextSlice(offset[0], offset[1], trimEOF);
+ this.#lastVisitedLine = [line, text];
+ return text;
+ }
+
+ getTextSlice(start: number, end: number, trimEOF = false): string {
+ if (start >= end) {
+ return '';
+ }
+
+ const sliceStart = clamp(start, 0, this.#length);
+ const sliceEnd = clamp(end, sliceStart, this.#length);
+ if (sliceStart >= sliceEnd) {
+ return '';
+ }
+
+ const location = this.#findPieceAtOffset(sliceStart);
+ if (location === undefined) {
+ return '';
+ }
+
+ const chunks: string[] = [];
+ let [node, offsetInPiece] = location as [PieceNode | null, number];
+ let remaining = sliceEnd - sliceStart;
+ while (node !== null && remaining > 0) {
+ const takeLength = Math.min(node.piece.length - offsetInPiece, remaining);
+ const buffer = this.#bufferFor(node.piece.source);
+ const start = node.piece.offset + offsetInPiece;
+ let end = start + takeLength;
+ if (trimEOF) {
+ while (end > start && isEOL(buffer.text.charCodeAt(end - 1))) {
+ end--;
+ }
+ }
+ chunks.push(buffer.text.slice(start, end));
+ remaining -= takeLength;
+ offsetInPiece = 0;
+ node = this.#nextNode(node);
+ }
+
+ return chunks.join('');
+ }
+
+ charAt(offset: number): string {
+ const location = this.#findPieceAtOffset(offset);
+ if (location === undefined) {
+ return '';
+ }
+
+ const [node, offsetInPiece] = location;
+ const buffer = this.#bufferFor(node.piece.source);
+ return buffer.text.charAt(node.piece.offset + offsetInPiece);
+ }
+
+ includes(needle: string): boolean {
+ if (needle.length === 0) {
+ return true;
+ }
+
+ const prefixTable = createPrefixTable(needle);
+ let matched = 0;
+ let found = false;
+ this.#forEachPieceSegment((segment) => {
+ for (let offset = segment.start; offset < segment.end; offset++) {
+ const charCode = segment.text.charCodeAt(offset);
+ while (matched > 0 && charCode !== needle.charCodeAt(matched)) {
+ matched = prefixTable[matched - 1];
+ }
+ if (charCode === needle.charCodeAt(matched)) {
+ matched++;
+ }
+ if (matched === needle.length) {
+ found = true;
+ return false;
+ }
+ }
+ return true;
+ });
+ return found;
+ }
+
+ findNextNonOverlappingSubstring(
+ needle: string,
+ occupied: readonly [start: number, end: number][]
+ ): number | undefined {
+ if (needle.length === 0 || needle.length > this.#length) {
+ return undefined;
+ }
+
+ const ranges = normalizeRanges(occupied, this.#length);
+ const pivot = ranges.reduce((max, [, end]) => Math.max(max, end), 0);
+ const prefixTable = createPrefixTable(needle);
+ let matched = 0;
+ let documentOffset = 0;
+ let wrappedOffset: number | undefined;
+ let foundOffset: number | undefined;
+
+ this.#forEachPieceSegment((segment) => {
+ for (let offset = segment.start; offset < segment.end; offset++) {
+ const charCode = segment.text.charCodeAt(offset);
+ while (matched > 0 && charCode !== needle.charCodeAt(matched)) {
+ matched = prefixTable[matched - 1];
+ }
+ if (charCode === needle.charCodeAt(matched)) {
+ matched++;
+ }
+ if (matched === needle.length) {
+ const start = documentOffset - needle.length + 1;
+ if (!rangeOverlaps(ranges, start, start + needle.length)) {
+ if (start >= pivot) {
+ foundOffset = start;
+ return false;
+ }
+ wrappedOffset ??= start;
+ }
+ matched = prefixTable[matched - 1];
+ }
+ documentOffset++;
+ }
+ return true;
+ });
+
+ return foundOffset ?? wrappedOffset;
+ }
+
+ insert(text: string, offset: number): void {
+ if (text.length === 0) {
+ return;
+ }
+
+ const insertOffset = clamp(offset, 0, this.#length);
+ const addOffset = this.#add.append(text);
+ const insertedPiece = new Piece(Piece.Added, addOffset, text.length);
+ const pieces = this.#pieces();
+ const nextPieces: Piece[] = [];
+
+ let cursor = 0;
+ let inserted = false;
+
+ for (const piece of pieces) {
+ const pieceEnd = cursor + piece.length;
+ if (!inserted && insertOffset <= pieceEnd) {
+ const splitOffset = insertOffset - cursor;
+ if (splitOffset > 0) {
+ nextPieces.push({ ...piece, length: splitOffset });
+ }
+ nextPieces.push(insertedPiece);
+ if (splitOffset < piece.length) {
+ nextPieces.push({
+ ...piece,
+ offset: piece.offset + splitOffset,
+ length: piece.length - splitOffset,
+ });
+ }
+ inserted = true;
+ } else {
+ nextPieces.push(piece);
+ }
+ cursor = pieceEnd;
+ }
+
+ if (!inserted) {
+ nextPieces.push(insertedPiece);
+ }
+
+ this.#setPieces(nextPieces);
+ this.#lastVisitedLine = null;
+ }
+
+ delete(offset: number, length: number): void {
+ if (length <= 0 || this.#length === 0) {
+ return;
+ }
+
+ const start = clamp(offset, 0, this.#length);
+ const end = clamp(start + length, start, this.#length);
+ if (start === end) {
+ return;
+ }
+
+ const nextPieces: Piece[] = [];
+ let cursor = 0;
+ for (const piece of this.#pieces()) {
+ const pieceStart = cursor;
+ const pieceEnd = cursor + piece.length;
+ const keepBefore = clamp(start - pieceStart, 0, piece.length);
+ const keepAfter = clamp(pieceEnd - end, 0, piece.length);
+
+ if (keepBefore > 0) {
+ nextPieces.push({ ...piece, length: keepBefore });
+ }
+ if (keepAfter > 0) {
+ nextPieces.push({
+ ...piece,
+ offset: piece.offset + piece.length - keepAfter,
+ length: keepAfter,
+ });
+ }
+ cursor = pieceEnd;
+ }
+
+ this.#setPieces(nextPieces);
+ this.#lastVisitedLine = null;
+ }
+
+ positionAt(offset: number): Position {
+ const clampedOffset = clamp(offset, 0, this.#length);
+ if (this.#length === 0) {
+ return { line: 0, character: 0 };
+ }
+
+ let position: Position | undefined;
+ const scan = this.#forEachLineBreak((lineBreak, line) => {
+ if (clampedOffset < lineBreak[1]) {
+ position = {
+ line,
+ character: clampedOffset - lineBreak[0],
+ };
+ return false;
+ }
+ return true;
+ });
+
+ if (position !== undefined) {
+ return position;
+ }
+
+ return {
+ line: scan.nextLine,
+ character: clampedOffset - scan.nextLineStart,
+ };
+ }
+
+ offsetAt(position: Position): number {
+ if (position.line < 0 || this.#length === 0) {
+ return 0;
+ }
+ if (position.line >= this.#lineCount) {
+ throw new Error(`Line index out of range: ${position.line}`);
+ }
+ const offset = this.#getLineOffset(position.line);
+ if (offset === undefined) {
+ throw new Error(`Line index out of range: ${position.line}`);
+ }
+ const character = clamp(position.character, 0, offset[1] - offset[0]);
+ return offset[0] + character;
+ }
+
+ #findPieceAtOffset(
+ offset: number
+ ): [node: PieceNode, offsetInPiece: number] | undefined {
+ if (offset < 0 || offset >= this.#length) {
+ return undefined;
+ }
+
+ let node = this.#root;
+ let remaining = offset;
+ while (node !== null) {
+ const leftLength = node.left?.subtreeLength ?? 0;
+ if (remaining < leftLength) {
+ node = node.left;
+ continue;
+ }
+
+ remaining -= leftLength;
+ if (remaining < node.piece.length) {
+ return [node, remaining];
+ }
+
+ remaining -= node.piece.length;
+ node = node.right;
+ }
+
+ return undefined;
+ }
+
+ #nextNode(node: PieceNode): PieceNode | null {
+ if (node.right !== null) {
+ let next = node.right;
+ while (next.left !== null) {
+ next = next.left;
+ }
+ return next;
+ }
+
+ let current = node;
+ while (current.parent !== null && current === current.parent.right) {
+ current = current.parent;
+ }
+ return current.parent;
+ }
+
+ #getLineOffset(line: number): [start: number, end: number] | undefined {
+ if (line < 0) {
+ throw new Error(`Line index out of range: ${line}`);
+ }
+ if (this.#length === 0) {
+ if (line === 0) {
+ return [0, 0];
+ }
+ throw new Error(`Line index out of range: ${line}`);
+ }
+
+ let offset: [start: number, end: number] | undefined;
+ const scan = this.#forEachLineBreak((lineBreak, ln) => {
+ if (ln === line) {
+ offset = lineBreak;
+ return false;
+ }
+ return true;
+ });
+
+ if (offset !== undefined) {
+ return offset;
+ }
+ if (scan.nextLine !== line) {
+ throw new Error(`Line index out of range: ${line}`);
+ }
+ return [scan.nextLineStart, this.#length];
+ }
+
+ #textFromPieces(): string {
+ const chunks: string[] = [];
+ this.#forEachPieceSegment((segment) => {
+ chunks.push(segment.text.slice(segment.start, segment.end));
+ });
+ return chunks.join('');
+ }
+
+ #forEachPieceSegment(
+ callback: (segment: {
+ readonly start: number;
+ readonly end: number;
+ readonly text: string;
+ readonly lineOffsets: number[];
+ }) => boolean | void
+ ): void {
+ this.#walk(this.#root, (node) => {
+ const buffer = this.#bufferFor(node.piece.source);
+ return callback({
+ text: buffer.text,
+ lineOffsets: buffer.lineOffsets,
+ start: node.piece.offset,
+ end: node.piece.offset + node.piece.length,
+ });
+ });
+ }
+
+ #forEachLineBreak(
+ callback: (
+ lineBreak: [start: number, end: number],
+ line: number
+ ) => boolean | void
+ ): {
+ nextLine: number;
+ nextLineStart: number;
+ } {
+ let line = 0;
+ let lineStart = 0;
+ let documentOffset = 0;
+
+ this.#forEachPieceSegment((segment) => {
+ const lineOffsetStart = upperBound(segment.lineOffsets, segment.start);
+ const lineOffsetEnd = upperBound(segment.lineOffsets, segment.end);
+ for (let i = lineOffsetStart; i < lineOffsetEnd; i++) {
+ const bufferLineOffset = segment.lineOffsets[i];
+ const endWithEOL = documentOffset + (bufferLineOffset - segment.start);
+
+ if (callback([lineStart, endWithEOL], line) === false) {
+ return false;
+ }
+
+ line++;
+ lineStart = endWithEOL;
+ }
+
+ documentOffset += segment.end - segment.start;
+ return true;
+ });
+
+ return { nextLine: line, nextLineStart: lineStart };
+ }
+
+ #bufferFor(source: number): TextBuffer {
+ return source === Piece.Original ? this.#original : this.#add;
+ }
+
+ #pieces(): Piece[] {
+ const pieces: Piece[] = [];
+ this.#walk(this.#root, (node) => {
+ pieces.push(node.piece);
+ });
+ return pieces;
+ }
+
+ #setPieces(pieces: Piece[]): void {
+ const coalescedPieces = coalescePieces(pieces);
+ this.#root = null;
+ for (const piece of coalescedPieces) {
+ this.#insertRightmost(piece);
+ }
+ this.#recomputeSubtreeLength(this.#root);
+ this.#computeBufferMetadata();
+ }
+
+ #computeBufferMetadata(): void {
+ let length = 0;
+ let lineCount = 0;
+
+ this.#forEachPieceSegment((segment) => {
+ length += segment.end - segment.start;
+ lineCount +=
+ upperBound(segment.lineOffsets, segment.end) -
+ upperBound(segment.lineOffsets, segment.start);
+ });
+
+ this.#length = length;
+ this.#lineCount = length === 0 ? 0 : lineCount + 1;
+ }
+
+ #recomputeSubtreeLength(node: PieceNode | null): number {
+ if (node === null) {
+ return 0;
+ }
+
+ node.subtreeLength =
+ this.#recomputeSubtreeLength(node.left) +
+ node.piece.length +
+ this.#recomputeSubtreeLength(node.right);
+ return node.subtreeLength;
+ }
+
+ #walk(
+ node: PieceNode | null,
+ visit: (node: PieceNode) => boolean | void
+ ): boolean {
+ if (node === null) {
+ return true;
+ }
+ if (!this.#walk(node.left, visit)) {
+ return false;
+ }
+ if (visit(node) === false) {
+ return false;
+ }
+ return this.#walk(node.right, visit);
+ }
+
+ #insertRightmost(piece: Piece): void {
+ const node = new PieceNode(piece);
+ if (this.#root === null) {
+ node.color = PieceNode.Black;
+ this.#root = node;
+ return;
+ }
+
+ let parent = this.#root;
+ while (parent.right !== null) {
+ parent = parent.right;
+ }
+ parent.right = node;
+ node.parent = parent;
+
+ let current = node;
+ while (current.parent?.color === PieceNode.Red) {
+ const parent = current.parent;
+ const grandparent = parent.parent;
+ if (grandparent === null) {
+ break;
+ }
+
+ if (parent === grandparent.left) {
+ const uncle = grandparent.right;
+ if (uncle?.color === PieceNode.Red) {
+ parent.color = PieceNode.Black;
+ uncle.color = PieceNode.Black;
+ grandparent.color = PieceNode.Red;
+ current = grandparent;
+ } else {
+ if (current === parent.right) {
+ current = parent;
+ this.#rotateLeft(current);
+ }
+ current.parent!.color = PieceNode.Black;
+ grandparent.color = PieceNode.Red;
+ this.#rotateRight(grandparent);
+ }
+ } else {
+ const uncle = grandparent.left;
+ if (uncle?.color === PieceNode.Red) {
+ parent.color = PieceNode.Black;
+ uncle.color = PieceNode.Black;
+ grandparent.color = PieceNode.Red;
+ current = grandparent;
+ } else {
+ if (current === parent.left) {
+ current = parent;
+ this.#rotateRight(current);
+ }
+ current.parent!.color = PieceNode.Black;
+ grandparent.color = PieceNode.Red;
+ this.#rotateLeft(grandparent);
+ }
+ }
+ }
+
+ if (this.#root !== null) {
+ this.#root.color = PieceNode.Black;
+ }
+ }
+
+ #rotateLeft(node: PieceNode): void {
+ const right = node.right;
+ if (right === null) {
+ return;
+ }
+
+ node.right = right.left;
+ if (right.left !== null) {
+ right.left.parent = node;
+ }
+ right.parent = node.parent;
+ if (node.parent === null) {
+ this.#root = right;
+ } else if (node === node.parent.left) {
+ node.parent.left = right;
+ } else {
+ node.parent.right = right;
+ }
+ right.left = node;
+ node.parent = right;
+ node.updateSubtreeLength();
+ right.updateSubtreeLength();
+ }
+
+ #rotateRight(node: PieceNode): void {
+ const left = node.left;
+ if (left === null) {
+ return;
+ }
+
+ node.left = left.right;
+ if (left.right !== null) {
+ left.right.parent = node;
+ }
+ left.parent = node.parent;
+ if (node.parent === null) {
+ this.#root = left;
+ } else if (node === node.parent.right) {
+ node.parent.right = left;
+ } else {
+ node.parent.left = left;
+ }
+ left.right = node;
+ node.parent = left;
+ node.updateSubtreeLength();
+ left.updateSubtreeLength();
+ }
+}
+
+function isEOL(charCode: number): boolean {
+ return charCode === /* \n */ 10 || charCode === /* \r */ 13;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+function createPrefixTable(text: string): number[] {
+ const table = Array.from({ length: text.length }).fill(0);
+ let matched = 0;
+ for (let i = 1; i < text.length; i++) {
+ const charCode = text.charCodeAt(i);
+ while (matched > 0 && charCode !== text.charCodeAt(matched)) {
+ matched = table[matched - 1];
+ }
+ if (charCode === text.charCodeAt(matched)) {
+ matched++;
+ }
+ table[i] = matched;
+ }
+ return table;
+}
+
+function normalizeRanges(
+ ranges: readonly [start: number, end: number][],
+ length: number
+): [start: number, end: number][] {
+ const normalized: [start: number, end: number][] = [];
+ for (const [rawStart, rawEnd] of ranges) {
+ const start = clamp(rawStart, 0, length);
+ const end = clamp(rawEnd, start, length);
+ if (start < end) {
+ normalized.push([start, end]);
+ }
+ }
+ normalized.sort((a, b) => a[0] - b[0]);
+
+ const merged: [start: number, end: number][] = [];
+ for (const range of normalized) {
+ const previous = merged[merged.length - 1];
+ if (previous !== undefined && range[0] <= previous[1]) {
+ previous[1] = Math.max(previous[1], range[1]);
+ continue;
+ }
+ merged.push(range);
+ }
+ return merged;
+}
+
+function rangeOverlaps(
+ ranges: readonly [start: number, end: number][],
+ start: number,
+ end: number
+): boolean {
+ let low = 0;
+ let high = ranges.length;
+ while (low < high) {
+ const mid = low + Math.floor((high - low) / 2);
+ if (ranges[mid][1] <= start) {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+
+ const range = ranges[low];
+ return range !== undefined && range[0] < end;
+}
+
+// Keeps the table compact after repeated edits by joining neighboring pieces
+// that already point at contiguous text in the same backing buffer.
+function coalescePieces(pieces: Piece[]): Piece[] {
+ const coalescedPieces: Piece[] = [];
+ for (const piece of pieces) {
+ if (piece.length === 0) {
+ continue;
+ }
+
+ const previous = coalescedPieces[coalescedPieces.length - 1];
+ if (
+ previous !== undefined &&
+ previous.source === piece.source &&
+ previous.offset + previous.length === piece.offset
+ ) {
+ coalescedPieces[coalescedPieces.length - 1] = {
+ ...previous,
+ length: previous.length + piece.length,
+ };
+ continue;
+ }
+
+ coalescedPieces.push(piece);
+ }
+ return coalescedPieces;
+}
+
+// Returns the index of the first element in the array that is greater than the target.
+function upperBound(values: number[], target: number): number {
+ let lo = 0;
+ let hi = values.length;
+ while (lo < hi) {
+ const mid = lo + Math.floor((hi - lo) / 2);
+ if (values[mid] <= target) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ return lo;
+}
diff --git a/packages/diffs/src/editor/platform.ts b/packages/diffs/src/editor/platform.ts
new file mode 100644
index 000000000..e2cd6a313
--- /dev/null
+++ b/packages/diffs/src/editor/platform.ts
@@ -0,0 +1,27 @@
+let _isMacLike: boolean | undefined = undefined;
+let _isLinux: boolean | undefined = undefined;
+
+export function isMacLike(): boolean {
+ return (
+ _isMacLike ??
+ (_isMacLike = /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()))
+ );
+}
+
+export function isLinux(): boolean {
+ return _isLinux ?? (_isLinux = /Linux/i.test(getPlatform()));
+}
+
+export function isPrimaryModifier(
+ { metaKey, ctrlKey }: MouseEvent | KeyboardEvent,
+ isMac: boolean = isMacLike()
+): boolean {
+ return isMac ? metaKey && !ctrlKey : ctrlKey && !metaKey;
+}
+
+function getPlatform(): string {
+ const navigator = globalThis.navigator as Navigator & {
+ userAgentData?: { platform?: string };
+ };
+ return navigator?.platform ?? navigator?.userAgentData?.platform ?? 'unknown';
+}
diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts
new file mode 100644
index 000000000..f91b4330c
--- /dev/null
+++ b/packages/diffs/src/editor/textDocument.ts
@@ -0,0 +1,383 @@
+import type { LineAnnotation } from '../types';
+import { type EditorSelection } from './editorSelection';
+import {
+ coalesceEditStackEntries,
+ createEditStackEntry,
+ EditStack,
+ shouldCoalesceEditStackEntry,
+} from './editStack';
+import { PieceTable } from './pieceTable';
+
+/**
+ * Position in a text document expressed as zero-based line and character offset.
+ * The offsets are based on a UTF-16 string representation. So a string of the form
+ * `a𐐀b` the character offset of the character `a` is 0, the character offset of `𐐀`
+ * is 1 and the character offset of b is 3 since `𐐀` is represented using two code
+ * units in UTF-16.
+ *
+ * Positions are line end character agnostic. So you can not specify a position that
+ * denotes `\r|\n` or `\n|` where `|` represents the character offset.
+ */
+export interface Position {
+ /**
+ * Line position in a document (zero-based).
+ *
+ * If a line number is greater than the number of lines in a document, it
+ * defaults back to the number of lines in the document.
+ * If a line number is negative, it defaults to 0.
+ *
+ * The above two properties are implementation specific.
+ */
+ readonly line: number;
+ /**
+ * Character offset on a line in a document (zero-based).
+ *
+ * The meaning of this offset is determined by the negotiated
+ * `PositionEncodingKind`.
+ *
+ * If the character value is greater than the line length it defaults back
+ * to the line length. This property is implementation specific.
+ */
+ readonly character: number;
+}
+
+/**
+ * A range in a text document expressed as (zero-based) start and end positions.
+ *
+ * If you want to specify a range that contains a line including the line ending
+ * character(s) then use an end position denoting the start of the next line.
+ * For example:
+ * ```ts
+ * {
+ * start: { line: 5, character: 23 }
+ * end : { line 6, character : 0 }
+ * }
+ * ```
+ */
+export interface Range {
+ /**
+ * The range's start position.
+ */
+ readonly start: Position;
+ /**
+ * The range's end position.
+ */
+ readonly end: Position;
+}
+
+/**
+ * A text edit applicable to a text document.
+ */
+export interface TextEdit {
+ /**
+ * The range of the text document to be manipulated. To insert
+ * text into a document create a range where start === end.
+ */
+ readonly range: Range;
+ /**
+ * The string to be inserted. For delete operations use an
+ * empty string.
+ */
+ readonly newText: string;
+}
+
+/** Different with `TextEdit`, the range has been resolved to offsets. */
+export interface ResolvedTextEdit {
+ /** The start offset of the text change. */
+ readonly start: number;
+ /** The end offset of the text change. */
+ readonly end: number;
+ /**
+ * The string to be inserted. For delete operations use an
+ * empty string.
+ */
+ readonly text: string;
+}
+
+export interface TextDocumentChange {
+ /** First line whose rendered content or tokenizer state may have changed. */
+ readonly startLine: number;
+ /** Character on the first changed line where the edit began. */
+ readonly startCharacter: number;
+ /** Last line whose rendered content may have changed after the edit. */
+ readonly endLine: number;
+ /** Line count before the edit was applied. */
+ readonly previousLineCount: number;
+ /** Line count after the edit was applied. */
+ readonly lineCount: number;
+ /** Difference between the old and new line counts. */
+ readonly lineDelta: number;
+}
+
+/**
+ * A vscode-languageserver-textdocument compatible text document.
+ */
+export class TextDocument {
+ #uri: string;
+ #languageId: string;
+ #version: number;
+ #pieceTable: PieceTable;
+ #editStack: EditStack;
+
+ constructor(
+ uri: string,
+ text: string,
+ languageId = 'plaintext',
+ version = 0,
+ editStack: EditStack = new EditStack()
+ ) {
+ this.#uri = new URL(uri, 'file://').toString();
+ this.#languageId = languageId;
+ this.#version = version;
+ this.#pieceTable = new PieceTable(text);
+ this.#editStack = editStack;
+ }
+
+ get uri(): string {
+ return this.#uri;
+ }
+
+ get languageId(): string {
+ return this.#languageId;
+ }
+
+ get version(): number {
+ return this.#version;
+ }
+
+ get lineCount(): number {
+ return this.#pieceTable.lineCount;
+ }
+
+ get canUndo(): boolean {
+ return this.#editStack.canUndo;
+ }
+
+ get canRedo(): boolean {
+ return this.#editStack.canRedo;
+ }
+
+ positionAt(offset: number): Position {
+ return this.#pieceTable.positionAt(offset);
+ }
+
+ offsetAt(position: Position): number {
+ return this.#pieceTable.offsetAt(position);
+ }
+
+ getText(range?: Range): string {
+ return this.#pieceTable.getText(range);
+ }
+
+ getLineText(line: number, trimEOF = true): string {
+ return this.#pieceTable.getLineText(line, trimEOF);
+ }
+
+ charAt(offset: number): string;
+ charAt(position: Position): string;
+ charAt(positionOrOffset: Position | number): string {
+ if (typeof positionOrOffset === 'number') {
+ return this.#pieceTable.charAt(positionOrOffset);
+ }
+ return this.#pieceTable.charAt(this.offsetAt(positionOrOffset));
+ }
+
+ getTextSlice(start: number, end: number): string {
+ return this.#pieceTable.getTextSlice(start, end);
+ }
+
+ findNextNonOverlappingSubstring(
+ needle: string,
+ occupied: readonly [start: number, end: number][]
+ ): number | undefined {
+ return this.#pieceTable.findNextNonOverlappingSubstring(needle, occupied);
+ }
+
+ applyEdits(
+ edits: TextEdit[],
+ updateHistory = false,
+ selectionsBefore?: EditorSelection[],
+ selectionsAfter?: EditorSelection[],
+ lineAnnotationsBefore?: LineAnnotation[],
+ lineAnnotationsAfter?: LineAnnotation[]
+ ): TextDocumentChange | undefined {
+ if (edits.length === 0) {
+ return;
+ }
+ const resolvedEdits = this.#sortAndValidateResolvedEdits(
+ edits.map((edit) => this.#resolveEdit(edit))
+ );
+ if (updateHistory) {
+ const entry = createEditStackEntry(
+ this,
+ resolvedEdits,
+ this.#version,
+ this.#version + 1,
+ selectionsBefore,
+ selectionsAfter,
+ lineAnnotationsBefore,
+ lineAnnotationsAfter
+ );
+ const previousEntry = this.#editStack.peekUndo();
+ const change = this.#applyResolvedEdits(resolvedEdits);
+ this.#version++;
+ if (
+ change.lineDelta === 0 &&
+ shouldCoalesceEditStackEntry(previousEntry, entry)
+ ) {
+ this.#editStack.replaceLastUndo(
+ coalesceEditStackEntries(previousEntry!, entry)
+ );
+ } else {
+ this.#editStack.push(entry);
+ }
+ return change;
+ }
+ const change = this.#applyResolvedEdits(resolvedEdits);
+ this.#version++;
+ return change;
+ }
+
+ setLastUndoSelectionsAfter(selections: EditorSelection[]): void {
+ this.#editStack.setLastUndoSelectionsAfter(selections);
+ }
+
+ setLastUndoLineAnnotationsAfter(
+ lineAnnotations: LineAnnotation[]
+ ): void {
+ this.#editStack.setLastUndoLineAnnotationsAfter(lineAnnotations);
+ }
+
+ undo():
+ | [
+ change: TextDocumentChange,
+ selections?: EditorSelection[],
+ lineAnnotations?: LineAnnotation[],
+ ]
+ | undefined {
+ const entry = this.#editStack.popUndoToRedo();
+ if (entry === undefined) {
+ return undefined;
+ }
+ const change = this.#applyResolvedEdits(entry.inverseEdits);
+ if (change === undefined) {
+ return undefined;
+ }
+ this.#version = entry.versionBefore;
+ return [
+ change,
+ entry.selectionsBefore?.slice(),
+ entry.lineAnnotationsBefore?.slice(),
+ ];
+ }
+
+ redo():
+ | [
+ change: TextDocumentChange,
+ selections?: EditorSelection[],
+ lineAnnotations?: LineAnnotation[],
+ ]
+ | undefined {
+ const entry = this.#editStack.popRedoToUndo();
+ if (entry === undefined) {
+ return undefined;
+ }
+ const change = this.#applyResolvedEdits(entry.forwardEdits);
+ if (change === undefined) {
+ return undefined;
+ }
+ this.#version = entry.versionAfter;
+ return [
+ change,
+ entry.selectionsAfter?.slice(),
+ entry.lineAnnotationsAfter?.slice(),
+ ];
+ }
+
+ normalizePosition(position: Position): Position {
+ const line = Math.max(0, Math.min(position.line, this.lineCount - 1));
+ return {
+ line,
+ character: Math.max(
+ 0,
+ Math.min(position.character, this.getLineText(line).length)
+ ),
+ };
+ }
+
+ #resolveEdit(edit: TextEdit): ResolvedTextEdit {
+ let start = this.offsetAt(edit.range.start);
+ let end = this.offsetAt(edit.range.end);
+ if (start > end) {
+ const t = start;
+ start = end;
+ end = t;
+ }
+ return { start, end, text: edit.newText };
+ }
+
+ #sortAndValidateResolvedEdits(edits: ResolvedTextEdit[]): ResolvedTextEdit[] {
+ const sortedEdits = [...edits].sort((a, b) => a.start - b.start);
+ for (let i = 0; i < sortedEdits.length - 1; i++) {
+ if (sortedEdits[i].end > sortedEdits[i + 1].start) {
+ throw new Error('Overlapping text edits are not supported');
+ }
+ }
+ return sortedEdits;
+ }
+
+ #applyResolvedEdits(edits: ResolvedTextEdit[]): TextDocumentChange {
+ const previousLineCount = this.#pieceTable.lineCount;
+ const changedLineRange = this.#computeChangedLineRange(edits);
+ const startPosition = this.positionAt(edits[0].start);
+ for (let i = edits.length - 1; i >= 0; i--) {
+ const edit = edits[i];
+ this.#pieceTable.delete(edit.start, edit.end - edit.start);
+ this.#pieceTable.insert(edit.text, edit.start);
+ }
+ const lineCount = this.#pieceTable.lineCount;
+ const change: TextDocumentChange = {
+ startLine: changedLineRange.startLine,
+ startCharacter: startPosition.character,
+ endLine: Math.min(changedLineRange.endLine, Math.max(0, lineCount - 1)),
+ previousLineCount,
+ lineCount,
+ lineDelta: lineCount - previousLineCount,
+ };
+ return change;
+ }
+
+ #computeChangedLineRange(edits: ResolvedTextEdit[]): {
+ startLine: number;
+ endLine: number;
+ } {
+ let startLine = Infinity;
+ let endLine = 0;
+ let lineDeltaBeforeEdit = 0;
+ for (const edit of edits) {
+ const editStartLine = this.positionAt(edit.start).line;
+ const editEndLine = this.positionAt(edit.end).line;
+ const insertedLineSpan = lineFeedCount(edit.text);
+ startLine = Math.min(startLine, editStartLine);
+ endLine = Math.max(
+ endLine,
+ editStartLine + lineDeltaBeforeEdit + insertedLineSpan
+ );
+ lineDeltaBeforeEdit += insertedLineSpan - (editEndLine - editStartLine);
+ }
+ if (startLine === Infinity) {
+ return { startLine: 0, endLine: 0 };
+ }
+ return { startLine, endLine };
+ }
+}
+
+function lineFeedCount(text: string): number {
+ let count = 0;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) === /* \n */ 10) {
+ count++;
+ }
+ }
+ return count;
+}
diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts
new file mode 100644
index 000000000..0b71d854d
--- /dev/null
+++ b/packages/diffs/src/editor/tokenzier.ts
@@ -0,0 +1,158 @@
+import {
+ EncodedTokenMetadata,
+ type IGrammar,
+ type StateStack,
+} from 'shiki/textmate';
+
+import type { HighlightedToken } from '../types';
+import {
+ BGTOKENIZER_LINES_PRE_TOKENIZE,
+ TOKENIZE_MAX_LINE_LENGTH,
+ TOKENIZE_TIME_LIMIT,
+} from './constants';
+import type { TextDocument } from './textDocument';
+
+export interface BackgroundTokenizerOptions {
+ grammar: IGrammar;
+ colorMap: string[];
+ textDocument: TextDocument;
+ linesPreTokenize?: number;
+ onTokenize: (lines: Map>) => void;
+}
+
+/** Stoppable background tokenizer */
+export class BackgroundTokenizer {
+ #grammar: IGrammar;
+ #colorMap: string[];
+ #textDocument: TextDocument;
+ #messageKey: string;
+ #onMessage: (event: MessageEvent) => void;
+ #onTokenize: (lines: Map>) => void;
+
+ // state
+ #isStopped: boolean = true;
+ #lastLine: number = -1;
+ #lastState: StateStack | null = null;
+
+ constructor({
+ grammar,
+ colorMap,
+ textDocument,
+ onTokenize,
+ linesPreTokenize,
+ }: BackgroundTokenizerOptions) {
+ this.#grammar = grammar;
+ this.#colorMap = colorMap;
+ this.#textDocument = textDocument;
+ this.#onTokenize = onTokenize;
+ this.#onMessage = ({ data }: MessageEvent) => {
+ if (data === this.#messageKey) {
+ this.#doTokenize(linesPreTokenize);
+ }
+ };
+ this.#messageKey = 'tokenize-' + Date.now().toString(16);
+ addEventListener('message', this.#onMessage);
+ }
+
+ scheduleTokenize(startLine: number, state: StateStack): void {
+ this.#isStopped = false;
+ this.#lastLine = startLine;
+ this.#lastState = state;
+ // use `postMessage` instead of `setTimeout(fn, 0)` to avoid 4ms delay
+ postMessage(this.#messageKey);
+ }
+
+ stop(): void {
+ removeEventListener('message', this.#onMessage);
+ this.#isStopped = true;
+ this.#lastLine = -1;
+ this.#lastState = null;
+ }
+
+ #doTokenize(linesPreTokenize: number = BGTOKENIZER_LINES_PRE_TOKENIZE): void {
+ if (this.#isStopped || this.#lastState === null) {
+ return;
+ }
+
+ const lines = new Map>();
+ const totalLines = this.#textDocument.lineCount;
+ const endLine = Math.min(this.#lastLine + linesPreTokenize, totalLines);
+
+ let line = this.#lastLine;
+ let state = this.#lastState;
+ for (; line < endLine; line++) {
+ const lineText = this.#textDocument.getLineText(line);
+ if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) {
+ console.warn(
+ `[diffs] Line(${line}) too long to tokenize: ${lineText.length}`
+ );
+ lines.set(line, [[0, '', lineText]]);
+ continue;
+ }
+
+ if (lineText === '' || lineText.trim() === '') {
+ lines.set(line, [[0, '', lineText === '' ? ' ' : lineText]]);
+ continue;
+ }
+
+ const ret = tokenizeLine(
+ this.#grammar,
+ this.#colorMap,
+ lineText,
+ state,
+ TOKENIZE_TIME_LIMIT
+ );
+ lines.set(line, ret.resolvedTokens);
+ state = ret.ruleStack;
+ }
+
+ this.#onTokenize(lines);
+ if (line >= totalLines) {
+ this.stop();
+ return;
+ }
+
+ this.#lastLine = line;
+ this.#lastState = state;
+ postMessage(this.#messageKey);
+ }
+}
+
+export function tokenizeLine(
+ grammar: IGrammar,
+ colorMap: string[],
+ lineText: string,
+ stateStack: StateStack,
+ timeLimit?: number
+): {
+ ruleStack: StateStack;
+ resolvedTokens: Array;
+} {
+ const result = grammar.tokenizeLine2(lineText, stateStack, timeLimit);
+ if (result.stoppedEarly) {
+ console.warn(
+ `[diffs] Time limit reached when tokenizing line: ${lineText.substring(0, 100)}`
+ );
+ }
+ const rawTokens = result.tokens;
+ const tokensLength = rawTokens.length / 2;
+ const resolvedTokens: Array = [];
+ for (let j = 0; j < tokensLength; j++) {
+ const offset = rawTokens[2 * j];
+ const nextOffset =
+ j + 1 < tokensLength ? rawTokens[2 * j + 2] : lineText.length;
+ if (offset === nextOffset) {
+ // should never reach here, skip if happens anyway
+ continue;
+ }
+ const metadata = rawTokens[2 * j + 1];
+ const bg = EncodedTokenMetadata.getForeground(metadata);
+ const fg = colorMap[bg];
+ const tokenText = lineText.slice(offset, nextOffset);
+ resolvedTokens.push([offset, fg, tokenText]);
+ }
+ return {
+ ruleStack: result.ruleStack,
+ resolvedTokens,
+ };
+}
diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts
index 0fa831597..fc1db46b0 100644
--- a/packages/diffs/src/index.ts
+++ b/packages/diffs/src/index.ts
@@ -11,6 +11,7 @@ export * from './components/VirtualizedFile';
export * from './components/VirtualizedFileDiff';
export * from './components/Virtualizer';
export * from './constants';
+export * from './editor';
export * from './highlighter/languages/areLanguagesAttached';
export * from './highlighter/languages/attachResolvedLanguages';
export * from './highlighter/languages/cleanUpResolvedLanguages';
diff --git a/packages/diffs/src/react/EditorContext.tsx b/packages/diffs/src/react/EditorContext.tsx
new file mode 100644
index 000000000..52d6a3f1f
--- /dev/null
+++ b/packages/diffs/src/react/EditorContext.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import type { Context, PropsWithChildren } from 'react';
+import { createContext, useContext, useEffect } from 'react';
+
+import { Editor as VanillaEditor } from '../editor';
+
+export const EditorContext: Context | undefined> =
+ createContext | undefined>(undefined);
+
+export function EditorProvider({
+ children,
+ editor,
+}: PropsWithChildren<{ editor: VanillaEditor }>): React.JSX.Element {
+ useEffect(() => {
+ return () => {
+ editor.cleanUp();
+ };
+ }, [editor]);
+ return (
+ {children}
+ );
+}
+
+export function useEditor():
+ | VanillaEditor
+ | undefined {
+ return useContext(EditorContext) as VanillaEditor | undefined;
+}
diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx
index 11727a8d3..b32b8042a 100644
--- a/packages/diffs/src/react/File.tsx
+++ b/packages/diffs/src/react/File.tsx
@@ -25,6 +25,8 @@ export function File({
renderGutterUtility,
renderHoverUtility,
disableWorkerPool = false,
+ editable = false,
+ onChange,
}: FileProps): React.JSX.Element {
const { ref, getHoveredLine } = useFileInstance({
file,
@@ -37,6 +39,8 @@ export function File({
renderGutterUtility != null || renderHoverUtility != null,
hasCustomHeader: renderCustomHeader != null,
disableWorkerPool,
+ editable,
+ onChange,
});
const children = renderFileChildren({
file,
diff --git a/packages/diffs/src/react/index.ts b/packages/diffs/src/react/index.ts
index 92efc882c..af275c460 100644
--- a/packages/diffs/src/react/index.ts
+++ b/packages/diffs/src/react/index.ts
@@ -9,6 +9,7 @@ export * from './MultiFileDiff';
export * from './PatchDiff';
export * from './Virtualizer';
export * from './WorkerPoolContext';
+export * from './EditorContext';
export * from './constants';
export * from './types';
export * from './utils/renderDiffChildren';
diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts
index 47ee19078..3469dc5e6 100644
--- a/packages/diffs/src/react/types.ts
+++ b/packages/diffs/src/react/types.ts
@@ -60,4 +60,9 @@ export interface FileProps {
style?: CSSProperties;
prerenderedHTML?: string;
disableWorkerPool?: boolean;
+ editable?: boolean;
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void;
}
diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts
index 9b0971594..6c747642b 100644
--- a/packages/diffs/src/react/utils/useFileInstance.ts
+++ b/packages/diffs/src/react/utils/useFileInstance.ts
@@ -19,6 +19,7 @@ import type {
} from '../../types';
import { areOptionsEqual } from '../../utils/areOptionsEqual';
import { noopRender } from '../constants';
+import { useEditor } from '../EditorContext';
import { useVirtualizer } from '../Virtualizer';
import { WorkerPoolContext } from '../WorkerPoolContext';
import { useStableCallback } from './useStableCallback';
@@ -36,6 +37,11 @@ interface UseFileInstanceProps {
hasGutterRenderUtility: boolean;
hasCustomHeader: boolean;
disableWorkerPool: boolean;
+ editable: boolean;
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void;
}
interface UseFileInstanceReturn {
@@ -53,9 +59,12 @@ export function useFileInstance({
hasGutterRenderUtility,
hasCustomHeader,
disableWorkerPool,
+ editable,
+ onChange,
}: UseFileInstanceProps): UseFileInstanceReturn {
const simpleVirtualizer = useVirtualizer();
const poolManager = useContext(WorkerPoolContext);
+ const editor = useEditor();
const instanceRef = useRef<
File | VirtualizedFile | null
>(null);
@@ -122,6 +131,13 @@ export function useFileInstance({
}
});
+ useIsometricEffect(() => {
+ if (editable && editor != null && instanceRef.current != null) {
+ return editor.edit(instanceRef.current, onChange);
+ }
+ return undefined;
+ }, [editable, editor, onChange]);
+
const getHoveredLine = useCallback(():
| GetHoveredLineResult<'file'>
| undefined => {
diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts
index 98e6dff3b..bd2740aff 100644
--- a/packages/diffs/src/renderers/FileRenderer.ts
+++ b/packages/diffs/src/renderers/FileRenderer.ts
@@ -14,6 +14,7 @@ import type {
DiffsHighlighter,
FileContents,
FileHeaderRenderMode,
+ HighlightedToken,
LineAnnotation,
RenderedFileASTCache,
RenderFileOptions,
@@ -24,6 +25,7 @@ import type {
} from '../types';
import { areRenderRangesEqual } from '../utils/areRenderRangesEqual';
import { areThemesEqual } from '../utils/areThemesEqual';
+import { computeLineOffsets } from '../utils/computeFileOffsets';
import { createAnnotationElement } from '../utils/createAnnotationElement';
import { createContentColumn } from '../utils/createContentColumn';
import { createFileHeaderElement } from '../utils/createFileHeaderElement';
@@ -39,10 +41,8 @@ import {
createHastElement,
} from '../utils/hast_utils';
import { isFilePlainText } from '../utils/isFilePlainText';
-import { iterateOverFile } from '../utils/iterateOverFile';
import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter';
import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer';
-import { splitFileContents } from '../utils/splitFileContents';
import type { WorkerPoolManager } from '../worker';
type AnnotationLineMap = Record<
@@ -69,11 +69,6 @@ export interface FileRenderResult {
bufferAfter: number;
}
-interface LineCache {
- cacheKey: string | undefined;
- lines: string[];
-}
-
export interface FileRendererOptions extends BaseCodeOptions {
headerRenderMode?: FileHeaderRenderMode;
}
@@ -87,7 +82,8 @@ export class FileRenderer {
private renderCache: RenderedFileASTCache | undefined;
private computedLang: SupportedLanguages = 'text';
private lineAnnotations: AnnotationLineMap = {};
- private lineCache: LineCache | undefined;
+ private lineOffsets = new WeakMap();
+ private alternateLineCount = new WeakMap();
constructor(
public options: FileRendererOptions = { theme: DEFAULT_THEMES },
@@ -125,7 +121,6 @@ export class FileRenderer {
this.highlighter = undefined;
this.workerManager = undefined;
this.onRenderUpdate = undefined;
- this.lineCache = undefined;
}
public hydrate(file: FileContents): void {
@@ -181,23 +176,91 @@ export class FileRenderer {
return { options, forceRender: false };
}
- public getOrCreateLineCache(file: FileContents): string[] {
- // Uncached files will get split every time, not the greatest experience
- // tbh... but something people should try to optimize away
- if (file.cacheKey == null) {
- this.lineCache = undefined;
- return splitFileContents(file.contents);
+ public getOrCreateLineOffsets(file: FileContents): number[] {
+ let offsets = this.lineOffsets.get(file);
+ if (offsets == null) {
+ offsets = computeLineOffsets(file.contents);
+ this.lineOffsets.set(file, offsets);
}
+ return offsets;
+ }
+
+ public getLineCount(file: FileContents): number {
+ return (
+ this.alternateLineCount.get(file) ??
+ this.getOrCreateLineOffsets(file).length
+ );
+ }
- let { lineCache } = this;
- if (lineCache == null || lineCache.cacheKey !== file.cacheKey) {
- lineCache = {
- cacheKey: file.cacheKey,
- lines: splitFileContents(file.contents),
+ public emitDirtyLines(
+ themeType: 'dark' | 'light',
+ lines: Map>
+ ): void {
+ const renderCache = this.renderCache;
+ if (renderCache == null || renderCache.result == null) {
+ return;
+ }
+ for (const [line, tokens] of lines) {
+ renderCache.result.code[line] = {
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ 'data-line': line + 1,
+ 'data-line-type': 'context',
+ 'data-line-index': line,
+ },
+ children: tokens.map(([char, fg, text]) => {
+ if (char === 0 && fg === '') {
+ return {
+ type: 'text',
+ value: text,
+ };
+ }
+ return {
+ type: 'element',
+ tagName: 'span',
+ properties: {
+ 'data-char': char,
+ style: `--diffs-token-${themeType}:${fg};`,
+ },
+ children: [
+ {
+ type: 'text',
+ value: text,
+ },
+ ],
+ };
+ }),
};
}
- this.lineCache = lineCache;
- return lineCache.lines;
+ }
+
+ public emitLineCountChange(
+ lineCount: number,
+ newLineAnnotations?: LineAnnotation[]
+ ): void {
+ const renderCache = this.renderCache;
+ if (renderCache == null || renderCache.result == null) {
+ return undefined;
+ }
+ const prevCodeLines = renderCache.result.code.length;
+ renderCache.result.code.length = lineCount;
+ for (let i = prevCodeLines; i < lineCount; i++) {
+ renderCache.result.code[i] = {
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ 'data-line': i + 1,
+ 'data-line-type': 'context',
+ 'data-line-index': i,
+ },
+ children: [{ type: 'text', value: ' ' }],
+ };
+ }
+ this.alternateLineCount.set(renderCache.file, lineCount);
+ if (newLineAnnotations != null) {
+ this.setLineAnnotations(newLineAnnotations);
+ }
}
public renderFile(
@@ -237,7 +300,7 @@ export class FileRenderer {
file,
renderRange.startingLine,
renderRange.totalLines,
- this.getOrCreateLineCache(file)
+ this.getOrCreateLineOffsets(file)
);
this.renderCache.renderRange = renderRange;
}
@@ -346,66 +409,68 @@ export class FileRenderer {
renderRange: RenderRange,
{ code, themeStyles, baseThemeType }: ThemedFileResult
): FileRenderResult {
+ const totalLines = this.getLineCount(file);
const { disableFileHeader = false } = this.options;
const contentArray: ElementContent[] = [];
const gutter = createGutterWrapper();
- const lines = this.getOrCreateLineCache(file);
+ const endLine = Math.min(
+ renderRange.startingLine + renderRange.totalLines,
+ totalLines
+ );
let rowCount = 0;
- iterateOverFile({
- lines,
- startingLine: renderRange.startingLine,
- totalLines: renderRange.totalLines,
- callback: ({ lineIndex, lineNumber }) => {
- // Sparse array - directly indexed by lineIndex
- const line = code[lineIndex];
- if (line == null) {
- const message = 'FileRenderer.processFileResult: Line doesnt exist';
- console.error(message, {
- name: file.name,
- lineIndex,
- lineNumber,
- lines,
- });
- throw new Error(message);
- }
-
- if (line != null) {
- // Add gutter line number
- gutter.children.push(
- createGutterItem('context', lineNumber, `${lineIndex}`)
- );
- contentArray.push(line);
- rowCount++;
-
- // Check annotations using ACTUAL line number from file
- const annotations = this.lineAnnotations[lineNumber];
- if (annotations != null) {
- gutter.children.push(createGutterGap('context', 'annotation', 1));
- contentArray.push(
- createAnnotationElement({
- type: 'annotation',
- hunkIndex: 0,
- lineIndex: lineNumber,
- annotations: annotations.map((annotation) =>
- getLineAnnotationName(annotation)
- ),
- })
- );
- rowCount++;
- }
- }
- },
- });
+ for (
+ let lineIndex = renderRange.startingLine;
+ lineIndex < endLine;
+ lineIndex++
+ ) {
+ const lineNumber = lineIndex + 1;
+
+ // Sparse array - directly indexed by lineIndex
+ const line = code[lineIndex];
+ if (line == null) {
+ const message = 'FileRenderer.processFileResult: Line doesnt exist';
+ console.error(message, {
+ name: file.name,
+ lineIndex,
+ lineNumber,
+ });
+ throw new Error(message);
+ }
+
+ // Add gutter line number
+ gutter.children.push(
+ createGutterItem('context', lineNumber, `${lineIndex}`)
+ );
+ contentArray.push(line);
+ rowCount++;
+
+ // Check annotations using ACTUAL line number from file
+ const annotations = this.lineAnnotations[lineNumber];
+ if (annotations != null) {
+ gutter.children.push(createGutterGap('context', 'annotation', 1));
+ contentArray.push(
+ createAnnotationElement({
+ type: 'annotation',
+ hunkIndex: 0,
+ lineIndex: lineNumber,
+ annotations: annotations.map((annotation) =>
+ getLineAnnotationName(annotation)
+ ),
+ })
+ );
+ rowCount++;
+ }
+ }
// Finalize: wrap gutter and content
gutter.properties.style = `grid-row: span ${rowCount}`;
return {
gutterAST: gutter.children ?? [],
contentAST: contentArray,
- preAST: this.createPreElement(lines.length),
+ preAST: this.createPreElement(totalLines),
headerAST: !disableFileHeader ? this.renderHeader(file) : undefined,
- totalLines: lines.length,
+ totalLines,
rowCount,
themeStyles: themeStyles,
baseThemeType,
diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css
index 34edd501d..a6d59b20e 100644
--- a/packages/diffs/src/style.css
+++ b/packages/diffs/src/style.css
@@ -34,6 +34,7 @@
--diffs-bg-context-override
--diffs-bg-context-gutter-override
--diffs-bg-separator-override
+ --diffs-bg-caret-override
--diffs-fg-number-override
--diffs-fg-number-addition-override
@@ -131,6 +132,14 @@
var(--diffs-fg-number)
);
+ --diffs-bg-caret: var(
+ --diffs-bg-caret-override,
+ light-dark(
+ color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)),
+ color-mix(in lab, var(--diffs-fg) 75%, var(--diffs-bg))
+ )
+ );
+
--diffs-deletion-base: var(
--diffs-deletion-color-override,
light-dark(
@@ -319,7 +328,8 @@
[data-line-annotation],
[data-no-newline],
[data-merge-conflict],
- [data-merge-conflict-actions] {
+ [data-merge-conflict-actions],
+ [data-selection-range] {
/* Pre-fill css variables for appropriate up-mixing */
--diffs-computed-decoration-bg: var(--diffs-bg);
--diffs-computed-diff-line-bg: var(--diffs-bg);
@@ -669,12 +679,14 @@
[data-line-annotation],
[data-merge-conflict],
[data-merge-conflict-actions],
- [data-no-newline] {
+ [data-no-newline],
+ [data-selection-range] {
--diffs-selection-mix-target: var(
--diffs-bg-selection-override,
var(--diffs-selection-base)
);
+ &:where([data-selection-range]),
&:where(
[data-line],
[data-line-annotation],
@@ -717,6 +729,7 @@
}
}
+ &:where([data-selection-range]),
&[data-selected-line] {
--diffs-computed-selected-line-bg: light-dark(
color-mix(
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index eeaad5e65..d230daae6 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -37,6 +37,8 @@ export interface FileContents {
export type HighlighterTypes = 'shiki-js' | 'shiki-wasm';
+export type HighlightedToken = [char: number, fg: string, text: string];
+
export type {
BundledLanguage,
CodeToHastOptions,
@@ -623,7 +625,7 @@ export interface ForceFilePlainTextOptions {
startingLine?: number;
totalLines?: number;
// Pre-split lines for caching in windowing scenarios
- lines?: string[];
+ lineOffsets?: number[];
}
export interface RenderFileOptions {
@@ -741,3 +743,43 @@ export interface AppliedThemeStyleCache {
baseThemeType: 'light' | 'dark' | undefined;
scrollbarGutter: number | undefined;
}
+
+export interface DiffsEditor {
+ emitRender(
+ fileContainer: HTMLElement,
+ fileContents: FileContents,
+ lineAnnotations: LineAnnotation[] | undefined,
+ renderRange: RenderRange | undefined
+ ): void;
+ cleanUp(): void;
+}
+
+export interface DiffsEditableComponent {
+ readonly options: BaseCodeOptions;
+ setEditor: (editor: DiffsEditor) => void;
+ setOptions: (options: Partial) => void;
+ setSelectedLines: (range: { start: number; end: number } | null) => void;
+ emitDirtyLines: (
+ themeType: 'dark' | 'light',
+ lines: Map>
+ ) => void;
+ emitLineCountChange: (
+ lineCount: number,
+ newLineAnnotations?: LineAnnotation[]
+ ) => void;
+ removeEditor(): void;
+ rerender(): void;
+ cleanUp(): void;
+}
+
+export interface DiffsEditorSelection {
+ start: {
+ line: number;
+ character: number;
+ };
+ end: {
+ line: number;
+ character: number;
+ };
+ direction: 'none' | 'backward' | 'forward';
+}
diff --git a/packages/diffs/src/utils/cleanLastNewline.ts b/packages/diffs/src/utils/cleanLastNewline.ts
index 7a6220247..f78b42cab 100644
--- a/packages/diffs/src/utils/cleanLastNewline.ts
+++ b/packages/diffs/src/utils/cleanLastNewline.ts
@@ -1,3 +1,10 @@
export function cleanLastNewline(contents: string): string {
- return contents.replace(/\n$|\r\n$/, '');
+ let end = contents.length;
+ if (contents.charAt(end - 1) === '\n') {
+ end--;
+ if (contents.charAt(end - 1) === '\r') {
+ end--;
+ }
+ }
+ return contents.slice(0, end);
}
diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts
new file mode 100644
index 000000000..609090edf
--- /dev/null
+++ b/packages/diffs/src/utils/computeFileOffsets.ts
@@ -0,0 +1,25 @@
+const LINE_FEED = 10; // \n
+const CARRIAGE_RETURN = 13; // \r
+
+/**
+ * Computes line start offsets plus a final end offset for slicing line text.
+ * `lineCount` excludes the final newline-only parser row, except for files
+ * that contain only that row.
+ */
+export function computeLineOffsets(contents: string): number[] {
+ const offsets: number[] = [0];
+ for (let i = 0; i < contents.length; i++) {
+ const char = contents.charCodeAt(i);
+ if (char === LINE_FEED || char === CARRIAGE_RETURN) {
+ if (
+ char === CARRIAGE_RETURN &&
+ i + 1 < contents.length &&
+ contents.charCodeAt(i + 1) === LINE_FEED
+ ) {
+ i++;
+ }
+ offsets.push(i + 1);
+ }
+ }
+ return offsets;
+}
diff --git a/packages/diffs/src/utils/iterateOverFile.ts b/packages/diffs/src/utils/iterateOverFile.ts
deleted file mode 100644
index 60347eedf..000000000
--- a/packages/diffs/src/utils/iterateOverFile.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-export interface IterateOverFileProps {
- lines: string[];
- startingLine?: number;
- totalLines?: number;
- callback: FileLineCallback;
-}
-
-export interface FileLineCallbackProps {
- lineIndex: number; // 0-based index into lines array
- lineNumber: number; // 1-based line number (for display)
- content: string; // The line content string
- isLastLine: boolean; // True if this is the last line
-}
-
-export type FileLineCallback = (props: FileLineCallbackProps) => boolean | void;
-
-/**
- * Iterates over lines in a file with optional windowing support.
- *
- * Similar to `iterateOverDiff` but simplified for linear file content.
- * Supports viewport windowing for virtualization scenarios.
- *
- * @param props - Configuration for iteration
- * @param props.lines - Pre-split array of lines (use splitFileContents() to create from string)
- * @param props.startingLine - Optional starting line index (0-based, default: 0)
- * @param props.totalLines - Optional max lines to iterate (default: Infinity)
- * @param props.callback - Callback invoked for each line in the window.
- * Return `true` to stop iteration early.
- *
- * @example
- * ```typescript
- * const lines = splitFileContents('line1\nline2\nline3');
- * iterateOverFile({
- * lines,
- * startingLine: 0,
- * totalLines: 10,
- * callback: ({ lineIndex, lineNumber, content, isLastLine }) => {
- * console.log(`Line ${lineNumber}: ${content}`);
- * if (content.includes('stop')) return true; // Stop iteration
- * }
- * });
- * ```
- */
-export function iterateOverFile({
- lines,
- startingLine = 0,
- totalLines = Infinity,
- callback,
-}: IterateOverFileProps): void {
- // Calculate viewport window
- const len = Math.min(startingLine + totalLines, lines.length);
- // CLAUDE: DO NOT CHANGE THIS LOGIC UNDER ANY
- // CIRCUMSTANCE CHEESE N RICE
- const lastLineIndex = (() => {
- const lastLine = lines.at(-1);
- if (
- lastLine === '' ||
- lastLine === '\n' ||
- lastLine === '\r\n' ||
- lastLine === '\r'
- ) {
- return Math.max(0, lines.length - 2);
- }
- return lines.length - 1;
- })();
-
- // Iterate through windowed range
- for (let lineIndex = startingLine; lineIndex < len; lineIndex++) {
- const isLastLine = lineIndex === lastLineIndex;
- if (
- callback({
- lineIndex,
- lineNumber: lineIndex + 1,
- content: lines[lineIndex],
- isLastLine,
- }) === true ||
- isLastLine
- ) {
- break;
- }
- }
-}
diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts
index 5b4f83656..46a58de33 100644
--- a/packages/diffs/src/utils/renderFileWithHighlighter.ts
+++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts
@@ -8,14 +8,12 @@ import type {
RenderFileOptions,
ThemedFileResult,
} from '../types';
-import { cleanLastNewline } from './cleanLastNewline';
+import { computeLineOffsets } from './computeFileOffsets';
import { createTransformerWithState } from './createTransformerWithState';
import { formatCSSVariablePrefix } from './formatCSSVariablePrefix';
import { getFiletypeFromFileName } from './getFiletypeFromFileName';
import { getHighlighterThemeStyles } from './getHighlighterThemeStyles';
import { getLineNodes } from './getLineNodes';
-import { iterateOverFile } from './iterateOverFile';
-import { splitFileContents } from './splitFileContents';
const DEFAULT_PLAIN_TEXT_OPTIONS: ForceFilePlainTextOptions = {
forcePlainText: false,
@@ -33,7 +31,7 @@ export function renderFileWithHighlighter(
forcePlainText,
startingLine,
totalLines,
- lines,
+ lineOffsets,
}: ForceFilePlainTextOptions = DEFAULT_PLAIN_TEXT_OPTIONS
): ThemedFileResult {
if (forcePlainText) {
@@ -85,14 +83,17 @@ export function renderFileWithHighlighter(
};
})();
const highlightedLines = getLineNodes(
+ // TODO(@ije): use `grammar.tokenizeLine2` to replace `codeToHast` for better performance,
+ // use lines.offsets for line text extraction without concatenating strings
highlighter.codeToHast(
isWindowedHighlight
? extractWindowedFileContent(
- lines ?? splitFileContents(file.contents),
+ file,
+ lineOffsets ?? computeLineOffsets(file.contents),
startingLine,
totalLines
)
- : cleanLastNewline(file.contents),
+ : file.contents,
hastConfig
)
);
@@ -107,18 +108,16 @@ export function renderFileWithHighlighter(
}
function extractWindowedFileContent(
- lines: string[],
+ file: FileContents,
+ lineOffsets: number[],
startingLine: number,
totalLines: number
): string {
- let windowContent: string = '';
- iterateOverFile({
- lines,
- startingLine,
- totalLines,
- callback({ content }) {
- windowContent += content;
- },
- });
- return windowContent;
+ if (lineOffsets.length === 0) {
+ return '';
+ }
+ const endLine = Math.min(startingLine + totalLines, lineOffsets.length);
+ const startOffset = lineOffsets[startingLine] ?? file.contents.length;
+ const endOffset = lineOffsets[endLine] ?? file.contents.length;
+ return file.contents.slice(startOffset, endOffset);
}
diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts
index 405659bfa..1c33c9e4f 100644
--- a/packages/diffs/src/worker/WorkerPoolManager.ts
+++ b/packages/diffs/src/worker/WorkerPoolManager.ts
@@ -546,7 +546,7 @@ export class WorkerPoolManager {
file: FileContents,
startingLine: number,
totalLines: number,
- lines?: string[]
+ lineOffsets: number[]
): ThemedFileResult | undefined {
if (this.highlighter == null) {
this.queueInitialization();
@@ -556,7 +556,7 @@ export class WorkerPoolManager {
file,
this.highlighter,
this.renderOptions,
- { forcePlainText: true, startingLine, totalLines, lines }
+ { forcePlainText: true, startingLine, totalLines, lineOffsets }
);
}
diff --git a/packages/diffs/test/FileRenderer.ast.test.ts b/packages/diffs/test/FileRenderer.ast.test.ts
index 56fae37df..11f284f94 100644
--- a/packages/diffs/test/FileRenderer.ast.test.ts
+++ b/packages/diffs/test/FileRenderer.ast.test.ts
@@ -147,6 +147,20 @@ describe('FileRenderer AST Structure', () => {
expect(result2.totalLines).toBe(file2Lines);
});
+ test('should render one content line when the buffer ends with a newline', async () => {
+ const instance = new FileRenderer();
+ const result = await instance.asyncRender({
+ name: 'single-line.txt',
+ contents: 'hello\n',
+ });
+ const [gutter, contentColumn] = instance.renderCodeAST(result) as Element[];
+
+ expect(result.totalLines).toBe(2);
+ expect(result.rowCount).toBe(2);
+ expect(gutter.children).toHaveLength(2);
+ expect(contentColumn.children).toHaveLength(2);
+ });
+
test('should include CSS property in result', async () => {
const instance = new FileRenderer();
const result = await instance.asyncRender(mockFiles.file2);
diff --git a/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap b/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap
index 3ec26b069..471a92e2b 100644
--- a/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap
+++ b/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap
@@ -388,10 +388,34 @@ exports[`FileRenderer should render TypeScript code to AST matching snapshot 1`]
"tagName": "div",
"type": "element",
},
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "type": "text",
+ "value": "17",
+ },
+ ],
+ "properties": {
+ "data-line-number-content": "",
+ },
+ "tagName": "span",
+ "type": "element",
+ },
+ ],
+ "properties": {
+ "data-column-number": 17,
+ "data-line-index": "16",
+ "data-line-type": "context",
+ },
+ "tagName": "div",
+ "type": "element",
+ },
],
"properties": {
"data-gutter": "",
- "style": "grid-row: span 16",
+ "style": "grid-row: span 17",
},
"tagName": "div",
"type": "element",
@@ -1598,10 +1622,29 @@ exports[`FileRenderer should render TypeScript code to AST matching snapshot 1`]
"tagName": "div",
"type": "element",
},
+ {
+ "children": [
+ {
+ "type": "text",
+ "value":
+"
+"
+,
+ },
+ ],
+ "properties": {
+ "data-alt-line": undefined,
+ "data-line": 17,
+ "data-line-index": 16,
+ "data-line-type": "context",
+ },
+ "tagName": "div",
+ "type": "element",
+ },
],
"properties": {
"data-content": "",
- "style": "grid-row: span 16",
+ "style": "grid-row: span 17",
},
"tagName": "div",
"type": "element",
diff --git a/packages/diffs/test/editStack.test.ts b/packages/diffs/test/editStack.test.ts
new file mode 100644
index 000000000..3f0215684
--- /dev/null
+++ b/packages/diffs/test/editStack.test.ts
@@ -0,0 +1,175 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { EditorSelection } from '../src/editor/editorSelection';
+import {
+ DirectionNone,
+ type SelectionDirection,
+} from '../src/editor/editorSelection';
+import { createEditStackEntry, EditStack } from '../src/editor/editStack';
+import { TextDocument } from '../src/editor/textDocument';
+
+function createSelection(
+ startLine: number,
+ startCharacter: number,
+ endLine: number,
+ endCharacter: number,
+ direction: SelectionDirection = DirectionNone
+): EditorSelection {
+ return {
+ start: { line: startLine, character: startCharacter },
+ end: { line: endLine, character: endCharacter },
+ direction,
+ };
+}
+
+function caret(character: number) {
+ return createSelection(0, character, 0, character, DirectionNone);
+}
+
+function stackEntry(
+ textBeforeEdit: string,
+ resolvedEdits: { start: number; end: number; text: string }[],
+ versionBefore: number,
+ versionAfter: number,
+ selectionsBefore?: EditorSelection[],
+ selectionsAfter?: EditorSelection[]
+) {
+ const doc = new TextDocument(
+ 'inmemory://edit-stack-test',
+ textBeforeEdit,
+ 'plain',
+ versionBefore
+ );
+ return createEditStackEntry(
+ doc,
+ resolvedEdits,
+ versionBefore,
+ versionAfter,
+ selectionsBefore,
+ selectionsAfter
+ );
+}
+
+describe('EditHistory', () => {
+ test('push stores cloned selections and pop methods move entries between stacks', () => {
+ const editStack = new EditStack();
+ const selectionBefore = [caret(0), caret(1)];
+ const selectionAfter = [caret(2), caret(3)];
+
+ editStack.push(
+ stackEntry(
+ 'ab',
+ [{ start: 1, end: 1, text: 'X' }],
+ 4,
+ 5,
+ selectionBefore,
+ selectionAfter
+ )
+ );
+
+ selectionBefore[0] = caret(99);
+ selectionAfter[0] = caret(99);
+
+ expect(editStack.canUndo).toBe(true);
+ expect(editStack.canRedo).toBe(false);
+
+ const entry = editStack.popUndoToRedo();
+
+ expect(entry).toEqual({
+ forwardEdits: [{ start: 1, end: 1, text: 'X' }],
+ inverseEdits: [{ start: 1, end: 2, text: '' }],
+ versionBefore: 4,
+ versionAfter: 5,
+ selectionsBefore: [caret(0), caret(1)],
+ selectionsAfter: [caret(2), caret(3)],
+ });
+ expect(editStack.canUndo).toBe(false);
+ expect(editStack.canRedo).toBe(true);
+
+ expect(editStack.popRedoToUndo()).toEqual(entry);
+ expect(editStack.canUndo).toBe(true);
+ expect(editStack.canRedo).toBe(false);
+ });
+
+ test('setLastUndoSelectionsAfter stores cloned redo selections', () => {
+ const editStack = new EditStack();
+ let selectionAfter = caret(2);
+
+ editStack.push(
+ stackEntry(
+ 'a',
+ [{ start: 1, end: 1, text: 'b' }],
+ 1,
+ 2,
+ [caret(1)],
+ [selectionAfter]
+ )
+ );
+ selectionAfter = caret(99);
+
+ expect(editStack.popUndoToRedo()).toMatchObject({
+ selectionsAfter: [caret(2)],
+ });
+ });
+
+ test('push clears redo history when recording a new undo entry', () => {
+ const editStack = new EditStack();
+
+ editStack.push(
+ stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)])
+ );
+ editStack.push(
+ stackEntry('a', [{ start: 1, end: 1, text: 'b' }], 1, 2, [caret(1)])
+ );
+
+ expect(editStack.popUndoToRedo()).toMatchObject({
+ forwardEdits: [{ start: 1, end: 1, text: 'b' }],
+ });
+ expect(editStack.canRedo).toBe(true);
+
+ editStack.push(
+ stackEntry('a', [{ start: 1, end: 1, text: 'c' }], 1, 2, [caret(1)])
+ );
+
+ expect(editStack.canRedo).toBe(false);
+ expect(editStack.popUndoToRedo()).toMatchObject({
+ forwardEdits: [{ start: 1, end: 1, text: 'c' }],
+ });
+ expect(editStack.popUndoToRedo()).toMatchObject({
+ forwardEdits: [{ start: 0, end: 0, text: 'a' }],
+ });
+ });
+
+ test('maxEntries drops oldest undo history first', () => {
+ const editStack = new EditStack({ maxEntries: 3 });
+
+ for (let i = 0; i < 4; i++) {
+ editStack.push(
+ stackEntry('', [{ start: 0, end: 0, text: `${i}` }], i, i + 1, [
+ caret(0),
+ ])
+ );
+ }
+
+ const third = editStack.popUndoToRedo();
+ expect(third?.forwardEdits[0]?.text).toBe('3');
+ expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('2');
+ expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('1');
+ expect(editStack.popUndoToRedo()).toBeUndefined();
+ });
+
+ test('clear resets both undo and redo stacks', () => {
+ const editStack = new EditStack();
+
+ editStack.push(
+ stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)])
+ );
+ editStack.popUndoToRedo();
+ editStack.clear();
+
+ expect(editStack.canUndo).toBe(false);
+ expect(editStack.canRedo).toBe(false);
+ expect(editStack.popUndoToRedo()).toBeUndefined();
+ expect(editStack.popRedoToUndo()).toBeUndefined();
+ });
+});
diff --git a/packages/diffs/test/editorCommand.test.ts b/packages/diffs/test/editorCommand.test.ts
new file mode 100644
index 000000000..8dc5fcf97
--- /dev/null
+++ b/packages/diffs/test/editorCommand.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ type EditorCommand,
+ resolveEditorCommandFromKeyboardEvent,
+} from '../src/editor/editorCommand';
+
+type ShortcutKeyboardEvent = Pick<
+ KeyboardEvent,
+ 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key'
+>;
+type ShortcutCase = {
+ event: Partial & Pick;
+ expected: EditorCommand | undefined;
+};
+
+function event({
+ key,
+ ...overrides
+}: Partial &
+ Pick): KeyboardEvent {
+ return {
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ ...overrides,
+ key,
+ } as KeyboardEvent;
+}
+
+function withPlatform(platform: string, run: () => void): void {
+ const navigator = globalThis.navigator;
+ const originalPlatform = navigator.platform;
+ Object.defineProperty(navigator, 'platform', {
+ configurable: true,
+ value: platform,
+ });
+
+ try {
+ run();
+ } finally {
+ Object.defineProperty(navigator, 'platform', {
+ configurable: true,
+ value: originalPlatform,
+ });
+ }
+}
+
+function expectShortcuts(platform: string, cases: ShortcutCase[]): void {
+ const isMac = /macOS|MacIntel|iPhone|iPad|iPod/i.test(platform);
+ withPlatform(platform, () => {
+ for (const { event: shortcutEvent, expected } of cases) {
+ expect(
+ resolveEditorCommandFromKeyboardEvent(event(shortcutEvent), isMac)
+ ).toBe(expected);
+ }
+ });
+}
+
+describe('resolveEditorShortcutCommand', () => {
+ test('uses command shortcuts on macOS', () => {
+ expectShortcuts('MacIntel', [
+ { event: { key: 'z', metaKey: true }, expected: 'undo' },
+ { event: { key: 'z', metaKey: true, shiftKey: true }, expected: 'redo' },
+ { event: { key: 'a', metaKey: true }, expected: 'selectAll' },
+ {
+ event: { key: 'ArrowUp', metaKey: true },
+ expected: 'moveCursorToDocStart',
+ },
+ {
+ event: { key: 'ArrowDown', metaKey: true },
+ expected: 'moveCursorToDocEnd',
+ },
+ ]);
+ });
+
+ test('uses control shortcuts on windows and linux', () => {
+ expectShortcuts('Linux x86_64', [
+ { event: { key: 'z', ctrlKey: true }, expected: 'undo' },
+ { event: { key: 'z', ctrlKey: true, shiftKey: true }, expected: 'redo' },
+ { event: { key: 'y', ctrlKey: true }, expected: 'redo' },
+ { event: { key: 'a', ctrlKey: true }, expected: 'selectAll' },
+ {
+ event: { key: 'Home', ctrlKey: true },
+ expected: 'moveCursorToDocStart',
+ },
+ { event: { key: 'End', ctrlKey: true }, expected: 'moveCursorToDocEnd' },
+ ]);
+ });
+
+ test('ignores modified alt shortcuts and unsupported navigation', () => {
+ expectShortcuts('Linux x86_64', [
+ { event: { key: 'ArrowUp', ctrlKey: true }, expected: undefined },
+ { event: { key: 'z', ctrlKey: true, altKey: true }, expected: undefined },
+ ]);
+ });
+
+ test('maps tab and shift+tab without primary modifier', () => {
+ expectShortcuts('Linux x86_64', [
+ { event: { key: 'Tab' }, expected: 'indent' },
+ { event: { key: 'Tab', shiftKey: true }, expected: 'outdent' },
+ { event: { key: 'Tab', ctrlKey: true }, expected: undefined },
+ ]);
+ });
+});
diff --git a/packages/diffs/test/editorLineAnnotations.test.ts b/packages/diffs/test/editorLineAnnotations.test.ts
new file mode 100644
index 000000000..f28585ae7
--- /dev/null
+++ b/packages/diffs/test/editorLineAnnotations.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test } from 'bun:test';
+
+import { applyDocumentChangeToLineAnnotations } from '../src/editor/editorLineAnnotations';
+import { TextDocument } from '../src/editor/textDocument';
+import type { LineAnnotation } from '../src/types';
+
+describe('applyDocumentChangeToLineAnnotations', () => {
+ test('deletes annotations attached to deleted lines', () => {
+ const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree');
+ const annotations: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'one' },
+ { lineNumber: 2, metadata: 'two' },
+ { lineNumber: 3, metadata: 'three' },
+ ];
+
+ const change = textDocument.applyEdits([
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 2, character: 0 },
+ },
+ newText: '',
+ },
+ ]);
+
+ expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([
+ { lineNumber: 1, metadata: 'one' },
+ { lineNumber: 2, metadata: 'three' },
+ ]);
+ });
+
+ test('moves annotations down when lines are inserted above them', () => {
+ const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree');
+ const annotations: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'one' },
+ { lineNumber: 2, metadata: 'two' },
+ { lineNumber: 3, metadata: 'three' },
+ ];
+
+ const change = textDocument.applyEdits([
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 0 },
+ },
+ newText: 'inserted\n',
+ },
+ ]);
+
+ expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([
+ { lineNumber: 1, metadata: 'one' },
+ { lineNumber: 3, metadata: 'two' },
+ { lineNumber: 4, metadata: 'three' },
+ ]);
+ });
+
+ test('returns null when annotations do not move', () => {
+ const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree');
+ const annotations: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'one' },
+ ];
+
+ const change = textDocument.applyEdits([
+ {
+ range: {
+ start: { line: 2, character: 0 },
+ end: { line: 2, character: 0 },
+ },
+ newText: 'inserted\n',
+ },
+ ]);
+
+ expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual(
+ annotations
+ );
+ });
+});
diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts
new file mode 100644
index 000000000..de7a9034a
--- /dev/null
+++ b/packages/diffs/test/editorSelection.test.ts
@@ -0,0 +1,937 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ applyTextChangeToSelections,
+ applyTextReplaceToSelections,
+ convertSelection,
+ createSelectionFrom,
+ DirectionForward,
+ DirectionNone,
+ type EditorSelection,
+ extendSelection,
+ findNexMatch,
+ mapCursorMove,
+ mapSelectionShift,
+ selectionIntersects,
+} from '../src/editor/editorSelection';
+import {
+ DirectionBackward,
+ type SelectionDirection,
+} from '../src/editor/editorSelection';
+import { TextDocument } from '../src/editor/textDocument';
+
+type MockNode = {
+ nodeType: number;
+ tagName?: string;
+ parentElement?: MockElement | null;
+ children?: MockElement[];
+ childNodes?: MockNode[];
+ textContent?: string | null;
+};
+
+type MockElement = MockNode & {
+ tagName: string;
+ parentElement?: MockElement | null;
+ children: MockElement[];
+ childNodes: MockNode[];
+ dataset: Record;
+};
+
+function composedRange(
+ startContainer: Node,
+ startOffset: number,
+ endContainer = startContainer,
+ endOffset = startOffset
+): StaticRange {
+ return {
+ startContainer,
+ startOffset,
+ endContainer,
+ endOffset,
+ collapsed: startContainer === endContainer && startOffset === endOffset,
+ } as StaticRange;
+}
+
+function editorSelection(
+ startLine: number,
+ startCharacter: number,
+ endLine: number,
+ endCharacter: number
+): EditorSelection {
+ return {
+ start: { line: startLine, character: startCharacter },
+ end: { line: endLine, character: endCharacter },
+ direction: DirectionForward,
+ };
+}
+
+function createSelection(
+ startLine: number,
+ startCharacter: number,
+ endLine: number,
+ endCharacter: number,
+ direction: SelectionDirection = DirectionNone
+): EditorSelection {
+ return {
+ start: { line: startLine, character: startCharacter },
+ end: { line: endLine, character: endCharacter },
+ direction,
+ };
+}
+
+function pre(line: number, children: MockElement[] = []): MockElement {
+ const element: MockElement = {
+ nodeType: 1,
+ tagName: 'DIV',
+ parentElement: null,
+ children,
+ childNodes: children,
+ textContent: null,
+ dataset: { lineIndex: String(line) },
+ };
+ for (const child of children) {
+ child.parentElement = element;
+ }
+ return element;
+}
+
+function text(textContent: string): MockNode {
+ return {
+ nodeType: 3,
+ textContent,
+ };
+}
+
+function line(line: number, childNodes: MockNode[]): MockElement {
+ const element = pre(
+ line,
+ childNodes.filter((child): child is MockElement => child.nodeType === 1)
+ );
+ element.childNodes = childNodes;
+ element.textContent = childNodes
+ .map((child) => child.textContent ?? '')
+ .join('');
+ for (const child of childNodes) {
+ child.parentElement = element;
+ }
+ return element;
+}
+
+function br(): MockElement {
+ return {
+ nodeType: 1,
+ tagName: 'BR',
+ parentElement: null,
+ children: [],
+ childNodes: [],
+ textContent: '',
+ dataset: {},
+ };
+}
+
+function span(text: string, char?: number): MockElement {
+ const textNode: MockNode = {
+ nodeType: 3,
+ textContent: text,
+ };
+ const element: MockElement = {
+ nodeType: 1,
+ tagName: 'SPAN',
+ parentElement: null,
+ children: [],
+ childNodes: [textNode],
+ textContent: text,
+ dataset: {},
+ };
+ textNode.parentElement = element;
+ if (char !== undefined) {
+ element.dataset.char = String(char);
+ }
+ return element;
+}
+
+function button(text: string): MockElement {
+ const textNode: MockNode = {
+ nodeType: 3,
+ textContent: text,
+ };
+ const element: MockElement = {
+ nodeType: 1,
+ tagName: 'BUTTON',
+ parentElement: null,
+ children: [],
+ childNodes: [textNode],
+ textContent: text,
+ dataset: {},
+ };
+ textNode.parentElement = element;
+ return element;
+}
+
+function element(tagName: string, children: MockNode[] = []): MockElement {
+ const el: MockElement = {
+ nodeType: 1,
+ tagName,
+ parentElement: null,
+ children: children.filter(
+ (child): child is MockElement => child.nodeType === 1
+ ),
+ childNodes: children,
+ textContent: children.map((child) => child.textContent ?? '').join(''),
+ dataset: {},
+ };
+ for (const child of children) {
+ child.parentElement = el;
+ }
+ return el;
+}
+
+describe('convertSelection', () => {
+ test('maps a caret on an empty rendered line to character zero', () => {
+ const line = pre(1, [br()]);
+ expect(convertSelection(composedRange(line as unknown as Node, 0))).toEqual(
+ {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ });
+
+ test('treats a placeholder br boundary as the start of the line', () => {
+ const line = pre(2, [br()]);
+ expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual(
+ {
+ start: { line: 2, character: 0 },
+ end: { line: 2, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ });
+
+ test('ignores the line number gutter span on an empty line', () => {
+ const line = pre(3, [span('4'), br()]);
+ expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual(
+ {
+ start: { line: 3, character: 0 },
+ end: { line: 3, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual(
+ {
+ start: { line: 3, character: 0 },
+ end: { line: 3, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ });
+
+ test('ignores the fold toggle button in the gutter', () => {
+ const line = pre(4, [span('5'), button('>'), span('color', 0)]);
+ expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual(
+ {
+ start: { line: 4, character: 0 },
+ end: { line: 4, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ });
+
+ test('maps a direct line text node to its character offset', () => {
+ const textNode = text('abcdef');
+ line(6, [textNode]);
+ expect(
+ convertSelection(composedRange(textNode as unknown as Node, 2))
+ ).toEqual({
+ start: { line: 6, character: 2 },
+ end: { line: 6, character: 2 },
+ direction: DirectionNone,
+ });
+ });
+
+ test('maps a span text node from its data-char base', () => {
+ const token = span('abcdef', 10);
+ const textNode = token.childNodes[0];
+ pre(7, [token]);
+ expect(
+ convertSelection(composedRange(textNode as unknown as Node, 3))
+ ).toEqual({
+ start: { line: 7, character: 13 },
+ end: { line: 7, character: 13 },
+ direction: DirectionNone,
+ });
+ });
+
+ test('ignores newline placeholders in direct line text nodes', () => {
+ const textNode = text('\n');
+ line(8, [textNode]);
+ expect(
+ convertSelection(composedRange(textNode as unknown as Node, 1))
+ ).toEqual({
+ start: { line: 8, character: 0 },
+ end: { line: 8, character: 0 },
+ direction: DirectionNone,
+ });
+ });
+
+ test('maps clicks inside a fold button on an empty line to character zero', () => {
+ const icon = element('SVG', [element('POLYLINE')]);
+ const toggle = element('BUTTON', [icon]);
+ pre(5, [span('6'), toggle, br()]);
+ expect(
+ convertSelection(composedRange(toggle as unknown as Node, 0))
+ ).toEqual({
+ start: { line: 5, character: 0 },
+ end: { line: 5, character: 0 },
+ direction: DirectionNone,
+ });
+ expect(convertSelection(composedRange(icon as unknown as Node, 0))).toEqual(
+ {
+ start: { line: 5, character: 0 },
+ end: { line: 5, character: 0 },
+ direction: DirectionNone,
+ }
+ );
+ });
+});
+
+describe('selectionIntersects', () => {
+ test('detects overlapping ranges on the same line', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 6),
+ editorSelection(0, 4, 0, 8)
+ )
+ ).toBe(true);
+ });
+
+ test('detects overlapping ranges across lines', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 2, 3),
+ editorSelection(1, 0, 3, 1)
+ )
+ ).toBe(true);
+ });
+
+ test('does not treat adjacent range boundaries as intersections', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 6),
+ editorSelection(0, 6, 0, 8)
+ )
+ ).toBe(false);
+ });
+
+ test('does not intersect separated ranges', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 4),
+ editorSelection(1, 0, 1, 2)
+ )
+ ).toBe(false);
+ });
+
+ test('treats a caret inside a range as an intersection', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 6),
+ editorSelection(0, 4, 0, 4)
+ )
+ ).toBe(true);
+ });
+
+ test('treats a caret on a range boundary as an intersection', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 6),
+ editorSelection(0, 6, 0, 6)
+ )
+ ).toBe(true);
+ });
+
+ test('matches collapsed selections only at the same position', () => {
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 2),
+ editorSelection(0, 2, 0, 2)
+ )
+ ).toBe(true);
+ expect(
+ selectionIntersects(
+ editorSelection(0, 2, 0, 2),
+ editorSelection(0, 3, 0, 3)
+ )
+ ).toBe(false);
+ });
+});
+
+describe('extendSelection', () => {
+ test('extends a collapsed selection forward', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 3, DirectionNone),
+ createSelection(2, 10, 2, 10, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 3, 2, 10, DirectionForward));
+ });
+
+ test('extends a collapsed selection backward', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 3, DirectionNone),
+ createSelection(2, 1, 2, 1, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 1, 2, 3, DirectionBackward));
+ });
+
+ test('extends forward when shift-click lands after the original anchor', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionForward),
+ createSelection(2, 10, 2, 10, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 3, 2, 10, DirectionForward));
+ });
+
+ test('left extend spans from target through original end (forward original)', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionForward),
+ createSelection(2, 1, 2, 1, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 1, 2, 8, DirectionBackward));
+ });
+
+ test('right extend spans from original start through target (backward original)', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionBackward),
+ createSelection(2, 10, 2, 10, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 3, 2, 10, DirectionForward));
+ });
+
+ test('keeps the original anchored edge when shift-click lands inside the range', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionForward),
+ createSelection(2, 5, 2, 5, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 3, 2, 5, DirectionForward));
+ });
+
+ test('keeps the backward anchor stable when shift-click lands inside the range', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionBackward),
+ createSelection(2, 5, 2, 5, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 5, 2, 8, DirectionBackward));
+ });
+
+ test('collapses a forward selection when shift-click lands on its anchor', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionForward),
+ createSelection(2, 3, 2, 3, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 3, 2, 3, DirectionNone));
+ });
+
+ test('collapses a backward selection when shift-click lands on its anchor', () => {
+ expect(
+ extendSelection(
+ createSelection(2, 3, 2, 8, DirectionBackward),
+ createSelection(2, 8, 2, 8, DirectionNone)
+ )
+ ).toEqual(createSelection(2, 8, 2, 8, DirectionNone));
+ });
+});
+
+describe('createSelectionFrom', () => {
+ test('keeps forward direction when drag focus moves after anchor', () => {
+ const start = createSelection(2, 3, 2, 3, DirectionNone);
+ const current = createSelection(2, 3, 2, 8, DirectionNone);
+ expect(createSelectionFrom(start, current)).toEqual(
+ createSelection(2, 3, 2, 8, DirectionForward)
+ );
+ });
+
+ test('produces backward direction when drag focus moves before anchor', () => {
+ const start = createSelection(2, 8, 2, 8, DirectionNone);
+ const current = createSelection(2, 3, 2, 8, DirectionNone);
+ expect(createSelectionFrom(start, current)).toEqual(
+ createSelection(2, 3, 2, 8, DirectionBackward)
+ );
+ });
+
+ test('uses backward start anchor when selection already has direction', () => {
+ const start = createSelection(1, 2, 1, 6, DirectionBackward);
+ const current = createSelection(1, 0, 1, 6, DirectionNone);
+ expect(createSelectionFrom(start, current)).toEqual(
+ createSelection(1, 0, 1, 6, DirectionBackward)
+ );
+ });
+});
+
+describe('applyTextChangeToSelections', () => {
+ test('inserts the same text at multiple carets', () => {
+ const textDocument = new TextDocument('inmemory://1', 'a\nb\nc');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 5,
+ end: 5,
+ text: '!',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('a!\nb!\nc!');
+ expect(nextSelections).toEqual([
+ createSelection(0, 2, 0, 2),
+ createSelection(1, 2, 1, 2),
+ createSelection(2, 2, 2, 2),
+ ]);
+ });
+
+ test('replaces each selected range with the typed text', () => {
+ const textDocument = new TextDocument('inmemory://1', 'foo bar baz');
+ const selections = [
+ createSelection(0, 0, 0, 3, DirectionForward),
+ createSelection(0, 4, 0, 7, DirectionForward),
+ createSelection(0, 8, 0, 11, DirectionForward),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 8,
+ end: 11,
+ text: 'x',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('x x x');
+ expect(nextSelections).toEqual([
+ createSelection(0, 1, 0, 1),
+ createSelection(0, 3, 0, 3),
+ createSelection(0, 5, 0, 5),
+ ]);
+ });
+
+ test('mirrors backspace for multiple carets', () => {
+ const textDocument = new TextDocument('inmemory://1', 'ax\nbx\ncx');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 6,
+ end: 7,
+ text: '',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('x\nx\nx');
+ expect(nextSelections).toEqual([
+ createSelection(0, 0, 0, 0),
+ createSelection(1, 0, 1, 0),
+ createSelection(2, 0, 2, 0),
+ ]);
+ });
+
+ test('mirrors delete for multiple carets', () => {
+ const textDocument = new TextDocument('inmemory://1', 'xa\nxb\nxc');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 7,
+ end: 8,
+ text: '',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('x\nx\nx');
+ expect(nextSelections).toEqual([
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ]);
+ });
+
+ test('deletes explicit ranges across multiple selections', () => {
+ const textDocument = new TextDocument('inmemory://1', 'abc def ghi');
+ const selections = [
+ createSelection(0, 1, 0, 3),
+ createSelection(0, 5, 0, 7),
+ createSelection(0, 9, 0, 11),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 9,
+ end: 11,
+ text: '',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('a d g');
+ expect(nextSelections).toEqual([
+ createSelection(0, 1, 0, 1),
+ createSelection(0, 3, 0, 3),
+ createSelection(0, 5, 0, 5),
+ ]);
+ });
+
+ test('coalesces transformed edits that would overlap', () => {
+ const textDocument = new TextDocument('inmemory://1', ' ');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(0, 2, 0, 2),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 0,
+ end: 2,
+ text: '',
+ }
+ );
+
+ expect(textDocument.getText()).toBe(' ');
+ expect(nextSelections).toEqual([
+ createSelection(0, 0, 0, 0),
+ createSelection(0, 0, 0, 0),
+ ]);
+ });
+
+ test('places the caret on the inserted blank line after Enter', () => {
+ const textDocument = new TextDocument('inmemory://1', 'foo\nbar');
+ const selections = [createSelection(0, 3, 0, 3)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 3,
+ end: 3,
+ text: '\n',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('foo\n\nbar');
+ expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]);
+ });
+
+ test('copies leading indentation onto the new line after Enter', () => {
+ const textDocument = new TextDocument('inmemory://1', ' foo\nbar');
+ const selections = [createSelection(0, 5, 0, 5)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 5,
+ end: 5,
+ text: '\n',
+ }
+ );
+
+ expect(textDocument.getText()).toBe(' foo\n \nbar');
+ expect(nextSelections).toEqual([createSelection(1, 2, 1, 2)]);
+ });
+
+ test("uses each line's indent when inserting a newline at multiple carets", () => {
+ const textDocument = new TextDocument('inmemory://1', ' a\n\tb');
+ const selections = [
+ createSelection(0, 3, 0, 3),
+ createSelection(1, 2, 1, 2),
+ ];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 6,
+ end: 6,
+ text: '\n',
+ }
+ );
+
+ expect(textDocument.getText()).toBe(' a\n \n\tb\n\t');
+ expect(nextSelections).toEqual([
+ createSelection(1, 2, 1, 2),
+ createSelection(3, 1, 3, 1),
+ ]);
+ });
+
+ test('moves the caret to the previous line end after deleting a line break', () => {
+ const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar');
+ const selections = [createSelection(1, 0, 1, 0)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 3,
+ end: 4,
+ text: '',
+ }
+ );
+
+ expect(textDocument.getText()).toBe('foo\nbar');
+ expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]);
+ });
+
+ test('deletes one hard tab when backspacing in leading indentation', () => {
+ const textDocument = new TextDocument('inmemory://1', '\tfoo');
+ const selections = [createSelection(0, 1, 0, 1)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 0,
+ end: 1,
+ text: '',
+ },
+ undefined,
+ 2
+ );
+
+ expect(textDocument.getText()).toBe('foo');
+ expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]);
+ });
+
+ test('deletes one soft tab when backspacing in leading indentation', () => {
+ const textDocument = new TextDocument('inmemory://1', ' foo');
+ const selections = [createSelection(0, 4, 0, 4)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 3,
+ end: 4,
+ text: '',
+ },
+ undefined,
+ 4
+ );
+
+ expect(textDocument.getText()).toBe('foo');
+ expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]);
+ });
+
+ test('does not expand deletion outside leading indentation', () => {
+ const textDocument = new TextDocument('inmemory://1', ' foo');
+ const selections = [createSelection(0, 3, 0, 3)];
+ const { nextSelections } = applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: 2,
+ end: 3,
+ text: '',
+ },
+ undefined,
+ 2
+ );
+
+ expect(textDocument.getText()).toBe(' oo');
+ expect(nextSelections).toEqual([createSelection(0, 2, 0, 2)]);
+ });
+});
+
+describe('mapSelectionMove', () => {
+ test('moves all carets when the primary caret moves', () => {
+ const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ];
+
+ expect(
+ mapCursorMove(textDocument, selections, { line: 2, character: 0 })
+ ).toEqual([
+ createSelection(0, 0, 0, 0),
+ createSelection(1, 0, 1, 0),
+ createSelection(2, 0, 2, 0),
+ ]);
+ });
+
+ test('extends all selections when the primary selection grows', () => {
+ const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh');
+ const selections = [
+ createSelection(0, 1, 0, 2, DirectionForward),
+ createSelection(1, 1, 1, 2, DirectionForward),
+ ];
+
+ expect(
+ mapCursorMove(textDocument, selections, { line: 1, character: 1 })
+ ).toEqual([
+ createSelection(0, 1, 0, 1, DirectionNone),
+ createSelection(1, 1, 1, 1, DirectionNone),
+ ]);
+ });
+});
+
+describe('mapSelectionRangeMove', () => {
+ test('extends all carets when the primary textarea selection becomes a range', () => {
+ const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ ];
+
+ expect(
+ mapSelectionShift(textDocument, selections, createSelection(1, 1, 1, 3))
+ ).toEqual([
+ createSelection(0, 1, 0, 3, DirectionForward),
+ createSelection(1, 1, 1, 3, DirectionForward),
+ ]);
+ });
+
+ test('preserves backward selection direction from the textarea focus', () => {
+ const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh');
+ const selections = [
+ createSelection(0, 2, 0, 2),
+ createSelection(1, 2, 1, 2),
+ ];
+
+ expect(
+ mapSelectionShift(textDocument, selections, createSelection(1, 2, 1, 0))
+ ).toEqual([
+ createSelection(0, 0, 0, 2, DirectionBackward),
+ createSelection(1, 0, 1, 2, DirectionBackward),
+ ]);
+ });
+
+ test('maps a normalized backward range using selection direction', () => {
+ const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh');
+ const selections = [
+ createSelection(0, 2, 0, 2),
+ createSelection(1, 2, 1, 2),
+ ];
+ const shift: EditorSelection = {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 2 },
+ direction: DirectionBackward,
+ };
+
+ expect(mapSelectionShift(textDocument, selections, shift)).toEqual([
+ createSelection(0, 0, 0, 2, DirectionBackward),
+ createSelection(1, 0, 1, 2, DirectionBackward),
+ ]);
+ });
+});
+
+describe('applyTextReplaceToSelections', () => {
+ test('replaces each selection with its own pasted text', () => {
+ const textDocument = new TextDocument('inmemory://1', 'x\ny\nz');
+ const selections = [
+ createSelection(0, 1, 0, 1),
+ createSelection(1, 1, 1, 1),
+ createSelection(2, 1, 2, 1),
+ ];
+ const { nextSelections } = applyTextReplaceToSelections(
+ textDocument,
+ selections,
+ ['a', 'b', 'c']
+ );
+
+ expect(textDocument.getText()).toBe('xa\nyb\nzc');
+ expect(nextSelections).toEqual([
+ createSelection(0, 2, 0, 2),
+ createSelection(1, 2, 1, 2),
+ createSelection(2, 2, 2, 2),
+ ]);
+ });
+});
+
+describe('findNextMatch', () => {
+ test('returns undefined for empty selections', () => {
+ const doc = new TextDocument('inmemory://x', 'hello');
+ expect(findNexMatch(doc, [])).toBeUndefined();
+ });
+
+ test('ignores non-collapsed selections with different text', () => {
+ const doc = new TextDocument('inmemory://x', 'aa bb');
+ const selections: EditorSelection[] = [
+ createSelection(0, 0, 0, 2),
+ createSelection(0, 3, 0, 5),
+ ];
+ expect(findNexMatch(doc, selections)).toBeUndefined();
+ });
+
+ test('expands a collapsed caret to the surrounding word', () => {
+ const doc = new TextDocument('inmemory://x', "'foobar'");
+ const caret = createSelection(0, 4, 0, 4);
+ const next = findNexMatch(doc, [caret]);
+ expect(next).toEqual([
+ {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 7 },
+ direction: DirectionForward,
+ },
+ ]);
+ });
+
+ test('adds the next matching range when one occurrence is selected', () => {
+ const doc = new TextDocument('inmemory://x', 'foo x foo');
+ const first = createSelection(0, 0, 0, 3);
+ const afterFirst = findNexMatch(doc, [first]);
+ expect(afterFirst).toEqual([
+ first,
+ {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 9 },
+ direction: DirectionForward,
+ },
+ ]);
+ expect(findNexMatch(doc, afterFirst!)).toBeUndefined();
+ });
+
+ test('wraps to an earlier occurrence after the last match in the file', () => {
+ const doc = new TextDocument('inmemory://x', 'foo bar foo');
+ const secondFoo = createSelection(0, 8, 0, 11);
+ const wrapped = findNexMatch(doc, [secondFoo]);
+ expect(wrapped).toEqual([
+ secondFoo,
+ {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 3 },
+ direction: DirectionForward,
+ },
+ ]);
+ });
+
+ test('allows multiple selections when every range has the same text', () => {
+ const doc = new TextDocument('inmemory://x', 'ab ab ab');
+ const a = createSelection(0, 0, 0, 2);
+ const b = createSelection(0, 3, 0, 5);
+ const two = [a, b];
+ const third = findNexMatch(doc, two);
+ expect(third?.length).toBe(3);
+ expect(third?.[2]).toEqual({
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 8 },
+ direction: DirectionForward,
+ });
+ });
+});
diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts
new file mode 100644
index 000000000..28ed2412e
--- /dev/null
+++ b/packages/diffs/test/fileLineUtils.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from 'bun:test';
+
+import { computeLineOffsets } from '../src/utils/computeFileOffsets';
+
+describe('computeLineOffsets', () => {
+ test('returns a single start offset for empty contents', () => {
+ const result = computeLineOffsets('');
+
+ expect([...result]).toEqual([0]);
+ expect(result.length).toBe(1);
+ });
+
+ test('computes offsets for single line', () => {
+ const result = computeLineOffsets('hello');
+
+ expect([...result]).toEqual([0]);
+ expect(result.length).toBe(1);
+ });
+
+ test('computes offsets for LF files', () => {
+ const withTerminalNewline = computeLineOffsets('a\nb\n');
+ const withoutTerminalNewline = computeLineOffsets('a\nb');
+
+ expect([...withTerminalNewline]).toEqual([0, 2, 4]);
+ expect(withTerminalNewline.length).toBe(3);
+ expect([...withoutTerminalNewline]).toEqual([0, 2]);
+ expect(withoutTerminalNewline.length).toBe(2);
+ });
+
+ test('computes offsets for CRLF and lone CR line endings', () => {
+ const crlf = computeLineOffsets('a\r\nb\r\n');
+ const mixed = computeLineOffsets('a\rb\r\nc\n');
+
+ expect([...crlf]).toEqual([0, 3, 6]);
+ expect(crlf.length).toBe(3);
+ expect([...mixed]).toEqual([0, 2, 5, 7]);
+ expect(mixed.length).toBe(4);
+ });
+
+ test('treats newline-only contents as two offset boundaries', () => {
+ const lines = computeLineOffsets('\n');
+
+ expect([...lines]).toEqual([0, 1]);
+ expect(lines.length).toBe(2);
+ });
+});
diff --git a/packages/diffs/test/iterateOverFile.test.ts b/packages/diffs/test/iterateOverFile.test.ts
deleted file mode 100644
index a55f4ddcc..000000000
--- a/packages/diffs/test/iterateOverFile.test.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-
-import {
- type FileLineCallbackProps,
- iterateOverFile,
-} from '../src/utils/iterateOverFile';
-import { splitFileContents } from '../src/utils/splitFileContents';
-
-describe('iterateOverFile', () => {
- test('basic iteration', () => {
- const lines = splitFileContents('line1\nline2\nline3\nline4\nline5');
-
- const results: FileLineCallbackProps[] = [];
- iterateOverFile({
- lines,
- callback(props) {
- results.push(props);
- },
- });
-
- expect(results).toHaveLength(5);
-
- // Verify all props on first line
- expect(results[0]).toEqual({
- lineIndex: 0, // 0-based
- lineNumber: 1, // 1-based
- content: 'line1\n',
- isLastLine: false,
- });
-
- // Verify middle line
- expect(results[2]).toEqual({
- lineIndex: 2,
- lineNumber: 3,
- content: 'line3\n',
- isLastLine: false,
- });
-
- // Verify last line (no trailing newline in source)
- expect(results[4]).toEqual({
- lineIndex: 4,
- lineNumber: 5,
- content: 'line5',
- isLastLine: true,
- });
- });
-
- test('empty file', () => {
- const lines = splitFileContents('');
-
- const results: FileLineCallbackProps[] = [];
- iterateOverFile({
- lines,
- callback(props) {
- results.push(props);
- },
- });
-
- expect(results).toHaveLength(0);
- });
-
- test('single line file', () => {
- const lines = splitFileContents('only line');
-
- const results: FileLineCallbackProps[] = [];
- iterateOverFile({
- lines,
- callback(props) {
- results.push(props);
- },
- });
-
- expect(results).toHaveLength(1);
- expect(results[0].isLastLine).toBe(true);
- expect(results[0].lineIndex).toBe(0);
- expect(results[0].lineNumber).toBe(1);
- expect(results[0].content).toBe('only line');
- });
-
- test('preserves empty lines', () => {
- const lines = splitFileContents('line1\n\nline3\n\n\nline6');
-
- const results: string[] = [];
- iterateOverFile({
- lines,
- callback({ content }) {
- results.push(content);
- },
- });
-
- // Newlines are preserved except on last line
- expect(results).toEqual(['line1\n', '\n', 'line3\n', '\n', '\n', 'line6']);
- });
-
- test('windowing', () => {
- const lines = splitFileContents(
- Array(100)
- .fill(0)
- .map((_, i) => `line${i}`)
- .join('\n')
- );
-
- // Windowing from start
- let results: number[] = [];
- iterateOverFile({
- lines,
- startingLine: 0,
- totalLines: 10,
- callback({ lineIndex }) {
- results.push(lineIndex);
- },
- });
- expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
-
- // Windowing from middle
- results = [];
- iterateOverFile({
- lines,
- startingLine: 50,
- totalLines: 10,
- callback({ lineIndex }) {
- results.push(lineIndex);
- },
- });
- expect(results).toEqual([50, 51, 52, 53, 54, 55, 56, 57, 58, 59]);
-
- // Windowing past end - request more lines than available
- const shortLines = splitFileContents('line1\nline2\nline3\nline4\nline5');
- results = [];
- iterateOverFile({
- lines: shortLines,
- startingLine: 3,
- totalLines: 100,
- callback({ lineIndex }) {
- results.push(lineIndex);
- },
- });
- expect(results).toEqual([3, 4]); // Only lines 3 and 4 remain
-
- // Window starting beyond file end
- results = [];
- iterateOverFile({
- lines: shortLines,
- startingLine: 100,
- totalLines: 10,
- callback({ lineIndex }) {
- results.push(lineIndex);
- },
- });
- expect(results).toHaveLength(0);
- });
-
- test('last new line is not iterated over', () => {
- const lines = splitFileContents('line1\nline2\nline3\n\n\n');
-
- const results: string[] = [];
- iterateOverFile({
- lines,
- callback({ content }) {
- results.push(content);
- },
- });
-
- // Split creates: ['line1\n', 'line2\n', 'line3\n', '\n', '\n']
- // Only skips the LAST line if it's a newline, not all trailing newlines
- expect(results).toEqual(['line1\n', 'line2\n', 'line3\n', '\n']);
- });
-
- test('isLastLine with windowing', () => {
- const lines = splitFileContents(
- Array(10)
- .fill(0)
- .map((_, i) => `line${i}`)
- .join('\n')
- );
-
- // Window lines 5-7 (not including the actual last line of the file)
- const results: FileLineCallbackProps[] = [];
- iterateOverFile({
- lines,
- startingLine: 5,
- totalLines: 3,
- callback(props) {
- results.push(props);
- },
- });
-
- expect(results).toHaveLength(3);
- // isLastLine should be relative to full file, not the window
- expect(results[0].isLastLine).toBe(false); // Line 5 is not last in file
- expect(results[1].isLastLine).toBe(false); // Line 6 is not last in file
- expect(results[2].isLastLine).toBe(false); // Line 7 is not last in file
-
- // Window starting at actual last line
- results.length = 0;
- iterateOverFile({
- lines,
- startingLine: 9, // Last line (0-indexed)
- totalLines: 10,
- callback(props) {
- results.push(props);
- },
- });
-
- expect(results).toHaveLength(1);
- expect(results[0].lineIndex).toBe(9);
- expect(results[0].isLastLine).toBe(true);
- });
-
- test('early termination', () => {
- const lines = splitFileContents(
- Array(100)
- .fill(0)
- .map((_, i) => `line${i}`)
- .join('\n')
- );
-
- // Returning true stops iteration
- let results: number[] = [];
- iterateOverFile({
- lines,
- callback: ({ lineIndex }) => {
- results.push(lineIndex);
- if (lineIndex === 4) {
- return true; // Stop
- }
- return false;
- },
- });
- expect(results).toEqual([0, 1, 2, 3, 4]);
-
- // Returning false continues
- const shortLines = splitFileContents('a\nb\nc\nd\ne');
- results = [];
- iterateOverFile({
- lines: shortLines,
- callback: ({ lineIndex }) => {
- results.push(lineIndex);
- return false; // Continue
- },
- });
- expect(results).toEqual([0, 1, 2, 3, 4]);
-
- // Returning void continues
- results = [];
- iterateOverFile({
- lines: shortLines,
- callback: ({ lineIndex }) => {
- results.push(lineIndex);
- // Implicit undefined return - continue
- },
- });
- expect(results).toEqual([0, 1, 2, 3, 4]);
- });
-});
diff --git a/packages/diffs/test/mocks.ts b/packages/diffs/test/mocks.ts
index 7b843975c..e6a996f79 100644
--- a/packages/diffs/test/mocks.ts
+++ b/packages/diffs/test/mocks.ts
@@ -58,7 +58,8 @@ export function areThemesEqual(
return themeA === themeB;
}
return themeA.dark === themeB.dark && themeA.light === themeB.light;
-}`,
+}
+`,
},
file2: {
name: 'file2.js',
@@ -69,7 +70,8 @@ export function areThemesEqual(
return total;
}
-export default calculateTotal;`,
+export default calculateTotal;
+`,
},
};
diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts
new file mode 100644
index 000000000..79b80674a
--- /dev/null
+++ b/packages/diffs/test/pieceTable.test.ts
@@ -0,0 +1,342 @@
+import { describe, expect, test } from 'bun:test';
+
+import { PieceTable } from '../src/editor/pieceTable';
+import type { Position } from '../src/editor/textDocument';
+
+function lineTexts(text: string): string[] {
+ if (text === '') {
+ return [];
+ }
+
+ const lines: string[] = [];
+ let start = 0;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) === 10) {
+ lines.push(text.slice(start, i + 1));
+ start = i + 1;
+ }
+ }
+ if (start <= text.length) {
+ lines.push(text.slice(start));
+ }
+ return lines;
+}
+
+/** Trailing CR/LF removed, matching `PieceTable.getLineText` / `getTextSlice(..., true)`. */
+function trimLineEndings(text: string): string {
+ let end = text.length;
+ while (end > 0 && isLineEnding(text.charCodeAt(end - 1))) {
+ end--;
+ }
+ return text.slice(0, end);
+}
+
+function isLineEnding(c: number): boolean {
+ return c === 10 || c === 13;
+}
+
+function positionAt(text: string, offset: number): Position {
+ const clampedOffset = Math.min(Math.max(offset, 0), text.length);
+ let line = 0;
+ let lineStart = 0;
+
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) !== 10) {
+ continue;
+ }
+
+ const lineEnd = i + 1;
+ if (clampedOffset < lineEnd) {
+ return { line, character: clampedOffset - lineStart };
+ }
+ line++;
+ lineStart = lineEnd;
+ }
+
+ return {
+ line,
+ character: clampedOffset - lineStart,
+ };
+}
+
+function offsetAt(text: string, position: Position): number {
+ if (position.line < 0 || text.length === 0) {
+ return 0;
+ }
+
+ const lines = lineTexts(text);
+ if (position.line >= lines.length) {
+ return text.length;
+ }
+
+ let offset = 0;
+ for (let i = 0; i < position.line; i++) {
+ offset += lines[i].length;
+ }
+
+ const lineLength = lines[position.line].length;
+ return offset + Math.min(Math.max(position.character, 0), lineLength);
+}
+
+function expectTableToMatchText(table: PieceTable, text: string): void {
+ const lines = lineTexts(text);
+
+ expect(table.getText()).toBe(text);
+ expect(table.lineCount).toBe(lines.length);
+
+ for (let line = 0; line < lines.length; line++) {
+ expect(table.getLineText(line)).toBe(trimLineEndings(lines[line]));
+ }
+
+ for (let offset = 0; offset <= text.length; offset++) {
+ expect(table.positionAt(offset)).toEqual(positionAt(text, offset));
+ }
+
+ for (let line = 0; line < lines.length; line++) {
+ const lineLength = lines[line].length;
+ for (let character = 0; character <= lineLength; character++) {
+ expect(table.offsetAt({ line, character })).toBe(
+ offsetAt(text, { line, character })
+ );
+ }
+ }
+}
+
+function createRandom(seed: number): () => number {
+ let state = seed;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+}
+
+describe('PieceTable', () => {
+ test('returns the original text', () => {
+ const table = new PieceTable('hello');
+
+ expect(table.getText()).toBe('hello');
+ expect(table.lineCount).toBe(1);
+ });
+
+ test('reads text ranges by positions', () => {
+ const table = new PieceTable('aa\nbb\ncc');
+
+ expect(
+ table.getText({
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 2 },
+ })
+ ).toBe('bb');
+ });
+
+ test('getLineText omits trailing CR/LF', () => {
+ const table = new PieceTable('first\r\nsecond\n');
+
+ expect(table.getLineText(0)).toBe('first');
+ expect(table.getLineText(1)).toBe('second');
+ expect(table.getLineText(2)).toBe('');
+ expect(() => table.getLineText(99)).toThrow('Line index out of range: 99');
+ });
+
+ test('maps between offsets and positions', () => {
+ const table = new PieceTable('ab\nc');
+
+ expect(table.positionAt(0)).toEqual({ line: 0, character: 0 });
+ expect(table.positionAt(2)).toEqual({ line: 0, character: 2 });
+ expect(table.positionAt(3)).toEqual({ line: 1, character: 0 });
+ expect(table.positionAt(table.getText().length)).toEqual({
+ line: 1,
+ character: 1,
+ });
+ expect(table.offsetAt({ line: 1, character: 0 })).toBe(3);
+ expect(table.offsetAt({ line: 1, character: 99 })).toBe(4);
+ });
+
+ test('inserts at the start, middle, and end', () => {
+ const table = new PieceTable('bc');
+
+ table.insert('a', 0);
+ table.insert('X', 2);
+ table.insert('d', table.getText().length);
+
+ expect(table.getText()).toBe('abXcd');
+ });
+
+ test('deletes across original and added pieces', () => {
+ const table = new PieceTable('hello world');
+
+ table.insert(' brave', 5);
+ table.delete(5, 6);
+
+ expect(table.getText()).toBe('hello world');
+ });
+
+ test('handles mixed edits over multiple lines', () => {
+ const table = new PieceTable('one\ntwo\nthree');
+
+ table.insert(' zero', 3);
+ table.delete(9, 3);
+ table.insert('TWO', table.offsetAt({ line: 1, character: 0 }));
+
+ expect(table.getText()).toBe('one zero\nTWO\nthree');
+ expect(table.lineCount).toBe(3);
+ expect(table.getLineText(1)).toBe('TWO');
+ });
+
+ test('handles CRLF split across piece boundaries', () => {
+ const table = new PieceTable('a\r\nb');
+
+ table.insert('X', 2);
+ table.delete(2, 1);
+
+ expect(table.getText()).toBe('a\r\nb');
+ expect(table.lineCount).toBe(2);
+ expect(table.getLineText(0)).toBe('a');
+ expect(table.positionAt(2)).toEqual({ line: 0, character: 2 });
+ expect(table.positionAt(3)).toEqual({ line: 1, character: 0 });
+ });
+
+ test('handles an empty document', () => {
+ const table = new PieceTable('');
+
+ expectTableToMatchText(table, '');
+ expect(table.getLineText(0)).toBe('');
+ expect(table.positionAt(99)).toEqual({ line: 0, character: 0 });
+ expect(table.offsetAt({ line: 99, character: 99 })).toBe(0);
+ });
+
+ test('clamps insert and delete offsets', () => {
+ const table = new PieceTable('middle');
+
+ table.insert('start-', -10);
+ table.insert('-end', 999);
+ table.delete(-10, 6);
+ table.delete(6, 999);
+
+ expectTableToMatchText(table, 'middle');
+ });
+
+ test('reads ranges spanning original and added pieces', () => {
+ const table = new PieceTable('abcd');
+
+ table.insert('XX', 2);
+
+ expectTableToMatchText(table, 'abXXcd');
+ expect(
+ table.getText({
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 5 },
+ })
+ ).toBe('bXXc');
+ });
+
+ test('reads single characters from piece boundaries', () => {
+ const table = new PieceTable('ab\nef');
+
+ table.insert('CD', 3);
+
+ expect(table.charAt(0)).toBe('a');
+ expect(table.charAt(3)).toBe('C');
+ expect(table.charAt(4)).toBe('D');
+ expect(table.charAt(5)).toBe('e');
+ expect(table.charAt(-1)).toBe('');
+ expect(table.charAt(table.getText().length)).toBe('');
+ });
+
+ test('searches text across piece boundaries', () => {
+ const table = new PieceTable('a\nb');
+
+ table.insert('\r', 1);
+
+ expect(table.includes('\r\n')).toBe(true);
+ expect(table.includes('missing')).toBe(false);
+ expect(table.includes('')).toBe(true);
+ });
+
+ test('finds the next non-overlapping match across piece boundaries', () => {
+ const table = new PieceTable('foo x fo');
+
+ table.insert('o foo', table.getText().length);
+
+ expect(table.findNextNonOverlappingSubstring('foo', [[0, 3]])).toBe(6);
+ expect(
+ table.findNextNonOverlappingSubstring('foo', [
+ [0, 3],
+ [6, 9],
+ ])
+ ).toBe(10);
+ expect(
+ table.findNextNonOverlappingSubstring('foo', [
+ [6, 9],
+ [10, 13],
+ ])
+ ).toBe(0);
+ expect(
+ table.findNextNonOverlappingSubstring('foo', [
+ [0, 3],
+ [6, 9],
+ [10, 13],
+ ])
+ ).toBeUndefined();
+ });
+
+ test('tracks trailing newline as an empty final line', () => {
+ const table = new PieceTable('a\n');
+
+ expectTableToMatchText(table, 'a\n');
+ expect(table.getLineText(1)).toBe('');
+ expect(table.positionAt(2)).toEqual({ line: 1, character: 0 });
+ });
+
+ test('updates line metadata for inserted multiline text', () => {
+ const table = new PieceTable('before\nafter');
+
+ table.insert('\ninserted\r\nlines', 6);
+
+ expectTableToMatchText(table, 'before\ninserted\r\nlines\nafter');
+ });
+
+ test('deletes across several pieces', () => {
+ const table = new PieceTable('0123456789');
+
+ table.insert('aa', 2);
+ table.insert('bb', 6);
+ table.insert('cc', 12);
+ table.delete(0, table.getText().length - 1);
+
+ expectTableToMatchText(table, '9');
+ });
+
+ test('deletes all content', () => {
+ const table = new PieceTable('a\nb');
+
+ table.insert('c', 1);
+ table.delete(0, table.getText().length);
+
+ expectTableToMatchText(table, '');
+ expect(table.getLineText(0)).toBe('');
+ });
+
+ test('matches plain string edits across many insertions and deletions', () => {
+ const table = new PieceTable('start\r\nmiddle\nend');
+ const random = createRandom(42);
+ const inserts = ['a', 'BC', '\n', '\r\nx', '🙂', ''];
+ let text = 'start\r\nmiddle\nend';
+
+ for (let i = 0; i < 80; i++) {
+ if (random() < 0.6) {
+ const insert = inserts[Math.floor(random() * inserts.length)];
+ const offset = Math.floor(random() * (text.length + 1));
+ table.insert(insert, offset);
+ text = text.slice(0, offset) + insert + text.slice(offset);
+ } else {
+ const offset = Math.floor(random() * (text.length + 1));
+ const length = Math.floor(random() * 5);
+ table.delete(offset, length);
+ text = text.slice(0, offset) + text.slice(offset + length);
+ }
+ }
+
+ expectTableToMatchText(table, text);
+ });
+});
diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts
new file mode 100644
index 000000000..bb0aa007e
--- /dev/null
+++ b/packages/diffs/test/textDocument.test.ts
@@ -0,0 +1,1020 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { EditorSelection } from '../src/editor/editorSelection';
+import { DirectionNone } from '../src/editor/editorSelection';
+import { TextDocument, type TextEdit } from '../src/editor/textDocument';
+import type { LineAnnotation } from '../src/types';
+
+function doc(text: string) {
+ return new TextDocument('inmemory://1', text, 'plain');
+}
+
+function caret(line: number, character: number) {
+ const position = { line, character };
+ return {
+ start: position,
+ end: position,
+ direction: DirectionNone,
+ } satisfies EditorSelection;
+}
+
+describe('TextDocument', () => {
+ test('lang and lineCount', () => {
+ const d = doc('a\nb\nc');
+ expect(d.languageId).toBe('plain');
+ expect(d.lineCount).toBe(3);
+ });
+
+ test('getText without range returns full buffer', () => {
+ expect(doc('hello').getText()).toBe('hello');
+ });
+
+ test('getText with range', () => {
+ const d = doc('aa\nbb\ncc');
+ expect(
+ d.getText({
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 1 },
+ })
+ ).toBe('b');
+ });
+
+ test('getLineText', () => {
+ const d = doc('first\nsecond');
+ expect(d.getLineText(0)).toBe('first');
+ expect(d.getLineText(1)).toBe('second');
+ expect(() => d.getLineText(-1)).toThrow('Line index out of range: -1');
+ expect(() => d.getLineText(99)).toThrow('Line index out of range: 99');
+ });
+
+ test('getLineText trims line endings; getText range still includes them', () => {
+ const d = doc('first\r\nsecond\n');
+ expect(d.getLineText(0)).toBe('first');
+ expect(d.getLineText(1)).toBe('second');
+ expect(d.getLineText(2)).toBe('');
+ expect(
+ d.getText({
+ start: { line: 0, character: 0 },
+ end: { line: 1, character: 0 },
+ })
+ ).toBe('first\r\n');
+ expect(
+ d.getText({
+ start: { line: 1, character: 0 },
+ end: { line: 2, character: 0 },
+ })
+ ).toBe('second\n');
+ });
+
+ // test('offsetAt clamps to line and document bounds', () => {
+ // const d = doc('ab\nc');
+ // expect(d.offsetAt({ line: 0, character: 0 })).toBe(0);
+ // expect(d.offsetAt({ line: 0, character: 99 })).toBe(2);
+ // expect(d.offsetAt({ line: 1, character: 0 })).toBe(3);
+ // expect(() => d.offsetAt({ line: 99, character: 0 })).toThrow(
+ // 'Line index out of range: 99'
+ // );
+ // });
+
+ test('positionAt is inverse of offsetAt for in-range columns', () => {
+ const d = doc('ab\nc');
+ expect(d.positionAt(0)).toEqual({ line: 0, character: 0 });
+ expect(d.positionAt(3)).toEqual({ line: 1, character: 0 });
+ expect(d.positionAt(d.getText().length)).toEqual({ line: 1, character: 1 });
+ const { line, character } = d.positionAt(2);
+ expect(d.offsetAt({ line, character })).toBe(2);
+ });
+
+ // test('positionAt and offsetAt clamp line endings', () => {
+ // const d = doc('a\r\r\nb\r');
+ // expect(d.positionAt(2)).toEqual({ line: 0, character: 1 });
+ // expect(d.positionAt(3)).toEqual({ line: 0, character: 1 });
+ // expect(d.positionAt(4)).toEqual({ line: 1, character: 0 });
+ // expect(d.positionAt(6)).toEqual({ line: 1, character: 1 });
+ // expect(d.offsetAt({ line: 0, character: 10 })).toBe(1);
+ // expect(d.offsetAt({ line: 1, character: 10 })).toBe(5);
+ // });
+
+ test('positionAt maps initial line offsets from zero', () => {
+ const d = doc('first\nsecond\nthird');
+ expect(d.positionAt(0)).toEqual({ line: 0, character: 0 });
+ expect(d.positionAt(5)).toEqual({ line: 0, character: 5 });
+ expect(d.positionAt(6)).toEqual({ line: 1, character: 0 });
+ expect(d.offsetAt({ line: 2, character: 0 })).toBe(13);
+ });
+
+ test('applyEdits single replacement', () => {
+ const d = doc('hello world');
+ const change = d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 11 },
+ },
+ newText: 'you',
+ },
+ ]);
+ expect(d.getText()).toBe('hello you');
+ expect(change).toEqual({
+ startLine: 0,
+ startCharacter: 6,
+ endLine: 0,
+ previousLineCount: 1,
+ lineCount: 1,
+ lineDelta: 0,
+ });
+ });
+
+ test('applyEdits swaps inverted start/end', () => {
+ const d = doc('abcd');
+ d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 3 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'X',
+ },
+ ]);
+ expect(d.getText()).toBe('aXd');
+ });
+
+ test('applyEdits multiple non-overlapping regions', () => {
+ const d = doc('aa bb cc');
+ const edits: TextEdit[] = [
+ {
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 8 },
+ },
+ newText: 'CC',
+ },
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'AA',
+ },
+ ];
+ d.applyEdits(edits);
+ expect(d.getText()).toBe('AA bb CC');
+ });
+
+ test('applyEdits preserves line breaks around edited line', () => {
+ const d = doc('a\nb\nc');
+ const change = d.applyEdits([
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 1 },
+ },
+ newText: 'B',
+ },
+ ]);
+ expect(d.getText()).toBe('a\nB\nc');
+ expect(d.lineCount).toBe(3);
+ expect(change).toEqual({
+ startLine: 1,
+ startCharacter: 0,
+ endLine: 1,
+ previousLineCount: 3,
+ lineCount: 3,
+ lineDelta: 0,
+ });
+ });
+
+ test('applyEdits reports inserted lines in returned change', () => {
+ const d = doc('a');
+ const change = d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: '\nb',
+ },
+ ]);
+ expect(d.getText()).toBe('a\nb');
+ expect(change).toEqual({
+ startLine: 0,
+ startCharacter: 1,
+ endLine: 1,
+ previousLineCount: 1,
+ lineCount: 2,
+ lineDelta: 1,
+ });
+ });
+
+ test('applyEdits reports line deletions in returned change', () => {
+ const d = doc('a\nb\nc');
+ const change = d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 2, character: 0 },
+ },
+ newText: '',
+ },
+ ]);
+ expect(d.getText()).toBe('ac');
+ expect(change).toEqual({
+ startLine: 0,
+ startCharacter: 1,
+ endLine: 0,
+ previousLineCount: 3,
+ lineCount: 1,
+ lineDelta: -2,
+ });
+ });
+
+ test('applyEdits preserves CRLF after middle-line edit', () => {
+ const d = doc('a\r\nb\r\nc');
+ d.applyEdits([
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 1 },
+ },
+ newText: 'B',
+ },
+ ]);
+ expect(d.getText()).toBe('a\r\nB\r\nc');
+ });
+
+ test('getText(range) spans multiple lines correctly after edits', () => {
+ const d = doc('foo\nbar\nbaz');
+ d.applyEdits([
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 3 },
+ },
+ newText: 'BAR',
+ },
+ ]);
+ expect(
+ d.getText({
+ start: { line: 0, character: 2 },
+ end: { line: 2, character: 2 },
+ })
+ ).toBe('o\nBAR\nba');
+ });
+
+ test('undo restores batch with two disjoint edits', () => {
+ const d = doc('aa bb cc');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 8 },
+ },
+ newText: 'CC',
+ },
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'AA',
+ },
+ ],
+ true,
+ [caret(0, 0)]
+ );
+ d.undo();
+ expect(d.getText()).toBe('aa bb cc');
+ });
+
+ test('undo multi-line replacement', () => {
+ const d = doc('line1\nline2\nline3');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 1, character: 0 },
+ end: { line: 1, character: 5 },
+ },
+ newText: 'two',
+ },
+ ],
+ true,
+ [caret(1, 0)]
+ );
+ expect(d.getText()).toBe('line1\ntwo\nline3');
+ d.undo();
+ expect(d.getText()).toBe('line1\nline2\nline3');
+ });
+
+ test('undo stack depth for sequential edits', () => {
+ const d = doc('x');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 0 },
+ },
+ newText: 'a',
+ },
+ ],
+ true,
+ [caret(0, 0)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ d.undo();
+ expect(d.getText()).toBe('x');
+ });
+
+ test('undo keeps later multiline edit separate from typing group', () => {
+ const d = doc('x');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 0 },
+ },
+ newText: 'a',
+ },
+ ],
+ true,
+ [caret(0, 0)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 2 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '\n',
+ },
+ ],
+ true,
+ [caret(0, 2)]
+ );
+
+ expect(d.getText()).toBe('ab\nx');
+
+ d.undo();
+ expect(d.getText()).toBe('abx');
+
+ d.undo();
+ expect(d.getText()).toBe('x');
+ });
+
+ test('contiguous backspaces coalesce into one undo step', () => {
+ const d = doc('abc');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 2 },
+ end: { line: 0, character: 3 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 3)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 2)]
+ );
+
+ expect(d.getText()).toBe('a');
+
+ d.undo();
+ expect(d.getText()).toBe('abc');
+ });
+
+ test('replacement edits do not coalesce', () => {
+ const d = doc('ab');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'X',
+ },
+ ],
+ true,
+ [caret(0, 2)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'Y',
+ },
+ ],
+ true,
+ [caret(0, 2)]
+ );
+
+ expect(d.getText()).toBe('aY');
+
+ d.undo();
+ expect(d.getText()).toBe('aX');
+
+ d.undo();
+ expect(d.getText()).toBe('ab');
+ });
+
+ test('typing after replacing a selection coalesces into one undo step', () => {
+ const d = doc('hello');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ newText: 'w',
+ },
+ ],
+ true,
+ [caret(0, 5)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'orld',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+
+ expect(d.getText()).toBe('world');
+
+ d.undo();
+ expect(d.getText()).toBe('hello');
+ });
+
+ test('contiguous forward deletes coalesce into one undo step', () => {
+ const d = doc('abc');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+
+ expect(d.getText()).toBe('a');
+
+ d.undo();
+ expect(d.getText()).toBe('abc');
+ });
+
+ test('multi-cursor contiguous inserts coalesce into one undo step', () => {
+ const d = doc('ab\ncd');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'X',
+ },
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 1 },
+ },
+ newText: 'X',
+ },
+ ],
+ true,
+ [caret(0, 1), caret(1, 1)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 2 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'Y',
+ },
+ {
+ range: {
+ start: { line: 1, character: 2 },
+ end: { line: 1, character: 2 },
+ },
+ newText: 'Y',
+ },
+ ],
+ true,
+ [caret(0, 2), caret(1, 2)]
+ );
+
+ expect(d.getText()).toBe('aXYb\ncXYd');
+
+ d.undo();
+ expect(d.getText()).toBe('ab\ncd');
+ });
+
+ test('multi-cursor contiguous backspaces coalesce into one undo step', () => {
+ const d = doc('abc\ndef');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 2 },
+ end: { line: 0, character: 3 },
+ },
+ newText: '',
+ },
+ {
+ range: {
+ start: { line: 1, character: 2 },
+ end: { line: 1, character: 3 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 3), caret(1, 3)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 2), caret(1, 2)]
+ );
+
+ expect(d.getText()).toBe('a\nd');
+
+ d.undo();
+ expect(d.getText()).toBe('abc\ndef');
+ });
+
+ test('multi-cursor contiguous forward deletes coalesce into one undo step', () => {
+ const d = doc('abc\ndef');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 1), caret(1, 1)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 2 },
+ },
+ newText: '',
+ },
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 2 },
+ },
+ newText: '',
+ },
+ ],
+ true,
+ [caret(0, 1), caret(1, 1)]
+ );
+
+ expect(d.getText()).toBe('a\nd');
+
+ d.undo();
+ expect(d.getText()).toBe('abc\ndef');
+ });
+
+ test('multi-cursor batches with different edit shapes do not coalesce', () => {
+ const d = doc('ab\ncd');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'X',
+ },
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 1 },
+ },
+ newText: 'X',
+ },
+ ],
+ true,
+ [caret(0, 1), caret(1, 1)]
+ );
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 2 },
+ end: { line: 0, character: 2 },
+ },
+ newText: 'Y',
+ },
+ ],
+ true,
+ [caret(0, 2)]
+ );
+
+ d.undo();
+ expect(d.getText()).toBe('aXb\ncXd');
+
+ d.undo();
+ expect(d.getText()).toBe('ab\ncd');
+ });
+
+ test('applyEdits rejects overlapping ranges', () => {
+ const d = doc('0123456789');
+ expect(() =>
+ d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 5 },
+ },
+ newText: 'X',
+ },
+ {
+ range: {
+ start: { line: 0, character: 4 },
+ end: { line: 0, character: 7 },
+ },
+ newText: 'Y',
+ },
+ ])
+ ).toThrow('Overlapping text edits are not supported');
+ });
+
+ test('applyEdits empty array does not touch history', () => {
+ const d = doc('x');
+ d.applyEdits([]);
+ expect(d.canUndo).toBe(false);
+ });
+
+ test('applyEdits default does not record undo', () => {
+ const d = doc('a');
+ d.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ]);
+ expect(d.getText()).toBe('ab');
+ expect(d.canUndo).toBe(false);
+ expect(d.undo()).toBeUndefined();
+ });
+
+ test('undo and redo', () => {
+ const d = doc('a');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ expect(d.getText()).toBe('ab');
+ expect(d.canUndo).toBe(true);
+ expect(d.canRedo).toBe(false);
+
+ const undoResult = d.undo();
+ expect(d.getText()).toBe('a');
+ expect(undoResult?.[0]).toEqual({
+ startLine: 0,
+ startCharacter: 1,
+ endLine: 0,
+ previousLineCount: 1,
+ lineCount: 1,
+ lineDelta: 0,
+ });
+ expect(d.canUndo).toBe(false);
+ expect(d.canRedo).toBe(true);
+
+ const redoResult = d.redo();
+ expect(d.getText()).toBe('ab');
+ expect(redoResult?.[0]).toEqual({
+ startLine: 0,
+ startCharacter: 1,
+ endLine: 0,
+ previousLineCount: 1,
+ lineCount: 1,
+ lineDelta: 0,
+ });
+ expect(d.canUndo).toBe(true);
+ expect(d.canRedo).toBe(false);
+ });
+
+ test('undo and redo restore history entry versions', () => {
+ const d = new TextDocument('inmemory://1', 'a', 'plain', 7);
+ expect(d.version).toBe(7);
+
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ expect(d.version).toBe(8);
+
+ d.undo();
+ expect(d.getText()).toBe('a');
+ expect(d.version).toBe(7);
+
+ d.redo();
+ expect(d.getText()).toBe('ab');
+ expect(d.version).toBe(8);
+ });
+
+ test('new edit after undo clears redo stack', () => {
+ const d = doc('a');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ d.undo();
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'c',
+ },
+ ],
+ true,
+ [caret(0, 1)]
+ );
+ expect(d.getText()).toBe('ac');
+ expect(d.canRedo).toBe(false);
+ });
+
+ test('undo on empty stack returns false', () => {
+ const d = doc('z');
+ expect(d.undo()).toBeUndefined();
+ });
+
+ test('redo on empty stack returns false', () => {
+ const d = doc('z');
+ expect(d.redo()).toBeUndefined();
+ });
+
+ test('undo and redo return stored selections', () => {
+ const d = doc('abc');
+ const selectionBefore = caret(0, 1);
+ const selectionAfter = caret(0, 2);
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'x',
+ },
+ ],
+ true,
+ [selectionBefore],
+ [selectionAfter]
+ );
+
+ expect(d.undo()?.[1]).toEqual([selectionBefore]);
+ expect(d.redo()?.[1]).toEqual([selectionAfter]);
+ });
+
+ test('undo and redo preserve multiple selections', () => {
+ const d = doc('a\nb');
+ const selectionsBefore = [caret(0, 1), caret(1, 1)];
+ const selectionsAfter = [caret(0, 2), caret(1, 2)];
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 1 },
+ },
+ newText: '!',
+ },
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: '!',
+ },
+ ],
+ true,
+ selectionsBefore,
+ selectionsAfter
+ );
+
+ expect(d.undo()?.[1]).toEqual(selectionsBefore);
+ expect(d.redo()?.[1]).toEqual(selectionsAfter);
+ });
+
+ test('undo and redo return stored line annotations', () => {
+ const d = doc('abc');
+ const annotationsBefore: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'bookmark-a' },
+ ];
+ const annotationsAfter: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'bookmark-b' },
+ ];
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'x',
+ },
+ ],
+ true,
+ [caret(0, 1)],
+ [caret(0, 2)],
+ annotationsBefore,
+ annotationsAfter
+ );
+
+ expect(d.undo()?.[2]).toEqual(annotationsBefore);
+ expect(d.redo()?.[2]).toEqual(annotationsAfter);
+ });
+
+ test('undo omits line annotations tuple entry when none were recorded', () => {
+ const d = doc('abc');
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'x',
+ },
+ ],
+ true,
+ [caret(0, 1)],
+ [caret(0, 2)]
+ );
+
+ expect(d.undo()?.[2]).toBeUndefined();
+ expect(d.redo()?.[2]).toBeUndefined();
+ });
+
+ test('setLastUndoLineAnnotationsAfter updates redo line annotations', () => {
+ const d = doc('a');
+ const annotationsBefore: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'initial' },
+ ];
+ d.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 0, character: 1 },
+ end: { line: 0, character: 1 },
+ },
+ newText: 'b',
+ },
+ ],
+ true,
+ [caret(0, 1)],
+ undefined,
+ annotationsBefore,
+ undefined
+ );
+
+ const patchedAfter: LineAnnotation[] = [
+ { lineNumber: 1, metadata: 'patched-after-edit' },
+ ];
+ d.setLastUndoLineAnnotationsAfter(patchedAfter);
+
+ d.undo();
+ expect(d.redo()?.[2]).toEqual(patchedAfter);
+ });
+});