diff --git a/apps/demo/editor.html b/apps/demo/editor.html
new file mode 100644
index 000000000..7c107a57b
--- /dev/null
+++ b/apps/demo/editor.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ PierreJS R&D
+
+
+
+
+

+
+
+
+
+
+
+
+
+
diff --git a/apps/demo/index.html b/apps/demo/index.html
index aba77e5e8..0205f662c 100644
--- a/apps/demo/index.html
+++ b/apps/demo/index.html
@@ -2,11 +2,25 @@
-
+
+
+
+
+
+
+
PierreJS R&D
@@ -30,10 +44,28 @@
Wrap
+
-
+
+
+
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/public/editor/icon.svg b/apps/demo/public/editor/icon.svg
new file mode 100644
index 000000000..246c36b47
--- /dev/null
+++ b/apps/demo/public/editor/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/demo/public/editor/touch-icon.png b/apps/demo/public/editor/touch-icon.png
new file mode 100644
index 000000000..4cee27577
Binary files /dev/null and b/apps/demo/public/editor/touch-icon.png differ
diff --git a/apps/demo/public/manifest.webmanifest b/apps/demo/public/manifest.webmanifest
new file mode 100644
index 000000000..61b18d1db
--- /dev/null
+++ b/apps/demo/public/manifest.webmanifest
@@ -0,0 +1,13 @@
+{
+ "name": "@pierre/diffs",
+ "short_name": "diffs",
+ "display": "standalone",
+ "start_url": "/editor",
+ "scope": "/",
+ "theme_color": "#0a0a0a",
+ "background_color": "#0a0a0a",
+ "icons": [
+ { "src": "/editor/icon.svg", "sizes": "any", "type": "image/svg+xml" },
+ { "src": "/editor/touch-icon.png", "sizes": "180x180", "type": "image/png" }
+ ]
+}
diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts
new file mode 100644
index 000000000..e190d8a46
--- /dev/null
+++ b/apps/demo/src/editor.ts
@@ -0,0 +1,159 @@
+import {
+ DEFAULT_THEMES,
+ type FileContents,
+ VirtualizedFile,
+ Virtualizer,
+} from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
+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());
+ },
+};
+
+const fileTreeContainer = document.getElementById('file-tree-container')!;
+const editorContainer = document.getElementById('editor-container')!;
+const editor = new Editor({
+ enabledQuickEdit: true,
+ renderQuickEdit: ({ close, replaceSelectionText }) => {
+ const el = document.createElement('div');
+ const input = document.createElement('input');
+ const span = document.createElement('span');
+ const left = document.createElement('div');
+ const right = document.createElement('div');
+ el.className = 'quick-edit';
+ input.className = 'quick-edit-input';
+ span.className = 'quick-edit-status';
+ left.className = 'quick-edit-left';
+ right.className = 'quick-edit-right';
+ right.innerHTML = `
+
+ `;
+ input.placeholder = 'Ask AI...';
+ span.textContent = 'Thinking...';
+ input.addEventListener('keyup', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ input.style.opacity = '0';
+ span.style.opacity = '1';
+ right.style.opacity = '0.5';
+ setTimeout(() => {
+ close();
+ replaceSelectionText('');
+ }, 2000);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ }
+ });
+ left.append(span, input);
+ el.append(left, right);
+ setTimeout(() => {
+ input.focus();
+ }, 100);
+ return el;
+ },
+ onChange: (file) => {
+ const gs = gitStatus.filter((e) => e.path !== file.name);
+ gs.push({ path: file.name, status: 'modified' });
+ fileTree.setGitStatus(gs);
+ console.log('writeFile', file.name);
+ },
+});
+const virtualizer = new Virtualizer();
+const poolManager = createWorkerAPI({
+ theme: DEFAULT_THEMES,
+ langs: ['typescript', 'tsx'],
+ preferredHighlighter: 'shiki-wasm',
+ useTokenTransformer: true,
+});
+const fileInstance = new VirtualizedFile(
+ {
+ stickyHeader: true,
+ renderCustomHeader: (file) => {
+ const el = document.createElement('div');
+ el.className = 'editor-tab';
+ const parts = file.name.split('/');
+ const filename = parts.at(-1) ?? file.name;
+ const dir = parts.slice(0, -1).join('/');
+ el.innerHTML = `${dir.length > 0 ? `${dir}/` : ''}${filename}`;
+ return el;
+ },
+ },
+ virtualizer,
+ undefined,
+ poolManager
+);
+const [paths, gitStatus] = await Promise.all([
+ API.getPaths(),
+ API.getGitStatus(),
+]);
+const fileTree = new FileTree({
+ paths,
+ gitStatus,
+ density: 'compact',
+ search: true,
+ searchBlurBehavior: 'retain',
+ 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 });
+}
+
+void poolManager.initialize().then(() => {
+ console.log('WorkerPoolManager initialized, with:', poolManager.getStats());
+});
+virtualizer.setup(editorContainer);
+fileTree.setSearch('editor');
+fileTree.render({ fileTreeContainer });
+editor.edit(fileInstance);
+
+const splash = document.getElementById('splash');
+if (splash !== null) {
+ splash.classList.add('hidden');
+ splash.addEventListener('transitionend', () => splash.remove(), {
+ once: true,
+ });
+}
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 2c30f44aa..35ad0a008 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -21,6 +21,7 @@ import {
VirtualizedFileDiff,
Virtualizer,
} from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
import type { WorkerPoolManager } from '@pierre/diffs/worker';
import {
@@ -203,10 +204,12 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
const patchAnnotations = FAKE_DIFF_LINE_ANNOTATIONS[patchIndex] ?? [];
let hunkIndex = 0;
for (const fileDiff of parsedPatch.files) {
+ const editor = new Editor();
const fileAnnotations = patchAnnotations[hunkIndex];
let instance:
| FileDiff
| VirtualizedFileDiff;
+ let isEditing = false;
const options: FileDiffOptions = {
theme: DEMO_THEME,
themeType,
@@ -214,7 +217,8 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
overflow: wrap ? 'wrap' : 'scroll',
renderAnnotation: renderDiffAnnotation,
renderHeaderMetadata() {
- return createCollapsedToggle(
+ const collapseToggle = createToggle(
+ 'Collapse',
instance?.options.collapsed ?? false,
(checked) => {
instance?.setOptions({
@@ -226,6 +230,26 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
}
}
);
+ const editableToggle = createToggle(
+ 'Editable',
+ isEditing,
+ (checked) => {
+ isEditing = checked;
+ if (isEditing) {
+ editor.edit(instance);
+ } else {
+ editor.cleanUp();
+ }
+ }
+ );
+ const div = document.createElement('div');
+ div.style.display = 'flex';
+ div.style.gap = '8px';
+ div.append(collapseToggle);
+ if (!fileDiff.isPartial) {
+ div.append(editableToggle);
+ }
+ return div;
},
lineHoverHighlight: 'both',
expansionLineCount: 10,
@@ -713,18 +737,70 @@ if (renderFileButton != null) {
virtualizer?.setup(globalThis.document);
const wrap = getWrapped();
+ const editor = new Editor({
+ enabledQuickEdit: true,
+ renderQuickEdit: ({ close, replaceSelectionText }) => {
+ const el = document.createElement('div');
+ const input = document.createElement('input');
+ const span = document.createElement('span');
+ const left = document.createElement('div');
+ const right = document.createElement('div');
+ el.className = 'quick-edit';
+ input.className = 'quick-edit-input';
+ span.className = 'quick-edit-status';
+ left.className = 'quick-edit-left';
+ right.className = 'quick-edit-right';
+ right.innerHTML = `
+
+ `;
+ input.placeholder = 'Ask AI...';
+ span.textContent = 'Thinking...';
+ input.addEventListener('keyup', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ input.style.opacity = '0';
+ span.style.opacity = '1';
+ right.style.opacity = '0.5';
+ setTimeout(() => {
+ close();
+ replaceSelectionText('');
+ }, 2000);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ }
+ });
+ left.append(span, input);
+ el.append(left, right);
+ setTimeout(() => {
+ input.focus();
+ }, 100);
+ return el;
+ },
+ onChange: (file, lineAnnotations) => {
+ console.log('change', file, lineAnnotations);
+ },
+ });
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,
renderHeaderMetadata() {
- return createCollapsedToggle(
+ const collapsedToggle = createToggle(
+ 'Collapse',
instance?.options.collapsed ?? false,
(checked) => {
instance?.setOptions({
@@ -736,6 +812,36 @@ if (renderFileButton != null) {
}
}
);
+ const editableToggle = createToggle(
+ 'Editable',
+ isEditing,
+ (checked) => {
+ isEditing = checked;
+ if (isEditing) {
+ editor.edit(instance);
+ 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 {
+ editor.cleanUp();
+ }
+ }
+ );
+ const div = document.createElement('div');
+ div.style.display = 'flex';
+ div.style.gap = '8px';
+ div.append(collapsedToggle, editableToggle);
+ return div;
},
// Line selection stuff
@@ -923,7 +1029,34 @@ cleanButton?.addEventListener('click', () => {
cleanupInstances(container);
});
-function createCollapsedToggle(
+const lagRadarCheckbox = document.getElementById('lag-radar');
+const radar = document.getElementById('radar');
+if (lagRadarCheckbox != null && radar != null) {
+ const { default: lagRadar } =
+ // @ts-expect-error dynamic import
+ await import('https://mobz.github.io/lag-radar/lag-radar.js');
+ let dispose: (() => void) | undefined;
+ lagRadarCheckbox.addEventListener('change', () => {
+ if (
+ lagRadarCheckbox instanceof HTMLInputElement &&
+ lagRadarCheckbox.checked
+ ) {
+ dispose = lagRadar({
+ parent: radar,
+ size: 100,
+ frames: 60,
+ });
+ radar.style.display = 'block';
+ } else {
+ dispose?.();
+ dispose = undefined;
+ radar.style.display = 'none';
+ }
+ });
+}
+
+function createToggle(
+ labelText: string,
checked: boolean,
onChange: (checked: boolean) => void
): HTMLElement {
@@ -936,7 +1069,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 9e9d57c60..3d0f731f6 100644
--- a/apps/demo/src/style.css
+++ b/apps/demo/src/style.css
@@ -14,7 +14,7 @@
'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono',
'Courier New', monospace;
- --diffs-font-size: 14px;
+ --diffs-font-size: 13px;
--diffs-line-height: 20px;
--fg: light-dark(#213547, #ffffff);
@@ -45,7 +45,7 @@ html[data-theme-type='dark'] {
html,
body {
overflow-anchor: none;
- background-color: light-dark(white, black);
+ background-color: var(--diffs-bg);
}
html,
@@ -160,7 +160,7 @@ code {
min-width: 0;
min-height: 0;
overflow-anchor: none;
- background-color: light-dark(white, black);
+ background-color: light-dark(white, #0a0a0a);
}
.wrapper[data-code-view] {
@@ -253,3 +253,138 @@ 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, #0a0a0a);
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
+
+#file-tree-header h1 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--fg);
+ padding: 4px 16px 12px;
+}
+
+#file-tree-header svg {
+ width: 20px;
+ height: 20px;
+}
+
+#editor-container {
+ overflow-y: auto;
+ overscroll-behavior: none;
+ height: 100%;
+}
+
+#editor-container diffs-container {
+ margin-top: 0;
+}
+
+[slot='header-custom'] {
+ padding-block: 8px;
+}
+
+.editor-tab {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding-inline: 12px;
+ margin-bottom: -1px;
+ width: fit-content;
+ gap: 0;
+ font-size: 13px;
+ background-color: color-mix(in srgb, var(--diffs-fg) 5%, var(--diffs-bg));
+ border-radius: 8px;
+}
+
+.editor-tab-dir {
+ opacity: 0.5;
+}
+
+.editor-tab-name {
+ font-weight: 500;
+}
+
+.quick-edit {
+ box-sizing: border-box;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 8px 0;
+ padding: 8px;
+ border: 1px solid rgba(128, 128, 128, 0.5);
+ border-radius: 6px;
+}
+
+.quick-edit-left {
+ position: relative;
+ flex: 1;
+ height: 24px;
+}
+
+.quick-edit-input {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 24px;
+ padding: 0;
+ margin: 0;
+ line-height: 24px;
+ border: none;
+ outline: none;
+ resize: none;
+}
+
+.quick-edit-status {
+ position: absolute;
+ top: 0;
+ left: 0;
+ line-height: 24px;
+ color: #999;
+ opacity: 0;
+}
+
+.quick-edit-right {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #666;
+}
+
+#file-tree-container {
+ box-sizing: border-box;
+ padding-top: 16px;
+}
+
+@media (display-mode: standalone) {
+ #file-tree-container {
+ margin-left: 8px;
+ height: calc(100% - 8px);
+ border: 1px solid light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
+ border-radius: 8px;
+ box-shadow:
+ 0 2px 4px rgb(0 0 0 / 0.025),
+ 0 6px 12px rgb(0 0 0 / 0.025);
+ }
+
+ [slot='header-custom'] {
+ padding-top: 0;
+ }
+}
diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts
index c4526a4ec..47e762e78 100644
--- a/apps/demo/vite.config.ts
+++ b/apps/demo/vite.config.ts
@@ -1,10 +1,14 @@
+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 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 +52,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 +215,111 @@ 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);
+ if (reqPath.includes('..')) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ res.end('Path contains forbidden characters');
+ return;
+ }
+ try {
+ 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();
+ }
+ } 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 +328,8 @@ export default defineConfig(() => {
},
},
},
+ server: {
+ hmr: !process.env.NO_HMR,
+ },
};
});
diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx
index f203ec3b8..a755738fe 100644
--- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx
+++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx
@@ -26,6 +26,14 @@ import {
CUSTOM_HUNK_SEPARATORS_EXAMPLE,
CUSTOM_HUNK_SEPARATORS_SWITCHER,
} from '../docs/CustomHunkSeparators/constants';
+import {
+ EDITOR_LAZY_FILE_EXAMPLE,
+ EDITOR_QUICK_EDIT_CONTEXT_TYPE,
+ EDITOR_QUICK_EDIT_EXAMPLE,
+ EDITOR_REACT_EXAMPLE,
+ EDITOR_VANILLA_FILE_DIFF_EXAMPLE,
+ EDITOR_VANILLA_FILE_EXAMPLE,
+} from '../docs/Editor/constants';
import {
INSTALLATION_EXAMPLES,
PACKAGE_MANAGERS,
@@ -161,6 +169,7 @@ export default function DocsPage() {
+
@@ -371,6 +380,36 @@ async function CodeViewSection() {
return {content};
}
+async function EditorSection() {
+ const [
+ editorVanillaFileExample,
+ editorVanillaFileDiffExample,
+ editorLazyFileExample,
+ editorQuickEditContextType,
+ editorQuickEditExample,
+ editorReactExample,
+ ] = await Promise.all([
+ preloadFile(EDITOR_VANILLA_FILE_EXAMPLE),
+ preloadFile(EDITOR_VANILLA_FILE_DIFF_EXAMPLE),
+ preloadFile(EDITOR_LAZY_FILE_EXAMPLE),
+ preloadFile(EDITOR_QUICK_EDIT_CONTEXT_TYPE),
+ preloadFile(EDITOR_QUICK_EDIT_EXAMPLE),
+ preloadFile(EDITOR_REACT_EXAMPLE),
+ ]);
+ const content = await renderMDX({
+ filePath: '(diffs)/docs/Editor/content.mdx',
+ scope: {
+ editorVanillaFileExample,
+ editorVanillaFileDiffExample,
+ editorLazyFileExample,
+ editorQuickEditContextType,
+ editorQuickEditExample,
+ editorReactExample,
+ },
+ });
+ return {content};
+}
+
async function VirtualizationSection() {
const [
reactVirtualizerBasic,
diff --git a/apps/docs/app/(diffs)/docs/Editor/ComponentTabs.tsx b/apps/docs/app/(diffs)/docs/Editor/ComponentTabs.tsx
new file mode 100644
index 000000000..361fe38dd
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/Editor/ComponentTabs.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import type { PreloadedFileResult } from '@pierre/diffs/ssr';
+import { useState } from 'react';
+
+import { DocsCodeExample } from '@/components/docs/DocsCodeExample';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
+
+type EditorComponentMode = 'file' | 'file-diff';
+
+interface EditorComponentTabsProps {
+ fileExample: PreloadedFileResult;
+ fileDiffExample: PreloadedFileResult;
+}
+
+export function EditorComponentTabs({
+ fileExample,
+ fileDiffExample,
+}: EditorComponentTabsProps) {
+ const [mode, setMode] = useState('file');
+
+ return (
+ <>
+ setMode(value as EditorComponentMode)}
+ >
+ File
+ FileDiff
+
+ {mode === 'file' ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/docs/app/(diffs)/docs/Editor/EditorDemo.tsx b/apps/docs/app/(diffs)/docs/Editor/EditorDemo.tsx
new file mode 100644
index 000000000..610a8f9c8
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/Editor/EditorDemo.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+import type { FileContents } from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
+import { EditorProvider, File } from '@pierre/diffs/react';
+import { useMemo, useState } from 'react';
+
+const initialFile: FileContents = {
+ name: 'editable-demo.ts',
+ contents: `import { Editor } from '@pierre/diffs/editor';
+
+const editor = new Editor({
+ onChange(file, lineAnnotations) {
+ console.log('change', file.name, file.contents, lineAnnotations);
+ },
+});
+
+const dispose = editor.edit(fileInstance);
+
+// Later, when the editor is no longer needed:
+dispose();
+`,
+};
+
+export function EditorDemo() {
+ const [file, _setFile] = useState(initialFile);
+ const [changeCount, setChangeCount] = useState(0);
+
+ const editor = useMemo(
+ () =>
+ new Editor({
+ enabledQuickEdit: true,
+ renderQuickEdit({ close, replaceSelectionText, getSelectionText }) {
+ const container = document.createElement('div');
+ const button = document.createElement('button');
+
+ container.style.cssText =
+ 'display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0;';
+ button.type = 'button';
+ button.textContent = 'Wrap selection in TODO()';
+ button.style.cssText =
+ 'font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 35%, transparent); background: color-mix(in srgb, currentColor 8%, transparent); cursor: pointer;';
+ button.addEventListener('click', () => {
+ replaceSelectionText(`TODO(${getSelectionText()})`);
+ close();
+ });
+
+ container.appendChild(button);
+ return container;
+ },
+ onChange(_file) {
+ // setFile(nextFile);
+ setChangeCount((count) => count + 1);
+ },
+ }),
+ []
+ );
+
+ return (
+
+
+
+
Editable file demo
+
+ Click into the code and type to try the editor.
+
+
+
+ Changes: {changeCount}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/app/(diffs)/docs/Editor/constants.ts b/apps/docs/app/(diffs)/docs/Editor/constants.ts
new file mode 100644
index 000000000..f1f029fd6
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/Editor/constants.ts
@@ -0,0 +1,228 @@
+import type { PreloadFileOptions } from '@pierre/diffs/ssr';
+
+import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS';
+
+const options = {
+ theme: { dark: 'pierre-dark', light: 'pierre-light' },
+ disableFileHeader: true,
+ unsafeCSS: CustomScrollbarCSS,
+} as const;
+
+export const EDITOR_VANILLA_FILE_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'editor_vanilla_file.ts',
+ contents: `import {
+ Virtualizer,
+ VirtualizedFile,
+ type FileContents,
+} from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
+
+const root = document.getElementById('file-scroll-root');
+const content = document.getElementById('file-scroll-content');
+if (root == null || content == null) {
+ throw new Error('Expected virtualized file containers to exist');
+}
+
+const file: FileContents = {
+ name: 'example.ts',
+ contents: 'export function greet(name: string) {\\n return name;\\n}',
+};
+
+const virtualizer = new Virtualizer();
+virtualizer.setup(root, content);
+
+const fileInstance = new VirtualizedFile(
+ { theme: { dark: 'pierre-dark', light: 'pierre-light' } },
+ virtualizer
+);
+fileInstance.render({ file, containerWrapper: content });
+
+const editor = new Editor({
+ onChange(file, lineAnnotations) {
+ console.log('change', file.name, file.contents, lineAnnotations);
+ },
+});
+
+const dispose = editor.edit(fileInstance);
+
+// Later, when the editor is no longer needed:
+dispose();
+virtualizer.cleanUp();
+// Or call editor.cleanUp();`,
+ },
+ options,
+};
+
+export const EDITOR_VANILLA_FILE_DIFF_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'editor_vanilla_file_diff.ts',
+ contents: `import {
+ Virtualizer,
+ VirtualizedFileDiff,
+ type FileContents,
+} from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
+
+const root = document.getElementById('diff-scroll-root');
+const content = document.getElementById('diff-scroll-content');
+if (root == null || content == null) {
+ throw new Error('Expected virtualized diff containers to exist');
+}
+
+const oldFile: FileContents = {
+ name: 'example.ts',
+ contents: 'export function greet(name: string) {\\n return name;\\n}',
+};
+
+const newFile: FileContents = {
+ ...oldFile,
+ contents:
+ 'export function greet(name: string) {\\n return "Hello, " + name;\\n}',
+};
+
+const virtualizer = new Virtualizer();
+virtualizer.setup(root, content);
+
+const fileDiffInstance = new VirtualizedFileDiff(
+ { theme: { dark: 'pierre-dark', light: 'pierre-light' } },
+ virtualizer
+);
+fileDiffInstance.render({ oldFile, newFile, containerWrapper: content });
+
+const editor = new Editor({
+ onChange(file, lineAnnotations) {
+ console.log('change', file.name, file.contents, lineAnnotations);
+ },
+});
+
+const dispose = editor.edit(fileDiffInstance);
+
+// Later, when the editor is no longer needed:
+dispose();
+virtualizer.cleanUp();
+// Or call editor.cleanUp();`,
+ },
+ options,
+};
+
+export const EDITOR_LAZY_FILE_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'editor_lazy_file.ts',
+ contents: `import type { VirtualizedFile } from '@pierre/diffs';
+
+const button = document.getElementById('edit-button');
+
+async function edit(fileInstance: VirtualizedFile): Promise<() => void> {
+ const { Editor } = await import('@pierre/diffs/editor');
+ const editor = new Editor({
+ onChange(file, lineAnnotations) {
+ console.log('change', file.name, file.contents, lineAnnotations);
+ },
+ });
+ return editor.edit(fileInstance);
+}
+
+// Click to edit and lazy-load the editor bundle only when it is needed.
+button.addEventListener('click', () => {
+ void edit(fileInstance);
+});`,
+ },
+ options,
+};
+
+export const EDITOR_QUICK_EDIT_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'editor_quick_edit.ts',
+ contents: `import { Editor } from '@pierre/diffs/editor';
+
+const editor = new Editor({
+ enabledQuickEdit: true,
+ renderQuickEdit: (context) => {
+ const container = document.createElement('div');
+ const button = document.createElement('button');
+
+ button.type = 'button';
+ button.textContent = 'Wrap selection in TODO()';
+ button.addEventListener('click', () => {
+ context.replaceSelectionText(\`TODO(\${context.getSelectionText()})\`);
+ context.close();
+ });
+
+ container.appendChild(button);
+ return container;
+ },
+});`,
+ },
+ options,
+};
+
+export const EDITOR_QUICK_EDIT_CONTEXT_TYPE: PreloadFileOptions = {
+ file: {
+ name: 'quick_edit_context.ts',
+ contents: `export interface QuickEditContext {
+ /** The current selection. */
+ selection: EditorSelection;
+ /** The text document. */
+ textDocument: TextDocument;
+ /** Applies the edits to the text document. */
+ applyEdits: (edits: TextEdit[]) => void;
+ /** Gets the text of the current selection. */
+ getSelectionText: () => string;
+ /** Replaces the text of the current selection. */
+ replaceSelectionText: (text: string) => void;
+ /** Closes the quick edit. */
+ close: () => void;
+}`,
+ },
+ options,
+};
+
+export const EDITOR_REACT_EXAMPLE: PreloadFileOptions = {
+ file: {
+ name: 'editor_react.tsx',
+ contents: `import type { FileContents } from '@pierre/diffs';
+import { Editor } from '@pierre/diffs/editor';
+import { EditorProvider, File } from '@pierre/diffs/react';
+import { useMemo, useState } from 'react';
+
+const file: FileContents = {
+ name: 'example.ts',
+ contents: \`function greet(name: string) {
+ console.log(\\\`Hello, \\\${name}!\\\`);
+}
+
+export { greet };\`,
+};
+
+export function EditCodeFile() {
+ const [editable, setEditable] = useState(true);
+ const editor = useMemo(
+ () =>
+ new Editor({
+ onChange(file, lineAnnotations) {
+ console.log('change', file.name, file.contents, lineAnnotations);
+ },
+ }),
+ []
+ );
+
+ return (
+
+
+
+
+
+ );
+}`,
+ },
+ options,
+};
diff --git a/apps/docs/app/(diffs)/docs/Editor/content.mdx b/apps/docs/app/(diffs)/docs/Editor/content.mdx
new file mode 100644
index 000000000..ed935f4cb
--- /dev/null
+++ b/apps/docs/app/(diffs)/docs/Editor/content.mdx
@@ -0,0 +1,138 @@
+## Editor
+
+}>
+ The editor API is experimental and subject to change.
+
+
+The editor is a pluggable editing layer for `File`-style code surfaces. It adds
+keyboard-driven editing to an already-rendered file or diff while the existing
+renderer continues to own syntax highlighting, layout, annotations, and
+virtualization.
+
+Import it from `@pierre/diffs/editor`. It is intentionally separate from the
+core bundle so you can lazy-load it when editing is optional.
+
+- Text editing with a document model and debounced `onChange` callbacks
+- Selection management, including multiple cursors (Cmd/Ctrl-click to add)
+- Undo and redo
+- Find-in-file search (panel and incremental match)
+- [Quick Edit](#editor-quick-edit) (opt-in, custom UI)
+- Automatic indentation on Tab and Shift+Tab
+- Mobile-friendly input via native `contentEditable` and selection handles
+
+
+
+### How It Works
+
+The editor does not replace `File`, `FileDiff`, or their virtualized variants.
+You render the surface first, then attach editing:
+
+1. **Attach** — Call `editor.edit(component)` in vanilla JS, or pass
+ `contentEditable` on a React `File` / `FileDiff` wrapped in `EditorProvider`.
+ The hookup returns a dispose function (vanilla) or runs automatically when
+ `contentEditable` toggles (React).
+2. **Edit surface** — The editor sets `contentEditable` on the code content
+ element so mobile keyboards, paste, and native selection UI work as users
+ expect. Clipboard shortcuts are handled by the browser; editor commands
+ handle structure-aware actions like indent and undo.
+3. **Selections** — Carets and ranges are tracked with the native `Selection`
+ API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds
+ another cursor without clearing existing ones.
+4. **Updates** — Keystrokes update an internal `TextDocument`, then changed
+ lines are re-highlighted through the same tokenizer pipeline as read-only
+ mode. Your `onChange` handler receives updated `FileContents` (and line
+ annotations when present), debounced by 500ms.
+
+When attaching, the editor may normalize component options (for example,
+enabling the token transformer and disabling gutter utilities that conflict with
+editing) and trigger a re-render.
+
+### Vanilla JS
+
+For vanilla integrations, render with `VirtualizedFile` or `VirtualizedFileDiff`
+inside a [Virtualizer](#virtualization) scroll region. Virtualization keeps
+large files responsive; the editor cooperates with the virtualizer's visible
+line window instead of assuming the full document is in the DOM.
+
+
+
+}>
+ Editing a `FileDiff` requires the full file contents of both sides. The editor
+ targets the addition side (the new version of the file) and cannot reconstruct
+ it from a partial diff. Make sure one of the following is true before attaching
+ the editor:
+
+- You rendered the diff by passing `oldFile` and `newFile` as `FileContents`
+ objects directly (the common case).
+- You rendered from a `FileDiff` object where `isPartial` is `false`, meaning
+ `additionLines` contains the complete new-file contents (not just the patch
+ context lines).
+
+If neither condition is met — for example, when the diff was parsed from a raw
+patch with no accompanying source files — `editor.edit()` will attach but
+editing will have no effect.
+
+
+
+### React
+
+Create one `Editor` instance (typically with `useMemo`), wrap your tree in
+`EditorProvider`, and set `contentEditable` on `File` or `FileDiff` when editing
+should be active. The provider cleans up the editor on unmount.
+
+
+
+### Lazy Loading
+
+Because `@pierre/diffs/editor` is a standalone entry point, you can
+dynamic-import it only when the user enters edit mode. That keeps the initial
+page bundle smaller and can improve LCP on pages where editing is rare.
+
+
+
+### Quick Edit
+
+Quick Edit is disabled by default. Enable it on the `Editor` constructor with
+`enabledQuickEdit: true` and provide `renderQuickEdit` to return the UI for a
+given line (for example, an inline prompt or form). The editor shows a gutter
+icon on the active line; clicking it mounts your element and exposes helpers
+such as `getSelectionText`, `replaceSelectionText`, and `close` on the context
+object.
+
+
+
+The `renderQuickEdit` callback receives a context object with the current
+selection, editable text document, and helpers for updating or closing the Quick
+Edit UI:
+
+
+
+### Keyboard Shortcuts
+
+Shortcuts use the primary modifier: **⌘ on macOS**, **Ctrl on Windows and
+Linux**. On macOS, document start/end use ↑ and ↓ with the modifier; on other
+platforms, **Home** and **End** are used instead.
+
+| Command | Shortcut |
+| ------------------------- | ------------------ |
+| `selectAll` | Cmd + A |
+| `copy` | Cmd + C |
+| `cut` | Cmd + X |
+| `paste` | Cmd + V/Y |
+| `findNextMatch` | Cmd + D |
+| `indent` | Tab |
+| `outdent` | Shift + Tab |
+| `moveCursorToDocStart` | Cmd + Up |
+| `moveCursorToDocEnd` | Cmd + Down |
+| `expandSelectionDocStart` | Cmd + Shift + Up |
+| `expandSelectionDocEnd` | Cmd + Shift + Down |
+| `undo` | Cmd + Z |
+| `redo` | Cmd + Shift + Z |
+
+### Planned Features
+
+- Command palette (`Cmd + K`)
+- Language Server Protocol (LSP) integration
diff --git a/apps/docs/app/(diffs)/docs/Installation/content.mdx b/apps/docs/app/(diffs)/docs/Installation/content.mdx
index 037838c91..36b571ae1 100644
--- a/apps/docs/app/(diffs)/docs/Installation/content.mdx
+++ b/apps/docs/app/(diffs)/docs/Installation/content.mdx
@@ -14,5 +14,6 @@ The package provides several entry points for different use cases:
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `@pierre/diffs` | [Vanilla JS components](#vanilla-js-api) and [utility functions](#utilities) for parsing and rendering diffs |
| `@pierre/diffs/react` | [React components](#react-api) for rendering diffs with full interactivity |
+| `@pierre/diffs/editor` | [Editor](#editor) for making file views editable |
| `@pierre/diffs/ssr` | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting |
| `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) for offloading syntax highlighting to background threads |
diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css
index 3419f4d0f..0121ba1fa 100644
--- a/apps/docs/app/globals.css
+++ b/apps/docs/app/globals.css
@@ -195,7 +195,7 @@
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground font-geist;
+ @apply bg-[var(--diffshub-sidebar-bg,_var(--color-background))] text-foreground font-geist;
}
body.diffshub {
background-color: var(--diffshub-sidebar-bg);
diff --git a/apps/docs/lib/mdx.tsx b/apps/docs/lib/mdx.tsx
index 36312f6a1..19c43da43 100644
--- a/apps/docs/lib/mdx.tsx
+++ b/apps/docs/lib/mdx.tsx
@@ -16,6 +16,8 @@ import remarkGfm from 'remark-gfm';
import { CustomHunkSeparators } from '../app/(diffs)/_examples/CustomHunkSeparators/CustomHunkSeparators';
import { CodeViewExampleTabs } from '../app/(diffs)/docs/CodeView/ExampleTabs';
+import { EditorComponentTabs } from '../app/(diffs)/docs/Editor/ComponentTabs';
+import { EditorDemo } from '../app/(diffs)/docs/Editor/EditorDemo';
import { PackageManagerTabs } from '../app/(diffs)/docs/Installation/PackageManagerTabs';
import { CodeToggle } from '../app/(diffs)/docs/Overview/CodeToggle';
import {
@@ -63,6 +65,8 @@ const defaultComponents = {
IconFlagFill,
DocsCodeExample,
CodeViewExampleTabs,
+ EditorComponentTabs,
+ EditorDemo,
CustomHunkSeparators,
OverviewFileTree,
MultiFileDiff,
diff --git a/apps/docs/scripts/generate-llms-txt.ts b/apps/docs/scripts/generate-llms-txt.ts
index 40f046ce6..ffe16b230 100644
--- a/apps/docs/scripts/generate-llms-txt.ts
+++ b/apps/docs/scripts/generate-llms-txt.ts
@@ -42,6 +42,7 @@ const DIFFS_SECTIONS = [
'CoreTypes',
'ReactAPI',
'VanillaAPI',
+ 'Editor',
'Virtualization',
'CustomHunkSeparators',
'Utilities',
@@ -81,6 +82,8 @@ const SECTION_DESCRIPTIONS: Record> = {
'MultiFileDiff, PatchDiff, FileDiff, File components and shared props',
VanillaAPI:
'FileDiff and File classes, props, deprecated vanilla custom hunk separators, and low-level renderers',
+ Editor:
+ 'Pluggable editing for File surfaces, including text editing, native selections, history, search, quick edit, and shortcuts',
Virtualization: 'Virtual scrolling for large diffs and files',
CustomHunkSeparators:
'Built-in separator presets, CSS customization hooks, and the discouraged vanilla escape hatch',
diff --git a/bun.lock b/bun.lock
index 3ae9085d5..20b3b2708 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,7 @@
"@pierre/vscode-icons": "^0.0.9",
},
"devDependencies": {
- "@pierre/icons": "^0.5.0",
+ "@pierre/icons": "^0.6.0",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"husky": "^9.1.7",
@@ -28,6 +28,7 @@
"version": "0.0.0",
"dependencies": {
"@pierre/diffs": "workspace:*",
+ "@pierre/trees": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:",
"shiki": "catalog:",
@@ -105,7 +106,7 @@
},
"packages/diffs": {
"name": "@pierre/diffs",
- "version": "1.2.0-beta.6",
+ "version": "1.2.1",
"dependencies": {
"@pierre/theme": "catalog:",
"@shikijs/transformers": "^3.0.0",
@@ -197,7 +198,7 @@
},
"packages/trees": {
"name": "@pierre/trees",
- "version": "1.0.0-beta.3",
+ "version": "1.0.0-beta.4",
"dependencies": {
"@pierre/path-store": "workspace:*",
"preact": "catalog:",
@@ -261,7 +262,7 @@
"@octokit/auth-app": "8.1.1",
"@octokit/openapi-types": "26.0.0",
"@octokit/rest": "22.0.0",
- "@pierre/icons": "0.5.0",
+ "@pierre/icons": "0.6.0",
"@pierre/storage": "0.0.10",
"@pierre/theme": "1.0.3",
"@pierre/vscode-icons": "0.0.9",
@@ -640,7 +641,7 @@
"@pierre/docs": ["@pierre/docs@workspace:apps/docs"],
- "@pierre/icons": ["@pierre/icons@0.5.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-c46ZiKK8Vh3fhJM7Ci9jKW36UKsD+DTxVnr91liX0bgpc2jkwICW9bx9XTQsETpLR9CnoLSA/4ZQyLrb5HO0GA=="],
+ "@pierre/icons": ["@pierre/icons@0.6.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-GAm238ShSqNV9KXUiSGIkTvgIhFr+ohKv5f/efNNRZKmwdwGfZ1FUbRtofEwq5c8ZK+BXqBW5SlgVvVbRsPdrg=="],
"@pierre/path-store": ["@pierre/path-store@workspace:packages/path-store"],
@@ -2068,6 +2069,8 @@
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
+ "@pierre/docs/@pierre/icons": ["@pierre/icons@0.6.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-ulfg47GOFSExBOuLDU1eIEY/d++60FYNToiZglQ4tYy74xdEiD29m1ior9fRZoyZmiSNZx+MIRqme3pkMvlORg=="],
+
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
diff --git a/package.json b/package.json
index d3d04b3d0..c7fc254c1 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"@octokit/auth-app": "8.1.1",
"@octokit/openapi-types": "26.0.0",
"@octokit/rest": "22.0.0",
- "@pierre/icons": "0.5.0",
+ "@pierre/icons": "0.6.0",
"@pierre/storage": "0.0.10",
"@pierre/theme": "1.0.3",
"@pierre/vscode-icons": "0.0.9",
@@ -121,7 +121,7 @@
"@pierre/vscode-icons": "^0.0.9"
},
"devDependencies": {
- "@pierre/icons": "^0.5.0",
+ "@pierre/icons": "^0.6.1",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"husky": "^9.1.7",
diff --git a/packages/diffs/package.json b/packages/diffs/package.json
index b044c4e1f..9f630cca7 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"
@@ -53,6 +57,10 @@
"import": "./dist/worker/worker-portable.js"
}
},
+ "publishConfig": {
+ "access": "public",
+ "tag": "beta"
+ },
"scripts": {
"build": "tsdown --clean",
"dev": "echo 'Watching for changes…' && tsdown --watch --log-level error",
diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts
index 477a7d053..eee622a76 100644
--- a/packages/diffs/src/components/File.ts
+++ b/packages/diffs/src/components/File.ts
@@ -25,7 +25,11 @@ import { SVGSpriteSheet } from '../sprite';
import type {
AppliedThemeStyleCache,
BaseCodeOptions,
+ DiffsEditableComponent,
+ DiffsEditor,
+ DiffsTextDocument,
FileContents,
+ HighlightedToken,
LineAnnotation,
PrePropertiesConfig,
RenderFileMetadata,
@@ -58,8 +62,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;
@@ -118,7 +120,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}`;
@@ -160,6 +164,8 @@ export class File {
protected renderRange: RenderRange | undefined;
protected enabled = true;
+ protected editor: DiffsEditor | undefined;
+
constructor(
public options: FileOptions = { theme: DEFAULT_THEMES },
private workerManager?: WorkerPoolManager | undefined,
@@ -324,6 +330,10 @@ export class File {
}
this.enabled = false;
+
+ // Clean up the editor
+ this.editor?.cleanUp();
+ this.editor = undefined;
}
public virtualizedSetup(): void {
@@ -427,12 +437,60 @@ export class File {
this.flushManagers();
}
- public getOrCreateLineCache(
+ protected getOrCreateLineOffSets(
file: FileContents | undefined = this.file
- ): string[] {
- return file != null
- ? this.fileRenderer.getOrCreateLineCache(file)
- : EMPTY_STRINGS;
+ ): number[] {
+ return file != null ? this.fileRenderer.getOrCreateLineOffsets(file) : [0]; // empty string
+ }
+
+ protected updateBuffers(renderRange: RenderRange): void {
+ if (this.pre != null) {
+ this.applyBuffers(this.pre, renderRange);
+ }
+ }
+
+ public setupEditor(editor: DiffsEditor): () => void {
+ this.editor?.cleanUp();
+ const fileContainer = this.fileContainer;
+ const file = this.file;
+ if (fileContainer != null && file != null) {
+ void this.fileRenderer.initializeHighlighter().then((highlighter) => {
+ editor.emitRender(
+ highlighter,
+ fileContainer,
+ file,
+ this.lineAnnotations,
+ this.renderRange
+ );
+ });
+ }
+ this.editor = editor;
+ return () => {
+ this.editor = undefined;
+ };
+ }
+
+ public emitLineChange(
+ lines: Map>,
+ themeType: 'dark' | 'light'
+ ): void {
+ this.fileRenderer.applyDirtyLines(lines, themeType);
+ }
+
+ public emitLayoutChange(
+ textDocument: DiffsTextDocument,
+ newLineAnnotations?: LineAnnotation[]
+ ): void {
+ this.fileRenderer.applyLayoutChange(textDocument, newLineAnnotations);
+ if (
+ newLineAnnotations != null &&
+ newLineAnnotations !== this.lineAnnotations
+ ) {
+ this.annotationCache.forEach(({ element }) => element.remove());
+ this.annotationCache.clear();
+ this.lineAnnotations = newLineAnnotations;
+ this.rerender();
+ }
}
public render({
@@ -574,6 +632,19 @@ export class File {
}
this.renderAnnotations();
this.renderGutterUtility();
+
+ const editor = this.editor;
+ if (editor != null) {
+ void this.fileRenderer.initializeHighlighter().then((highlighter) => {
+ editor.emitRender(
+ highlighter,
+ fileContainer,
+ file,
+ this.lineAnnotations,
+ this.renderRange
+ );
+ });
+ }
} catch (error: unknown) {
if (disableErrorHandling) {
throw error;
@@ -663,7 +734,7 @@ export class File {
) {
return;
}
- const lines = this.fileRenderer.getOrCreateLineCache(file);
+ const lines = this.fileRenderer.getOrCreateLineOffsets(file);
if (
lines.length >
(this.options.tokenizeMaxLength ?? DEFAULT_TOKENIZE_MAX_LENGTH)
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 74e888433..57a95bdb7 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -33,6 +33,9 @@ import type {
BaseDiffOptions,
CustomPreProperties,
DiffLineAnnotation,
+ DiffsEditableComponent,
+ DiffsEditor,
+ DiffsTextDocument,
ExpansionDirections,
FileContents,
FileDiffMetadata,
@@ -168,7 +171,9 @@ interface HydrationSetup {
let instanceId = -1;
-export class FileDiff {
+export class FileDiff<
+ LAnnotation = undefined,
+> implements DiffsEditableComponent {
// NOTE(amadeus): We sorta need this to ensure the web-component file is
// properly loaded
static LoadedCustomComponent: boolean = DiffsContainerLoaded;
@@ -219,6 +224,9 @@ export class FileDiff {
protected enabled = true;
+ protected editor: DiffsEditor | undefined;
+ protected renderDiffTimer: ReturnType | undefined;
+
constructor(
public options: FileDiffOptions = { theme: DEFAULT_THEMES },
protected workerManager?: WorkerPoolManager | undefined,
@@ -521,6 +529,13 @@ export class FileDiff {
}
this.enabled = false;
+
+ this.editor?.cleanUp();
+ this.editor = undefined;
+ if (this.renderDiffTimer !== undefined) {
+ clearTimeout(this.renderDiffTimer);
+ }
+ this.renderDiffTimer = undefined;
}
public virtualizedSetup(): void {
@@ -901,6 +916,21 @@ export class FileDiff {
if (!deferManagers) {
this.flushManagers();
}
+
+ const editor = this.editor;
+ const file = this.getAdditionFile();
+ if (editor != null && file != null) {
+ void this.hunksRenderer.initializeHighlighter().then((highlighter) => {
+ editor.emitRender(
+ highlighter,
+ fileContainer,
+ file,
+ this.lineAnnotations,
+ this.renderRange,
+ 'advanced'
+ );
+ });
+ }
} catch (error: unknown) {
if (disableErrorHandling) {
throw error;
@@ -922,6 +952,105 @@ export class FileDiff {
}
}
+ emitLayoutChange(
+ textDocument: DiffsTextDocument,
+ newLineAnnotations?: DiffLineAnnotation[]
+ ): void {
+ if (
+ newLineAnnotations !== undefined &&
+ newLineAnnotations !== this.lineAnnotations
+ ) {
+ this.setLineAnnotations(newLineAnnotations);
+ this.hunksRenderer.setLineAnnotations(this.lineAnnotations);
+ }
+
+ const deletionFile = this.getDeletionFile();
+ if (deletionFile != null) {
+ const { name, lang } = deletionFile;
+ const newFile = {
+ name,
+ lang,
+ cacheKey: name + '-' + Date.now(),
+ } as FileContents;
+ Object.defineProperty(newFile, 'contents', {
+ get: () => textDocument.getText(),
+ });
+ if (this.renderDiffTimer !== undefined) {
+ clearTimeout(this.renderDiffTimer);
+ }
+ this.renderDiffTimer = setTimeout(() => {
+ this.fileDiff = parseDiffFromFile(
+ deletionFile,
+ newFile,
+ this.options.parseDiffOptions
+ );
+ this.hunksRenderer.renderDiff(this.fileDiff, this.renderRange);
+ }, 500);
+ }
+ }
+
+ setupEditor(editor: DiffsEditor): () => void {
+ this.editor?.cleanUp();
+ const fileContainer = this.fileContainer;
+ const file = this.getAdditionFile();
+ if (fileContainer != null && file != null) {
+ void this.hunksRenderer.initializeHighlighter().then((highlighter) => {
+ editor.emitRender(
+ highlighter,
+ fileContainer,
+ file,
+ this.lineAnnotations,
+ this.renderRange,
+ 'advanced'
+ );
+ });
+ }
+ this.editor = editor;
+ return () => {
+ this.editor = undefined;
+ };
+ }
+
+ private getDeletionFile(): FileContents | undefined {
+ if (this.deletionFile != null) {
+ return this.deletionFile;
+ }
+ const fileDiff = this.fileDiff;
+ if (fileDiff != null && !fileDiff.isPartial) {
+ const { name, lang, cacheKey } = fileDiff;
+ const file = {
+ name,
+ lang,
+ cacheKey,
+ } as FileContents;
+ Object.defineProperty(file, 'contents', {
+ get: () => fileDiff.deletionLines.join(''),
+ });
+ return file;
+ }
+ return undefined;
+ }
+
+ private getAdditionFile(): FileContents | undefined {
+ if (this.additionFile != null) {
+ return this.additionFile;
+ }
+ const fileDiff = this.fileDiff;
+ if (fileDiff != null && !fileDiff.isPartial) {
+ const { name, lang, cacheKey } = fileDiff;
+ const file = {
+ name,
+ lang,
+ cacheKey,
+ } as FileContents;
+ Object.defineProperty(file, 'contents', {
+ get: () => fileDiff.additionLines.join(''),
+ });
+ return file;
+ }
+ return undefined;
+ }
+
private removeRenderedCode(): void {
this.resizeManager.cleanUp();
this.scrollSyncManager.cleanUp();
diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts
index 74136eea0..d5970c121 100644
--- a/packages/diffs/src/components/VirtualizedFile.ts
+++ b/packages/diffs/src/components/VirtualizedFile.ts
@@ -1,6 +1,8 @@
import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants';
import type {
+ DiffsTextDocument,
FileContents,
+ LineAnnotation,
NumericScrollLineAnchor,
PendingCodeViewLayoutReset,
RenderRange,
@@ -9,13 +11,13 @@ import type {
ThemeTypes,
VirtualFileMetrics,
} from '../types';
+import { areFilesEqual } from '../utils/areFilesEqual';
import { areObjectsEqual } from '../utils/areObjectsEqual';
import { areOptionsEqual } from '../utils/areOptionsEqual';
import {
getVirtualFileHeaderRegion,
getVirtualFilePaddingBottom,
} from '../utils/computeVirtualFileMetrics';
-import { iterateOverFile } from '../utils/iterateOverFile';
import type { WorkerPoolManager } from '../worker';
import type { CodeView } from './CodeView';
import { File, type FileOptions, type FileRenderProps } from './File';
@@ -287,8 +289,7 @@ export class VirtualizedFile<
}
const { disableFileHeader = false, collapsed = false } = this.options;
- const lines = this.getOrCreateLineCache(this.file);
- const lastLineIndex = getLastVisibleLineIndex(lines);
+ const lastLineIndex = this.getOrCreateLineOffSets(this.file).at(-1) ?? -1;
let top = getVirtualFileHeaderRegion(this.metrics, disableFileHeader);
if (collapsed || lastLineIndex < 0) {
@@ -342,8 +343,7 @@ export class VirtualizedFile<
return undefined;
}
- const lines = this.getOrCreateLineCache(this.file);
- const lastLineIndex = getLastVisibleLineIndex(lines);
+ const lastLineIndex = this.getOrCreateLineOffSets(this.file).at(-1) ?? -1;
if (lastLineIndex < 0) {
return undefined;
}
@@ -463,7 +463,7 @@ export class VirtualizedFile<
overflow = 'scroll',
} = this.options;
const { lineHeight } = this.metrics;
- const lines = this.getOrCreateLineCache(this.file);
+ const lineCount = this.fileRenderer.getLineCount(this.file);
const headerRegion = getVirtualFileHeaderRegion(
this.metrics,
disableFileHeader
@@ -477,18 +477,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.addLayoutCheckpoint(lineIndex, this.height);
- this.height += this.getLineHeight(lineIndex, false);
- },
- });
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) {
+ this.addLayoutCheckpoint(lineIndex, this.height);
+ this.height += this.getLineHeight(lineIndex, false);
+ }
}
- if (lines.length > 0) {
+ if (lineCount > 0) {
this.height += paddingBottom;
}
@@ -533,16 +530,45 @@ export class VirtualizedFile<
this.virtualizer.instanceChanged(this, false);
}
+ override emitLayoutChange(
+ textDocument: DiffsTextDocument,
+ newLineAnnotations?: LineAnnotation[],
+ shouldUpdateBuffer = false
+ ): void {
+ const previousRenderRange = this.renderRange;
+ super.emitLayoutChange(textDocument, newLineAnnotations);
+ this.getSimpleVirtualizer()?.markDOMDirty();
+ this.resetLayoutCache(true);
+ // Update the buffers caused by the line-count change to ensure the editor
+ // scrolls to the correct position before re-rendering
+ if (
+ shouldUpdateBuffer &&
+ previousRenderRange !== undefined &&
+ this.file !== undefined
+ ) {
+ const windowSpecs = this.virtualizer.getWindowSpecs();
+ const renderRange = this.computeRenderRangeFromWindow(
+ this.file,
+ this.top ?? 0,
+ windowSpecs
+ );
+ if (renderRange.bufferAfter !== previousRenderRange.bufferAfter) {
+ this.updateBuffers(renderRange);
+ }
+ }
+ }
+
override render({
fileContainer,
file,
forceRender = false,
...props
}: FileRenderProps): boolean {
+ const didFileChange = this.file == null || !areFilesEqual(this.file, file);
const { forceRenderOverride, isSetup } = this;
this.forceRenderOverride = undefined;
- this.file ??= file;
+ this.file = file;
fileContainer = this.getOrCreateFileContainerNode(fileContainer);
@@ -574,6 +600,10 @@ export class VirtualizedFile<
this.isSetup = true;
} else {
this.top ??= this.getVirtualizedTop();
+ if (didFileChange) {
+ this.getSimpleVirtualizer()?.markDOMDirty();
+ this.resetLayoutCache(true);
+ }
}
if (!this.isVisible && this.isSimpleMode()) {
@@ -719,8 +749,7 @@ export class VirtualizedFile<
): RenderRange {
const { disableFileHeader = false, overflow = 'scroll' } = this.options;
const { hunkLineCount, lineHeight } = this.metrics;
- const lines = this.getOrCreateLineCache(file);
- const lineCount = lines.length;
+ const lineCount = this.fileRenderer.getLineCount(file);
const fileHeight = this.height;
const headerRegion = getVirtualFileHeaderRegion(
this.metrics,
@@ -813,51 +842,50 @@ export class VirtualizedFile<
let centerHunk: number | undefined;
let overflowCounter: number | undefined;
- iterateOverFile({
- lines,
- startingLine: checkpoint?.lineIndex ?? 0,
- callback: ({ lineIndex }) => {
- const isAtHunkBoundary = currentLine % hunkLineCount === 0;
- const currentHunk = Math.floor(currentLine / hunkLineCount);
-
- if (isAtHunkBoundary) {
- hunkOffsets[currentHunk] = absoluteLineTop - (fileTop + headerRegion);
-
- if (overflowCounter != null) {
- if (overflowCounter <= 0) {
- return true;
- }
- overflowCounter--;
- }
- }
+ const startingLineIndex = checkpoint?.lineIndex ?? 0;
+ for (
+ let lineIndex = startingLineIndex;
+ lineIndex < lineCount;
+ lineIndex++
+ ) {
+ const isAtHunkBoundary = currentLine % hunkLineCount === 0;
+ const currentHunk = Math.floor(currentLine / hunkLineCount);
- const lineHeight = this.getLineHeight(lineIndex, false);
+ if (isAtHunkBoundary) {
+ hunkOffsets[currentHunk] = absoluteLineTop - (fileTop + headerRegion);
- // Track visible region
- if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
- firstVisibleHunk ??= currentHunk;
+ if (overflowCounter != null) {
+ if (overflowCounter <= 0) {
+ break;
+ }
+ overflowCounter--;
}
+ }
- // Track which hunk contains the viewport center
- if (absoluteLineTop + lineHeight > viewportCenter) {
- centerHunk ??= currentHunk;
- }
+ const lineHeight = this.getLineHeight(lineIndex, false);
- // Start overflow when we are out of the viewport at a hunk boundary
- if (
- overflowCounter == null &&
- absoluteLineTop >= bottom &&
- isAtHunkBoundary
- ) {
- overflowCounter = overflowHunks;
- }
+ // Track visible region
+ if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
+ firstVisibleHunk ??= currentHunk;
+ }
- currentLine++;
- absoluteLineTop += lineHeight;
+ // Track which hunk contains the viewport center
+ if (absoluteLineTop + lineHeight > viewportCenter) {
+ centerHunk ??= currentHunk;
+ }
- return false;
- },
- });
+ // Start overflow when we are out of the viewport at a hunk boundary
+ if (
+ overflowCounter == null &&
+ absoluteLineTop >= bottom &&
+ isAtHunkBoundary
+ ) {
+ overflowCounter = overflowHunks;
+ }
+
+ currentLine++;
+ absoluteLineTop += lineHeight;
+ }
// No visible lines found
if (firstVisibleHunk == null) {
@@ -909,18 +937,3 @@ export class VirtualizedFile<
};
}
}
-
-function getLastVisibleLineIndex(lines: string[]): number {
- const lastLine = lines.at(-1);
- if (
- lastLine == null ||
- lastLine === '' ||
- lastLine === '\n' ||
- lastLine === '\r\n' ||
- lastLine === '\r'
- ) {
- return lines.length - 2;
- }
-
- return lines.length - 1;
-}
diff --git a/packages/diffs/src/components/Virtualizer.ts b/packages/diffs/src/components/Virtualizer.ts
index f38d988af..2c59719f4 100644
--- a/packages/diffs/src/components/Virtualizer.ts
+++ b/packages/diffs/src/components/Virtualizer.ts
@@ -638,7 +638,7 @@ export class Virtualizer {
return this.height;
}
- private markDOMDirty() {
+ markDOMDirty(): void {
this.scrollDirty = true;
this.scrollHeightDirty = true;
this.heightDirty = true;
diff --git a/packages/diffs/src/editor/command.ts b/packages/diffs/src/editor/command.ts
new file mode 100644
index 000000000..8ed4d5d66
--- /dev/null
+++ b/packages/diffs/src/editor/command.ts
@@ -0,0 +1,65 @@
+import { isMacLike, isPrimaryModifier } from './platform';
+
+export type EditorCommand =
+ | 'indent'
+ | 'outdent'
+ | 'undo'
+ | 'redo'
+ | 'selectAll'
+ | 'findNextMatch'
+ | 'openSearchPanel'
+ | 'moveCursorToDocStart'
+ | 'moveCursorToDocEnd'
+ | 'expandSelectionDocStart'
+ | 'expandSelectionDocEnd';
+
+const SHORTCUTS: Partial> = {
+ a: 'selectAll',
+ d: 'findNextMatch',
+ f: 'openSearchPanel',
+};
+
+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')) {
+ if (shiftKey) {
+ return 'expandSelectionDocStart';
+ }
+ return 'moveCursorToDocStart';
+ }
+
+ if (normalizedKey === 'End' || (isMac && normalizedKey === 'ArrowDown')) {
+ if (shiftKey) {
+ return 'expandSelectionDocEnd';
+ }
+ return 'moveCursorToDocEnd';
+ }
+
+ return SHORTCUTS[normalizedKey];
+}
diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts
new file mode 100644
index 000000000..29fb6d1a3
--- /dev/null
+++ b/packages/diffs/src/editor/css.ts
@@ -0,0 +1,196 @@
+const DEBUG_SELECTION = false;
+
+export const editorCSS: 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; }
+ }
+ :host, /* for jump anchor */
+ [data-code], /* for editor overlay */
+ [data-content] /* for wrap line */
+ {
+ position: relative;
+ }
+ [data-content] {
+ background-color: transparent;
+ caret-color: var(--diffs-bg-caret);
+ outline: none;
+ }
+
+ @media (width >= 480px) {
+ [data-content] {
+ caret-color: ${DEBUG_SELECTION ? 'red' : 'transparent'};
+ }
+ [data-quick-edit] {
+ caret-color: currentColor;
+ }
+ }
+ [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: ${DEBUG_SELECTION ? 'transparent' : 'var(--diffs-bg-caret)'};
+ animation: blinking 1.2s infinite;
+ animation-delay: 0.8s;
+ visibility: hidden;
+ }
+ [data-selection-range] {
+ height: 1lh;
+ z-index: -10;
+ background-color: var(--diffs-line-bg);
+ }
+ [data-editor-overlay] {
+ display: contents;
+ }
+
+ @media (width >= 480px) {
+ [data-content]:focus ~ [data-editor-overlay] [data-caret] {
+ visibility: visible;
+ }
+ }
+
+ [data-quick-edit-icon] {
+ position: absolute;
+ top: 0;
+ left: calc(-1lh + 2px);
+ z-index: 10;
+ width: 1lh;
+ height: 1lh;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: color-mix(in lab, var(--diffs-fg) 40%, var(--diffs-bg));
+ transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out;
+ cursor: pointer;
+ visibility: hidden;
+ }
+ [data-quick-edit-icon][data-visible='true'] {
+ visibility: visible;
+ }
+ [data-quick-edit-icon]:hover {
+ background-color: color-mix(in lab, var(--diffs-fg) 8%, var(--diffs-bg));
+ color: var(--diffs-fg);
+ }
+ [data-quick-edit] {
+ padding-inline-end: 1ch;
+ }
+
+ [data-search-panel] {
+ --editor-panel-shadow-color: light-dark(rgb(0 0 0 / 0.075), rgb(0 0 0 / 0.15));
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ min-width: 300px;
+ max-width: 100%;
+ margin-inline: 8px;
+ z-index: 100;
+ display: flex;
+ gap: 4px;
+ margin-inline: 1ch;
+ margin-bottom: 4px;
+ background-clip: padding-box;
+ background-color: color-mix(in lab, color-mix(in lab, var(--diffs-fg) 4%, var(--diffs-bg)), transparent 40%);
+ border: 1px solid color-mix(in lab, var(--diffs-fg) 15%, var(--diffs-bg));
+ padding: 6px 6px 6px 10px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px var(--editor-panel-shadow-color), 0 4px 8px var(--editor-panel-shadow-color);
+ backdrop-filter: blur(8px);
+ }
+ [data-search-panel-row] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 1px;
+ width: 100%;
+ }
+ [data-search-panel-row] input {
+ field-sizing: content;
+ min-width: 120px;
+ font-size: 14px;
+ line-height: 24px;
+ padding-inline: 4px;
+ border: none;
+ outline: none;
+ background-color: transparent;
+ color: var(--diffs-fg);
+ }
+ [data-search-panel-row] input::selection {
+ background-color: color-mix(in lab, var(--diffs-fg) 8%, var(--diffs-bg));
+ }
+ [data-search-panel-row] [data-matches] {
+ min-width: 10ch;
+ font-size: 12px;
+ line-height: 20px;
+ padding-inline: 4px;
+ margin-right: auto;
+ color: color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg));
+ }
+
+ [data-search-panel-row] [data-divider] {
+ width: 1px;
+ height: 12px;
+ margin-inline: 8px;
+ background-color: color-mix(in lab, var(--diffs-fg) 12%, var(--diffs-bg));
+ flex-shrink: 0;
+ }
+ [data-search-panel-row] svg {
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+ }
+ [data-search-panel-row] [data-icon] {
+ flex-shrink: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ color: color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg));
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out;
+ }
+ [data-search-panel-row] [data-icon="search"] {
+ width: 16px;
+ margin-right: 2px;
+ }
+ [data-search-panel-row] [data-icon][data-disabled='true'] {
+ opacity: 0.25;
+ pointer-events: none;
+ }
+ [data-search-panel-row] [data-icon]:not([data-icon='search']):hover {
+ background-color: color-mix(in lab, var(--diffs-fg) 6%, var(--diffs-bg));
+ color: var(--diffs-fg);
+ }
+ [data-search-panel-row] [data-icon][data-active='true'] {
+ background-color: color-mix(in lab, var(--diffs-fg) 10%, var(--diffs-bg));
+ color: var(--diffs-fg);
+ }
+`;
+
+// Safari doesn't support `::selection` for slot elements in ShadowDOM,
+// Add a global style to disable selection for slot elements
+export const editorGlobalCSS = /* CSS */ `
+ [data-annotation-slot] {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+`;
diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts
new file mode 100644
index 000000000..f409eee32
--- /dev/null
+++ b/packages/diffs/src/editor/editStack.ts
@@ -0,0 +1,348 @@
+import type { DiffLineAnnotation } from '../types';
+import type { EditorSelection } from './selection';
+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?: DiffLineAnnotation[];
+ /** Line annotations after the transaction. */
+ lineAnnotationsAfter?: DiffLineAnnotation[];
+}
+
+/** 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. */
+ setLastUndoLineAnnotations(
+ lineAnnotationsBefore: DiffLineAnnotation[],
+ lineAnnotationsAfter: DiffLineAnnotation[]
+ ): void {
+ const lastEntry = this.#undoStack[this.#undoStack.length - 1];
+ if (lastEntry !== undefined) {
+ lastEntry.lineAnnotationsBefore = lineAnnotationsBefore.slice();
+ lastEntry.lineAnnotationsAfter = lineAnnotationsAfter.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?: DiffLineAnnotation[],
+ lineAnnotationsAfter?: DiffLineAnnotation[]
+): 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/editor.ts b/packages/diffs/src/editor/editor.ts
new file mode 100644
index 000000000..5ef4af063
--- /dev/null
+++ b/packages/diffs/src/editor/editor.ts
@@ -0,0 +1,2361 @@
+import {
+ type Position,
+ type ResolvedTextEdit,
+ TextDocument,
+ type TextDocumentChange,
+ type TextEdit,
+} from '../editor/textDocument';
+import type {
+ DiffLineAnnotation,
+ DiffsEditableComponent,
+ DiffsEditor,
+ DiffsEditorSelection,
+ DiffsHighlighter,
+ FileContents,
+ HighlightedToken,
+ RenderRange,
+} from '../types';
+import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName';
+import {
+ type EditorCommand,
+ resolveEditorCommandFromKeyboardEvent,
+} from './command';
+import { editorCSS, editorGlobalCSS } from './css';
+import { applyDocumentChangeToLineAnnotations } from './lineAnnotations';
+import { isPrimaryModifier, isSafari } from './platform';
+import { type QuickEditContext, QuickEditWidget } from './quickEdit';
+import { SearchPanelWidget } from './searchPanel';
+import type { EditorSelection } from './selection';
+import {
+ applyDeleteHardLineForwardToSelections,
+ applyTextChangeToSelections,
+ applyTextReplaceToSelections,
+ applyTransposeToSelections,
+ comparePosition,
+ convertSelection,
+ createSelectionFrom,
+ createSelectionFromAnchorAndFocusOffsets,
+ DirectionBackward,
+ DirectionForward,
+ DirectionNone,
+ expandCollapsedSelectionToWord,
+ extendSelection,
+ extendSelections,
+ findNexMatch,
+ getDocumentBoundarySelection,
+ getDocumentFullSelection,
+ getSelectionAnchor,
+ getSelectionText,
+ isCollapsedSelection,
+ mapCursorMove,
+ mapSelectionShift,
+ resolveIndentEdits,
+ selectionIntersects,
+} from './selection';
+import {
+ getExpandedAsciiTextColumns,
+ getUnicodeMeasurementOffsets,
+ Metrics,
+ snapTextOffsetToUnicodeBoundary,
+} from './textMeasure';
+import { EditorTokenizer, renderLineTokens } from './tokenzier';
+import { addEventListener, debounce, extend, h, round } from './utils';
+
+function clampDomOffset(node: Node, offset: number): number {
+ if (node.nodeType === 3) {
+ const length = (node as Text).textContent?.length ?? 0;
+ return Math.max(0, Math.min(offset, length));
+ }
+ if (node.nodeType === 1) {
+ return Math.max(0, Math.min(offset, node.childNodes.length));
+ }
+ return 0;
+}
+
+export interface EditorOptions {
+ enabledQuickEdit?: boolean;
+ renderQuickEdit?: (context: QuickEditContext) => HTMLElement;
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: DiffLineAnnotation[]
+ ) => void;
+}
+
+export class Editor implements DiffsEditor {
+ #options: EditorOptions;
+ #editMode: 'simple' | 'advanced' = 'simple';
+ #wrap = false;
+ #metrics = new Metrics();
+ #tokenizer?: EditorTokenizer;
+
+ // event handlers
+ #editorEventDisposes?: (() => void)[];
+ #globalEventDisposes?: (() => void)[];
+ #mouseUpDisposes?: (() => void)[];
+ #removeEditorFromComponent?: () => void;
+
+ // file
+ #component?: DiffsEditableComponent;
+ #fileContents?: FileContents;
+ #lineAnnotations?: DiffLineAnnotation[];
+ #textDocument?: TextDocument;
+ #renderRange?: RenderRange;
+
+ // cache
+ #codePaddingTop = 0;
+ #gutterWidthCache?: number;
+ #contentWidthCache?: number;
+ #lineYCache = new Map();
+ #wrapLineOffsetsCache = new Map();
+ #lastCharX?: [line: number, character: number, x: number, wrapLine: number];
+
+ // dom
+ #globalStyleElement?: HTMLStyleElement;
+ #styleElement?: HTMLStyleElement;
+ #componentContainer?: HTMLElement;
+ #contentElement?: HTMLElement;
+ #overlayElement?: HTMLElement;
+ #primaryCaretElement?: HTMLElement;
+ #selectionElements?: Map;
+ #quickEdit?: QuickEditWidget;
+ #searchPanel?: SearchPanelWidget;
+ #resizeObserver?: ResizeObserver;
+
+ // state
+ #shouldIgnoreSelectionChange = false;
+ #isGutterMouseDown = false;
+ #isContentMouseDown = false;
+ #shiftKeyPressed = false;
+ #selectionStart: EditorSelection | undefined;
+ #reservedSelections?: EditorSelection[];
+ #selections?: EditorSelection[];
+ #initSelections?: DiffsEditorSelection[];
+ #scrollingToLine?: number;
+ #scrollingToLineChar?: number;
+ #retainSearchPanelFocus = false;
+
+ #emitChange = debounce(
+ (
+ fileContents: FileContents,
+ lineAnnotations?: DiffLineAnnotation[]
+ ) => {
+ this.#options.onChange?.(fileContents, lineAnnotations);
+ },
+ 500
+ );
+
+ #onDeferTokenize = (
+ lines: Map>,
+ themeType: 'light' | 'dark'
+ ) => {
+ this.#component?.emitLineChange?.(lines, themeType);
+ // update the view if the render range is updated by scrolling
+ // and the deferred tokenized lines inside the render range
+ if (
+ this.#renderRange !== undefined &&
+ this.#renderRange.totalLines !== Infinity
+ ) {
+ const { startingLine, totalLines } = this.#renderRange;
+ const endLine = Math.min(
+ startingLine + totalLines,
+ this.#textDocument?.lineCount ?? 0
+ );
+ for (const [line, tokens] of lines) {
+ if (line >= startingLine && line < endLine) {
+ const lineElement = this.#getLineElement(line);
+ if (lineElement !== undefined) {
+ lineElement.replaceChildren(...renderLineTokens(tokens, themeType));
+ }
+ }
+ }
+ }
+ };
+
+ constructor(options: EditorOptions = {}) {
+ this.#options = options;
+ }
+
+ edit(component: DiffsEditableComponent): () => void {
+ this.#component = component;
+ this.#initialize();
+ if (
+ component.options.useTokenTransformer !== true ||
+ Reflect.get(component.options, 'enableGutterUtility') === true ||
+ Reflect.get(component.options, 'enableLineSelection') === true
+ ) {
+ // Normalize the component options:
+ // 1. Ensure the component uses token transformer that adds `data-char` attribute to the tokens
+ // 2. Disable gutter utility to avoid conflicts with the editor
+ const options = {
+ ...component.options,
+ useTokenTransformer: true,
+ enableGutterUtility: false,
+ enableLineSelection: false,
+ };
+ component.setOptions(options);
+ component.rerender();
+ }
+ this.#removeEditorFromComponent = component.setupEditor(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);
+ this.#scrollToPrimaryCaret();
+ } else {
+ this.#initSelections = selections;
+ }
+ }
+
+ focus(options?: FocusOptions): void {
+ const preventScroll = options?.preventScroll ?? false;
+ const primarySelection = this.#selections?.at(-1);
+ if (primarySelection !== undefined) {
+ const pos =
+ primarySelection.direction === DirectionBackward
+ ? primarySelection.end
+ : primarySelection.start;
+ this.#focus(pos, preventScroll);
+ } else {
+ this.#focus(undefined, preventScroll);
+ }
+ }
+
+ cleanUp(): void {
+ this.#tokenizer?.cleanUp();
+ this.#tokenizer = undefined;
+
+ this.#globalEventDisposes?.forEach((dispose) => dispose());
+ this.#globalEventDisposes = undefined;
+ this.#editorEventDisposes?.forEach((dispose) => dispose());
+ this.#editorEventDisposes = undefined;
+
+ this.#removeEditorFromComponent?.();
+ this.#removeEditorFromComponent = undefined;
+ this.#component?.setSelectedLines(null);
+ this.#component = undefined;
+ this.#fileContents = undefined;
+ this.#lineAnnotations = undefined;
+ this.#textDocument = undefined;
+ this.#renderRange = undefined;
+
+ this.#gutterWidthCache = undefined;
+ this.#contentWidthCache = undefined;
+ this.#lineYCache.clear();
+ this.#wrapLineOffsetsCache.clear();
+ this.#lastCharX = undefined;
+
+ this.#globalStyleElement?.remove();
+ this.#globalStyleElement = undefined;
+ this.#styleElement?.remove();
+ this.#styleElement = undefined;
+ this.#componentContainer = undefined;
+ this.#contentElement?.removeAttribute('contentEditable');
+ this.#contentElement = undefined;
+ this.#overlayElement?.remove();
+ this.#overlayElement = undefined;
+ this.#primaryCaretElement?.remove();
+ this.#primaryCaretElement = undefined;
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#selectionElements = undefined;
+ this.#searchPanel?.cleanup();
+ this.#searchPanel = undefined;
+ this.#quickEdit?.cleanup();
+ this.#quickEdit = undefined;
+ this.#resizeObserver?.disconnect();
+ this.#resizeObserver = undefined;
+
+ this.#shouldIgnoreSelectionChange = false;
+ this.#selectionStart = undefined;
+ this.#selections = undefined;
+ this.#reservedSelections = undefined;
+ }
+
+ emitRender(
+ highlighter: DiffsHighlighter,
+ fileContainer: HTMLElement,
+ fileContents: FileContents,
+ lineAnnotations: DiffLineAnnotation[] | undefined,
+ renderRange: RenderRange | undefined,
+ editMode?: 'simple' | 'advanced'
+ ): void {
+ const shadowRoot = fileContainer.shadowRoot;
+ if (shadowRoot == null) {
+ console.error('[editor] Could not find the shadow root.');
+ return;
+ }
+
+ let codeElement: HTMLElement | undefined;
+ for (const el of shadowRoot.querySelectorAll('[data-code]')) {
+ if (el.dataset.deletions === undefined) {
+ codeElement = el;
+ break;
+ }
+ }
+ if (codeElement === undefined) {
+ return;
+ }
+ const contentEl = codeElement.children[1] as HTMLElement | undefined;
+ if (contentEl === undefined) {
+ return;
+ }
+
+ this.#editMode = editMode ?? 'simple';
+ this.#wrap = this.#component?.options.overflow === 'wrap';
+
+ if (editMode === 'advanced' || (lineAnnotations?.length ?? 0) > 0) {
+ let startingLine: number | undefined;
+ let endLine: number | undefined;
+ for (const child of contentEl.children) {
+ const el = child as HTMLElement;
+ const line = el.dataset.line;
+ const lineType = el.dataset.lineType;
+ if (line !== undefined) {
+ const lineIndex = Number(line) - 1;
+ startingLine ??= lineIndex;
+ endLine = lineIndex;
+ }
+ if (lineType !== 'context' && lineType !== 'change-addition') {
+ el.contentEditable = 'false';
+ }
+ }
+ if (endLine !== undefined && renderRange !== undefined) {
+ const { startingLine, totalLines } = renderRange;
+ endLine = Math.max(endLine, startingLine + totalLines);
+ }
+ // normalize the render range
+ if (startingLine !== undefined && endLine !== undefined) {
+ renderRange = {
+ startingLine: startingLine,
+ totalLines: endLine - startingLine,
+ bufferBefore: 0,
+ bufferAfter: 0,
+ };
+ }
+ }
+
+ // inject editor css to the file container
+ if (this.#componentContainer !== fileContainer) {
+ this.#componentContainer = fileContainer;
+ this.#codePaddingTop = Number(
+ getComputedStyle(codeElement).paddingTop.slice(0, -2)
+ );
+ if (this.#globalStyleElement !== undefined) {
+ fileContainer.appendChild(this.#globalStyleElement);
+ }
+ if (this.#styleElement !== undefined) {
+ shadowRoot.appendChild(this.#styleElement);
+ }
+ }
+
+ if (
+ this.#textDocument === undefined ||
+ this.#fileContents === undefined ||
+ this.#fileContents.name !== fileContents.name
+ ) {
+ const textDocument = new TextDocument(
+ fileContents.name,
+ fileContents.contents,
+ fileContents.lang ?? getFiletypeFromFileName(fileContents.name)
+ );
+ this.#fileContents = fileContents;
+ this.#textDocument = textDocument;
+ this.#tokenizer?.cleanUp();
+ this.#tokenizer = new EditorTokenizer({
+ highlighter,
+ textDocument,
+ codeOptions: this.#component?.options ?? {},
+ onDeferTokenize: this.#onDeferTokenize,
+ });
+ this.#shouldIgnoreSelectionChange = false;
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#component?.setSelectedLines(null);
+ this.#selectionElements = undefined;
+ this.#selections = undefined;
+ this.#scrollingToLine = undefined;
+ this.#reservedSelections = undefined;
+ this.#searchPanel?.cleanup();
+ this.#searchPanel = undefined;
+ this.#quickEdit?.cleanup();
+ this.#quickEdit = undefined;
+ }
+
+ if (this.#contentElement !== contentEl) {
+ const guttterEl = contentEl.previousElementSibling as HTMLElement | null;
+ const targetIsContentElement = (e: Event) => {
+ const target = e.composedPath()[0] as HTMLElement;
+ return target === contentEl || contentEl.contains(target);
+ };
+
+ this.#metrics.init(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.#editorEventDisposes?.forEach((dispose) => dispose());
+ this.#editorEventDisposes = [
+ addEventListener(
+ contentEl,
+ 'pointerdown',
+ (e) => {
+ if (e.pointerType !== 'mouse') {
+ return;
+ }
+
+ // this is a workaround for the selection rendering glitch
+ // happens when selecting content in shadow DOM on Safari
+ if (
+ isSafari() &&
+ this.#lineAnnotations !== undefined &&
+ this.#lineAnnotations.length > 0
+ ) {
+ this.#mouseUpDisposes = [
+ ...contentEl.querySelectorAll(
+ '[data-line-annotation]'
+ ),
+ ]
+ .map((el) => [
+ addEventListener(el, 'mouseenter', () => {
+ this.#shouldIgnoreSelectionChange = true;
+ }),
+ addEventListener(el, 'mouseleave', () => {
+ this.#shouldIgnoreSelectionChange = false;
+ }),
+ ])
+ .flat();
+ }
+
+ this.#isContentMouseDown = true;
+ this.#selectionStart = undefined;
+ if (e.button === 0 && isPrimaryModifier(e)) {
+ this.#reservedSelections = this.#selections?.map((selection) => ({
+ ...selection,
+ }));
+ }
+ if (e.shiftKey) {
+ const primarySelection = this.#selections?.at(-1);
+ if (primarySelection !== undefined) {
+ const pos =
+ primarySelection.direction === DirectionBackward
+ ? primarySelection.end
+ : primarySelection.start;
+ // fix the window selection for shift mode
+ this.#updateWindowSelection({
+ start: pos,
+ end: pos,
+ direction: DirectionNone,
+ });
+ }
+ this.#shiftKeyPressed = true;
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(contentEl, 'keydown', (e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ this.#searchPanel?.cleanup();
+ this.#searchPanel = undefined;
+ this.#retainSearchPanelFocus = false;
+ this.#quickEdit?.cleanup();
+ this.#quickEdit = undefined;
+ return;
+ }
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ const command = resolveEditorCommandFromKeyboardEvent(e);
+ if (command !== undefined) {
+ e.preventDefault();
+ this.#runCommand(command);
+ }
+ }),
+
+ addEventListener(contentEl, 'copy', (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ e.preventDefault();
+ e.clipboardData?.setData('text', this.#getSelectionText());
+ }),
+
+ addEventListener(contentEl, 'cut', (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ e.preventDefault();
+ e.clipboardData?.setData('text', this.#getSelectionText());
+ this.#replaceSelectionText('');
+ }),
+
+ addEventListener(contentEl, 'paste', (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ e.preventDefault();
+ const text = e.clipboardData?.getData('text');
+ if (text !== undefined) {
+ // TODO(@ije): Add support of multiple selections copy&paste
+ // TODO(@ije): normalize the pasted text with textDocument.EOF
+ this.#replaceSelectionText(text);
+ }
+ }),
+
+ addEventListener(contentEl, 'beforeinput', (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.#handleInput(e.inputType, e.data);
+ }),
+
+ addEventListener(
+ contentEl,
+ 'compositionstart',
+ (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ this.#shouldIgnoreSelectionChange = true;
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ contentEl,
+ 'compositionend',
+ (e) => {
+ if (!targetIsContentElement(e)) {
+ return;
+ }
+ this.#shouldIgnoreSelectionChange = false;
+ this.#handleInput('insertText', e.data);
+ },
+ { passive: true }
+ ),
+ ];
+ if (guttterEl !== null && guttterEl.dataset.gutter !== undefined) {
+ this.#editorEventDisposes.push(
+ addEventListener(
+ guttterEl,
+ 'pointerdown',
+ (e) => {
+ const target = e.composedPath()[0] as HTMLElement;
+ const textDocument = this.#textDocument;
+ if (
+ target.dataset.lineNumberContent !== undefined &&
+ textDocument !== undefined
+ ) {
+ const lineNumber = target.textContent.trim();
+ const lineIndex = Number(lineNumber) - 1;
+ const selection: EditorSelection = {
+ start: { line: lineIndex, character: 0 },
+ end: {
+ line: lineIndex,
+ character: textDocument.getLineText(lineIndex).length,
+ },
+ direction: DirectionForward,
+ };
+ this.#isGutterMouseDown = true;
+ this.#selectionStart = selection;
+ this.#updateSelections([selection]);
+ }
+ this.#mouseUpDisposes = [
+ addEventListener(
+ document,
+ 'mousemove',
+ (e) => {
+ let lineNumber: number | undefined;
+ const target = e.composedPath()[0] as HTMLElement;
+ const dataset = target.dataset;
+ if (dataset.lineNumberContent !== undefined) {
+ lineNumber = Number(target.textContent.trim());
+ } else if (dataset.columnNumber !== undefined) {
+ lineNumber = Number(dataset.columnNumber);
+ } else if (dataset.line !== undefined) {
+ lineNumber = Number(dataset.line);
+ } else if (dataset.char !== undefined) {
+ const lineElement = target.closest('[data-line]');
+ if (lineElement instanceof HTMLElement) {
+ lineNumber = Number(lineElement.dataset.line);
+ }
+ }
+ if (
+ this.#isGutterMouseDown &&
+ this.#textDocument !== undefined &&
+ lineNumber !== undefined
+ ) {
+ const lineIndex = Number(lineNumber) - 1;
+ let selection: EditorSelection = {
+ start: { line: lineIndex, character: 0 },
+ end: {
+ line: lineIndex,
+ character:
+ this.#textDocument.getLineText(lineIndex).length,
+ },
+ direction: DirectionForward,
+ };
+ if (this.#selectionStart !== undefined) {
+ selection = createSelectionFrom(
+ this.#selectionStart,
+ selection
+ );
+ } else {
+ this.#selectionStart = selection;
+ }
+
+ this.#updateSelections([selection]);
+ }
+ },
+ { passive: true }
+ ),
+ ];
+ },
+ { passive: true }
+ )
+ );
+ }
+ this.#resizeObserver?.disconnect();
+ this.#resizeObserver = new ResizeObserver(() => {
+ requestAnimationFrame(() => {
+ this.#handleLayoutResize();
+ });
+ });
+ this.#resizeObserver.observe(contentEl);
+ this.#resizeObserver.observe(contentEl.parentElement!);
+ }
+
+ this.#lineYCache.clear();
+ this.#wrapLineOffsetsCache.clear();
+ this.#lastCharX = undefined;
+
+ this.#lineAnnotations = lineAnnotations;
+ this.#renderRange = renderRange;
+ this.#tokenizer?.prebuildStateStackMap(renderRange);
+
+ if (this.#initSelections !== undefined) {
+ this.setSelections(this.#initSelections);
+ this.#scrollToPrimaryCaret();
+ this.#initSelections = undefined;
+ } else if (this.#selections !== undefined && this.#selections.length > 0) {
+ // when re-rendering triggered by viewport scroll,
+ // re-render the existing selections
+ this.#updateSelections(this.#selections);
+ }
+
+ if (renderRange !== undefined) {
+ const { startingLine, totalLines } = renderRange;
+ console.debug(
+ '[diffs/editor] render file:',
+ fileContents.name,
+ 'RenderRange:',
+ startingLine + '-' + (startingLine + totalLines),
+ 'of',
+ this.#textDocument.lineCount,
+ 'lines'
+ );
+ }
+
+ if (this.#scrollingToLine !== undefined) {
+ this.#scrollToLine(this.#scrollingToLine, this.#scrollingToLineChar);
+ this.#scrollingToLine = undefined;
+ this.#scrollingToLineChar = undefined;
+ } else if (this.#selections !== undefined && this.#selections.length > 0) {
+ this.focus({ preventScroll: true });
+ }
+
+ if (this.#retainSearchPanelFocus) {
+ this.#retainSearchPanelFocus = false;
+ requestAnimationFrame(() => {
+ this.#searchPanel?.focus();
+ });
+ }
+
+ if (
+ this.#quickEdit !== undefined &&
+ this.#isLineVisible(this.#quickEdit.line) &&
+ this.#contentElement !== undefined
+ ) {
+ this.#quickEdit.render(this.#contentElement);
+ }
+ }
+
+ #initialize(): void {
+ this.#styleElement = h('style', {
+ dataset: 'editorCss',
+ textContent: editorCSS,
+ });
+
+ this.#globalStyleElement = h('style', {
+ dataset: 'editorGlobalCss',
+ textContent: editorGlobalCSS,
+ });
+
+ this.#overlayElement = h('div', {
+ dataset: 'editorOverlay',
+ });
+
+ this.#globalEventDisposes = [
+ addEventListener(
+ document,
+ 'selectionchange',
+ () => {
+ const shadowRoot = this.#componentContainer?.shadowRoot;
+ if (this.#shouldIgnoreSelectionChange || shadowRoot == null) {
+ 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;
+ }
+
+ // extend selection by shift + click
+ if (
+ this.#isContentMouseDown &&
+ this.#shiftKeyPressed &&
+ this.#selections !== undefined &&
+ this.#selections.length > 0
+ ) {
+ const primarySelection = this.#selections.at(-1)!;
+ this.#updateSelections([
+ extendSelection(primarySelection, selection),
+ ]);
+ return;
+ }
+
+ if (this.#isContentMouseDown) {
+ 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.#isContentMouseDown ||
+ this.#selections === undefined ||
+ this.#selections.length === 0 ||
+ this.#textDocument === undefined
+ ) {
+ this.#updateSelections([selection]);
+ }
+ // The selection change is triggered by the keyboard
+ // For example, moving the cursor by arrow keys.
+ else 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
+ );
+ this.#updateSelections(newSelections);
+ }
+ }
+ },
+ { passive: true }
+ ),
+
+ addEventListener(
+ document,
+ 'pointerup',
+ (e) => {
+ if (e.pointerType !== 'mouse') {
+ return;
+ }
+
+ this.#mouseUpDisposes?.forEach((dispose) => dispose());
+ this.#mouseUpDisposes = undefined;
+
+ if (this.#isGutterMouseDown) {
+ this.#isGutterMouseDown = false;
+ this.#focus();
+ }
+ this.#isContentMouseDown = false;
+ this.#shiftKeyPressed = false;
+ this.#selectionStart = undefined;
+ this.#reservedSelections = undefined;
+ this.#selectionElements?.forEach((el, key) => {
+ if (key.startsWith('quickEditIcon-')) {
+ el.dataset.visible = 'true';
+ }
+ });
+ },
+ { 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 }
+ ),
+ ];
+ }
+
+ // TODO(@ije): add command registry
+ #runCommand(command: EditorCommand) {
+ const textDocument = this.#textDocument;
+ if (textDocument === undefined) {
+ return;
+ }
+
+ switch (command) {
+ case 'openSearchPanel':
+ this.#renderSearchPanel();
+ 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);
+ } else {
+ const nextMatch = findNexMatch(textDocument, selections);
+ if (nextMatch !== undefined) {
+ this.#updateSelections(nextMatch);
+ this.#scrollToPrimaryCaret();
+ }
+ }
+ 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.#metrics.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.#metrics.tabSize)
+ );
+ }
+ }
+ const change = textDocument.applyEdits(
+ edits,
+ true,
+ this.#selections,
+ nextSelections
+ );
+ if (change !== undefined) {
+ this.#applyChange(change, nextSelections);
+ }
+ }
+ break;
+
+ case 'selectAll':
+ this.#updateSelections([getDocumentFullSelection(textDocument)]);
+ break;
+
+ case 'moveCursorToDocStart':
+ case 'moveCursorToDocEnd':
+ {
+ const atEnd = command === 'moveCursorToDocEnd';
+ this.#updateSelections([
+ getDocumentBoundarySelection(textDocument, atEnd),
+ ]);
+ this.#scrollToPrimaryCaret();
+ }
+ break;
+
+ case 'expandSelectionDocStart':
+ case 'expandSelectionDocEnd':
+ {
+ const atEnd = command === 'expandSelectionDocEnd';
+ const selections = this.#selections;
+ if (selections !== undefined) {
+ this.#updateSelections(
+ extendSelections(
+ selections,
+ getDocumentBoundarySelection(textDocument, atEnd)
+ )
+ );
+ this.#scrollToPrimaryCaret();
+ }
+ }
+ 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 lineAnnotations = this.#lineAnnotations?.length ?? 0;
+ const prevGutterWidth = this.#gutterWidthCache;
+ const prevContentWidth = this.#contentWidthCache;
+ this.#gutterWidthCache = undefined;
+ this.#contentWidthCache = undefined;
+ const gutterWidthChanged = this.#getGutterWidth() !== prevGutterWidth;
+ const contentWidthChanged = this.#getContentWidth() !== prevContentWidth;
+ if (!gutterWidthChanged && !contentWidthChanged) {
+ return;
+ }
+
+ this.#lastCharX = undefined;
+ if (contentWidthChanged && (this.#wrap || lineAnnotations > 0)) {
+ this.#lineYCache.clear();
+ this.#wrapLineOffsetsCache.clear();
+ }
+ if (this.#selections !== undefined) {
+ this.#updateSelections(this.#selections);
+ }
+ }
+
+ #rerender(
+ change: TextDocumentChange,
+ newLineAnnotations?: DiffLineAnnotation[],
+ renderRange = this.#renderRange,
+ shouldUpdateBuffer?: boolean
+ ) {
+ const tokenizer = this.#tokenizer;
+ const component = this.#component;
+ const fileContents = this.#fileContents;
+ const textDocument = this.#textDocument;
+ const contentEl = this.#contentElement;
+ const gutterEl = this.#contentElement?.previousElementSibling ?? undefined;
+ if (
+ tokenizer === undefined ||
+ component === undefined ||
+ fileContents === undefined ||
+ textDocument === undefined ||
+ contentEl === undefined ||
+ gutterEl === undefined ||
+ !(gutterEl instanceof HTMLElement) ||
+ gutterEl.dataset.gutter === undefined
+ ) {
+ return;
+ }
+
+ // cancel existing background tokenzier task
+ tokenizer.stopBackgroundTokenize();
+
+ const t = performance.now();
+ const isAdvancedMode = this.#editMode === 'advanced';
+ const dirtyLines = tokenizer.tokenize(change, renderRange);
+ const t2 = performance.now();
+
+ if (dirtyLines.size > 0) {
+ const children = contentEl.children;
+ const dirtyLineIndexes = new Set(dirtyLines.keys());
+
+ // update line elements that have been changed in the document
+ if (isAdvancedMode) {
+ for (const child of children) {
+ const el = child as HTMLElement;
+ const line = el.dataset.line;
+ if (line !== undefined) {
+ const lineIndex = Number(el.dataset.line) - 1;
+ const tokens = dirtyLines.get(lineIndex);
+ if (tokens !== undefined) {
+ el.replaceChildren(
+ ...renderLineTokens(tokens, tokenizer.themeType)
+ );
+ dirtyLineIndexes.delete(lineIndex);
+ if (dirtyLineIndexes.size === 0) {
+ break;
+ }
+ }
+ }
+ }
+ } else {
+ const startingLine = renderRange?.startingLine ?? 0;
+ for (
+ let i = change.startLine - startingLine;
+ i < children.length;
+ i++
+ ) {
+ const child = children[i] as HTMLElement | undefined;
+ if (child?.dataset.line !== undefined) {
+ const lineIndex = Number(child.dataset.line) - 1;
+ if (dirtyLines.has(lineIndex)) {
+ const tokens = dirtyLines.get(lineIndex)!;
+ child.replaceChildren(
+ ...renderLineTokens(tokens, tokenizer.themeType)
+ );
+ dirtyLineIndexes.delete(lineIndex);
+ if (dirtyLineIndexes.size === 0) {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // 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);
+ h(
+ 'div',
+ {
+ dataset: {
+ line: lineNumber,
+ lineType: 'context',
+ lineIndex: lineIndex.toString(),
+ },
+ // oxlint-disable-next-line react/no-children-prop
+ children: renderLineTokens(tokens, tokenizer.themeType),
+ },
+ contentEl
+ );
+ h(
+ 'div',
+ {
+ dataset: {
+ lineType: 'context',
+ columnNumber: lineNumber,
+ lineIndex: lineIndex.toString(),
+ },
+ // oxlint-disable-next-line react/no-children-prop
+ children: [
+ h('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();
+ }
+ }
+ }
+ }
+
+ // fix grid layout
+ if (change.lineDelta !== 0) {
+ gutterEl.style.gridRow = 'span ' + gutterEl.children.length;
+ contentEl.style.gridRow = 'span ' + contentEl.children.length;
+ }
+
+ component.emitLineChange?.(dirtyLines, tokenizer.themeType);
+ if (change.lineDelta !== 0 || isAdvancedMode) {
+ component.emitLayoutChange(
+ textDocument,
+ newLineAnnotations,
+ shouldUpdateBuffer
+ );
+ }
+
+ console.debug(
+ `[diffs/editor] re-render in: ${round(performance.now() - t2)}ms,`,
+ `tokenize in: ${round(t2 - t)}ms (${dirtyLines.size} dirty lines)`
+ );
+ }
+
+ #handleInput(inputType: string, data: string | null) {
+ switch (inputType) {
+ case 'insertText':
+ this.#replaceSelectionText(data ?? '');
+ break;
+ case 'insertParagraph':
+ // TODO(@ije): use document.EOF instead of '\n'
+ this.#replaceSelectionText('\n');
+ break;
+ case 'deleteContentBackward':
+ this.#deleteSelectionText();
+ break;
+ case 'deleteContentForward':
+ this.#deleteSelectionText(true);
+ break;
+ // TODO(@ije): Safari and Firefox does not support this input type
+ // use command instead
+ case 'deleteHardLineForward':
+ this.#deleteHardLineForward();
+ break;
+ case 'insertTranspose':
+ this.#insertTranspose();
+ break;
+ default:
+ console.warn(`[diffs] Unknown input type: ${inputType}`);
+ break;
+ }
+ }
+
+ #updateSelections(selections: EditorSelection[]) {
+ const primarySelection = selections.at(-1);
+ if (primarySelection === undefined) {
+ return;
+ }
+ this.#selections = selections;
+ this.#primaryCaretElement = undefined;
+ this.#component?.setSelectedLines(null);
+ if (isCollapsedSelection(primarySelection)) {
+ const line = primarySelection.start.line + 1;
+ this.#component?.setSelectedLines({
+ start: line,
+ end: line,
+ });
+ }
+ const fragment = document.createDocumentFragment();
+ const renderCtx = {
+ fragment,
+ elements: new Map(),
+ };
+ for (const selection of selections) {
+ if (!isCollapsedSelection(selection)) {
+ this.#renderSelection(renderCtx, selection);
+ }
+ this.#renderCaret(renderCtx, selection, selection === primarySelection);
+ }
+ if (
+ this.#options.enabledQuickEdit === true &&
+ !isCollapsedSelection(primarySelection)
+ ) {
+ this.#renderQuickEditIcon(renderCtx, primarySelection);
+ }
+ this.#overlayElement?.appendChild(fragment);
+ this.#selectionElements?.forEach((el) => el.remove());
+ this.#selectionElements?.clear();
+ this.#selectionElements = renderCtx.elements;
+ }
+
+ // update window native selection to match the selection
+ #updateWindowSelection(selection: EditorSelection) {
+ const winSelection = window.getSelection();
+ if (winSelection === null) {
+ return;
+ }
+ let { start, end, direction } = selection;
+ 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] = getSelectionAnchor(
+ startLineElement,
+ start.character
+ );
+ let [focusNode, focusOffset] = getSelectionAnchor(
+ endLineElement,
+ end.character
+ );
+ if (direction === DirectionBackward) {
+ [anchorNode, anchorOffset, focusNode, focusOffset] = [
+ focusNode,
+ focusOffset,
+ anchorNode,
+ anchorOffset,
+ ];
+ }
+ try {
+ winSelection.setBaseAndExtent(
+ anchorNode,
+ clampDomOffset(anchorNode, anchorOffset),
+ focusNode,
+ clampDomOffset(focusNode, focusOffset)
+ );
+ } catch (err) {
+ console.error('[diffs/editor] failed to update window selection:', err);
+ }
+ }
+
+ #focus(position?: Position, preventScroll = true) {
+ if (position !== undefined) {
+ this.#shouldIgnoreSelectionChange = true;
+ this.#updateWindowSelection({
+ start: position,
+ end: position,
+ direction: DirectionNone,
+ });
+ requestAnimationFrame(() => {
+ this.#contentElement?.focus({ preventScroll });
+ requestAnimationFrame(() => {
+ this.#shouldIgnoreSelectionChange = false;
+ });
+ });
+ } else {
+ requestAnimationFrame(() => {
+ this.#contentElement?.focus({ preventScroll });
+ });
+ }
+ }
+
+ #scrollToPrimaryCaret() {
+ const primaryCaretElement = this.#primaryCaretElement;
+ const primarySelection = this.#selections?.at(-1);
+ if (primarySelection === undefined) {
+ return;
+ }
+ if (primaryCaretElement !== undefined) {
+ primaryCaretElement.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ this.#focus(
+ primarySelection.direction === DirectionBackward
+ ? primarySelection.end
+ : primarySelection.start
+ );
+ } else {
+ const { start, end, direction } = primarySelection;
+ const pos = direction === DirectionBackward ? start : end;
+ this.#scrollToLine(pos.line, pos.character);
+ }
+ }
+
+ // add scroll margin to the primary caret element to prevent
+ // the caret from being hidden by the gutter
+ #getScrollMargin() {
+ const top = this.#searchPanel !== undefined ? 48 : 0;
+ const start = this.#getGutterWidth() + this.#metrics.ch;
+ const end = this.#metrics.ch;
+ return `${top}px ${end}px 0 ${start}px`;
+ }
+
+ #scrollToLine(line: number, char = 0) {
+ const virtualCaret = h('div', {
+ style: {
+ position: 'absolute',
+ left: '0',
+ width: '2px',
+ height: this.#metrics.lineHeight + 'px',
+ scrollMargin: this.#getScrollMargin(),
+ },
+ });
+ if (this.#getLineElement(line) !== undefined) {
+ const [left, wrapLine] = this.#getCharX(line, char);
+ const lineY = this.#getLineY(line) + wrapLine * this.#metrics.lineHeight;
+ virtualCaret.style.top = lineY + 'px';
+ virtualCaret.style.left = left + 'px';
+ this.#overlayElement?.appendChild(virtualCaret);
+ virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' });
+ this.#focus({ line, character: char });
+ requestAnimationFrame(() => virtualCaret.remove());
+ }
+ // if the line is not rendered yet(virtualized),
+ // scroll to the approximate line position to trigger
+ // the line to be rendered, then recall this function
+ // to ensure the line is scrolled into view
+ else {
+ const lineAnnotations = (this.#lineAnnotations ?? []).filter(
+ (annotation) => annotation.lineNumber < line
+ ).length;
+ const approximateLineY =
+ (lineAnnotations + line) * this.#metrics.lineHeight;
+ virtualCaret.style.top = approximateLineY + 'px';
+ this.#componentContainer?.shadowRoot?.appendChild(virtualCaret);
+ this.#scrollingToLine = line;
+ this.#scrollingToLineChar = char;
+ virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' });
+ requestAnimationFrame(() => virtualCaret.remove());
+ }
+ }
+
+ #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.#metrics.ch; // 1ch, align to diff css: padding-inline: 1ch
+ const contentWidth = this.#getContentWidth();
+ const textWidth =
+ 2 * paddingInline + this.#metrics.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.#metrics.ch; // gutter width + inline padding (1ch)
+ width = ln === end.line ? 0 : this.#metrics.ch;
+ } 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 prefixAsciiColumns = getExpandedAsciiTextColumns(
+ prefixInSegment,
+ this.#metrics.tabSize
+ );
+ segmentLeft =
+ offsetLeft +
+ (prefixAsciiColumns !== -1
+ ? prefixAsciiColumns * this.#metrics.ch
+ : this.#metrics.measureTextWidth(prefixInSegment));
+
+ if (wrapStartChar === wrapEndChar) {
+ segmentWidth = 0;
+ } else {
+ const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar);
+ const selectionAsciiWidth = getExpandedAsciiTextColumns(
+ selectionInSegment,
+ this.#metrics.tabSize
+ );
+ segmentWidth =
+ selectionAsciiWidth !== -1
+ ? selectionAsciiWidth * this.#metrics.ch
+ : this.#metrics.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.#metrics.ch;
+ const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#metrics.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 = h(
+ 'div',
+ {
+ dataset: 'selectionRange',
+ style: { cssText: css },
+ },
+ renderCtx.fragment
+ );
+ }
+
+ renderCtx.elements.set(cacheKey, rangeEl);
+ }
+
+ #renderCaret(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection,
+ isPrimary: boolean
+ ) {
+ 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 + '(' + wrapLine + ')-' + character;
+ if (renderCtx.elements.has(cacheKey)) {
+ return;
+ }
+ const caretEl = h(
+ 'div',
+ {
+ dataset: 'caret',
+ style: {
+ transform: `translateY(${this.#getLineY(line) + wrapLine * this.#metrics.lineHeight}px) translateX(${left - 1}px)`,
+ },
+ },
+ renderCtx.fragment
+ );
+ renderCtx.elements.set(cacheKey, caretEl);
+ if (isPrimary) {
+ caretEl.style.scrollMargin = this.#getScrollMargin();
+ this.#primaryCaretElement = caretEl;
+ }
+ }
+
+ #renderQuickEditIcon(
+ renderCtx: {
+ fragment: DocumentFragment;
+ elements: Map;
+ },
+ selection: EditorSelection
+ ) {
+ const line =
+ selection.direction === DirectionBackward
+ ? selection.start.line
+ : selection.end.line;
+ if (!this.#isLineVisible(line)) {
+ return;
+ }
+
+ const [left, wrapLine] = this.#getCharX(line, 0);
+ const cacheKey = 'quickEditIcon-' + line + '(' + wrapLine + ')';
+ if (renderCtx.elements.has(cacheKey)) {
+ return;
+ }
+
+ const quickEditIcon = QuickEditWidget.renderIcon(
+ left,
+ this.#getLineY(line) + wrapLine * this.#metrics.lineHeight,
+ renderCtx.fragment,
+ () => {
+ const cleanUpQuickEdit = () => {
+ this.#quickEdit?.cleanup();
+ this.#quickEdit = undefined;
+ };
+
+ const handleWidgetDomResize = () => {
+ // the line y cache is invalidated by the DOM change,
+ // clear the line y cache and re-render the selection
+ this.#lineYCache.clear();
+ if (this.#selections !== undefined) {
+ this.#updateSelections(this.#selections);
+ }
+ };
+
+ // remove the existing quick edit element
+ cleanUpQuickEdit();
+
+ const textDocument = this.#textDocument;
+ const renderQuickEdit = this.#options.renderQuickEdit;
+ const fileContainer = this.#componentContainer;
+ if (
+ textDocument === undefined ||
+ renderQuickEdit === undefined ||
+ fileContainer == null
+ ) {
+ return;
+ }
+
+ const line = selection.end.line;
+ const lineText = textDocument.getLineText(line);
+ const quickEditElement = renderQuickEdit({
+ textDocument,
+ selection,
+ applyEdits: (edits: TextEdit[]) => {
+ const change = textDocument.applyEdits(
+ edits,
+ true,
+ this.#selections
+ );
+ if (change !== undefined) {
+ this.#applyChange(change);
+ }
+ },
+ getSelectionText: () => {
+ return this.#textDocument?.getText(selection) ?? '';
+ },
+ replaceSelectionText: (text: string) => {
+ this.#replaceSelectionText(text);
+ },
+ close: () => {
+ cleanUpQuickEdit();
+ handleWidgetDomResize();
+ this.#scrollToPrimaryCaret();
+ },
+ });
+ let leadingWhitespaces = 0;
+ for (let i = 0; i < lineText.length; i++) {
+ const charCode = lineText.charCodeAt(i);
+ if (charCode === /* space */ 32) {
+ leadingWhitespaces++;
+ } else if (charCode === /* tab */ 9) {
+ leadingWhitespaces += this.#metrics.tabSize;
+ } else {
+ break;
+ }
+ }
+ this.#quickEdit = new QuickEditWidget(
+ line,
+ quickEditElement,
+ fileContainer,
+ leadingWhitespaces,
+ handleWidgetDomResize
+ );
+ this.#updateSelections([selection]);
+ if (this.#isLineVisible(line) && this.#contentElement !== undefined) {
+ this.#quickEdit.render(this.#contentElement);
+ }
+ }
+ );
+ renderCtx.elements.set(cacheKey, quickEditIcon);
+ }
+
+ // TODO(@ije): render search highlight
+ #renderSearchPanel() {
+ // cleanup the existing search panel
+ this.#searchPanel?.cleanup();
+
+ const textDocument = this.#textDocument;
+ const selections = this.#selections;
+ const preElement =
+ this.#componentContainer?.shadowRoot?.querySelector('pre');
+ if (
+ textDocument === undefined ||
+ selections === undefined ||
+ preElement == null
+ ) {
+ return;
+ }
+
+ const primaryIndex = selections.length - 1;
+ let primarySelection = selections[primaryIndex];
+ if (isCollapsedSelection(primarySelection)) {
+ const expanded = expandCollapsedSelectionToWord(
+ textDocument,
+ primarySelection
+ );
+ const nextSelections = [...selections.slice(0, primaryIndex), expanded];
+ this.#updateSelections(nextSelections);
+ primarySelection = expanded;
+ }
+ const selectionText = textDocument.getText(primarySelection);
+ const defaultQuery = !selectionText.includes('\n') ? selectionText : '';
+ const initialMatch: [number, number] | undefined =
+ defaultQuery !== ''
+ ? [
+ textDocument.offsetAt(primarySelection.start),
+ textDocument.offsetAt(primarySelection.end),
+ ]
+ : undefined;
+
+ this.#searchPanel = new SearchPanelWidget({
+ textDocument,
+ containerElement: preElement,
+ defaultQuery,
+ initialMatch,
+ getCurrentSearchRange: () => this.#selections?.at(-1),
+ postSearch: (kind, [startOffset, endOffset], retainFocus) => {
+ if (
+ kind === 'findNext' ||
+ kind === 'findPrevious' ||
+ kind === 'replace'
+ ) {
+ const nextSelection = createSelectionFromAnchorAndFocusOffsets(
+ textDocument,
+ startOffset,
+ endOffset
+ );
+ this.#updateSelections([nextSelection]);
+ this.#scrollToPrimaryCaret();
+ if (retainFocus === true) {
+ this.#retainSearchPanelFocus = true;
+ requestAnimationFrame(() => {
+ this.#searchPanel?.focus();
+ });
+ }
+ } else if (kind === 'findAll' || kind === 'replaceAll') {
+ const { line, character } = textDocument.positionAt(startOffset);
+ this.#scrollToLine(line, character);
+ }
+ },
+ onClose: () => {
+ this.#searchPanel = undefined;
+ this.#retainSearchPanelFocus = false;
+ },
+ });
+ this.#retainSearchPanelFocus = false;
+ }
+
+ #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 === undefined || primarySelection === undefined) {
+ return;
+ }
+ const { nextSelections, change } =
+ Array.isArray(text) && text.length === selections.length
+ ? applyTextReplaceToSelections(
+ textDocument,
+ selections,
+ text,
+ this.#lineAnnotations
+ )
+ : applyTextChangeToSelections(
+ textDocument,
+ selections,
+ {
+ start: textDocument.offsetAt(primarySelection.start),
+ end: textDocument.offsetAt(primarySelection.end),
+ text: Array.isArray(text) ? text.join('\n') : text,
+ },
+ this.#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: '',
+ };
+
+ this.#applyResolvedTextEdit(edit);
+ }
+
+ #deleteHardLineForward() {
+ const selections = this.#selections;
+ const textDocument = this.#textDocument;
+ if (selections === undefined || textDocument === undefined) {
+ return;
+ }
+ const { nextSelections, change } =
+ applyDeleteHardLineForwardToSelections(
+ textDocument,
+ selections,
+ this.#lineAnnotations
+ );
+ if (change !== undefined) {
+ this.#applyChange(
+ change,
+ nextSelections,
+ this.#applyChangeToLineAnnotations(change)
+ );
+ }
+ }
+
+ #insertTranspose() {
+ const selections = this.#selections;
+ const textDocument = this.#textDocument;
+ if (selections === undefined || textDocument === undefined) {
+ return;
+ }
+ const { nextSelections, change } = applyTransposeToSelections(
+ textDocument,
+ selections,
+ this.#lineAnnotations
+ );
+ if (change !== undefined) {
+ this.#applyChange(
+ change,
+ nextSelections,
+ this.#applyChangeToLineAnnotations(change)
+ );
+ }
+ }
+
+ #applyResolvedTextEdit(edit: ResolvedTextEdit) {
+ if (this.#selections === undefined || this.#textDocument === undefined) {
+ return;
+ }
+ const { nextSelections, change } = applyTextChangeToSelections(
+ this.#textDocument,
+ this.#selections,
+ edit,
+ this.#lineAnnotations,
+ this.#metrics.tabSize
+ );
+ if (change !== undefined) {
+ this.#applyChange(
+ change,
+ nextSelections,
+ this.#applyChangeToLineAnnotations(change)
+ );
+ }
+ }
+
+ #applyChange(
+ change: TextDocumentChange,
+ selections?: EditorSelection[],
+ newLineAnnotations?: DiffLineAnnotation[]
+ ) {
+ const fileContents = this.#fileContents;
+ const textDocument = this.#textDocument;
+ const onChange = this.#options.onChange;
+ if (
+ fileContents !== undefined &&
+ textDocument !== undefined &&
+ onChange !== undefined
+ ) {
+ const { contents: _, ...file } = fileContents;
+ let contents: string | undefined;
+ // tradeoff: using a getter for the 'contents' property
+ // to avoid pre-concactinating the text content of the textDocument
+ // but the user may get newer contents when accessing
+ // the 'contents' property
+ Object.defineProperty(file, 'contents', {
+ get: () => (contents ??= textDocument.getText()),
+ });
+ this.#emitChange(
+ file as FileContents,
+ newLineAnnotations ?? this.#lineAnnotations
+ );
+ }
+
+ // 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);
+ }
+ }
+ }
+ this.#lastCharX = undefined;
+
+ let renderRange = this.#renderRange;
+ let shouldUpdateBuffer: boolean | undefined;
+ if (
+ renderRange !== undefined &&
+ selections !== undefined &&
+ selections.length > 0
+ ) {
+ const primarySelection = selections.at(-1)!;
+ const renderRangeEndLine =
+ renderRange.startingLine + renderRange.totalLines;
+ // when typing new line at the end of the file,
+ // extend the render range +1 to trigger the re-render of the new line
+ if (primarySelection.end.line === renderRangeEndLine) {
+ renderRange = {
+ ...renderRange,
+ totalLines: renderRange.totalLines + 1,
+ };
+ } else if (primarySelection.end.line > renderRangeEndLine) {
+ shouldUpdateBuffer = true;
+ }
+ }
+ this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer);
+
+ if (selections !== undefined) {
+ // re-render selection range and caret, focus to the editor to update the window selection,
+ // and scroll to the crate to mock the 'contenteditable' behavior
+ this.#updateSelections(selections);
+ this.focus({ preventScroll: true });
+ requestAnimationFrame(() => {
+ if (this.#primaryCaretElement !== undefined) {
+ this.#primaryCaretElement.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ } else if (selections.length > 0) {
+ const { start, end, direction } = selections.at(-1)!;
+ const pos = direction === DirectionBackward ? start : end;
+ this.#scrollToLine(pos.line, pos.character);
+ }
+ });
+ }
+ }
+
+ #applyChangeToLineAnnotations(
+ change: TextDocumentChange
+ ): DiffLineAnnotation[] | undefined {
+ if (this.#lineAnnotations !== undefined) {
+ const nextLineAnnotations =
+ applyDocumentChangeToLineAnnotations(
+ change,
+ this.#lineAnnotations
+ );
+ if (nextLineAnnotations !== undefined) {
+ this.#textDocument?.setLastUndoLineAnnotations(
+ this.#lineAnnotations,
+ nextLineAnnotations
+ );
+ return nextLineAnnotations;
+ }
+ }
+ return undefined;
+ }
+
+ #getLineElement(line: number): HTMLElement | undefined {
+ const contentElement = this.#contentElement;
+ if (contentElement === undefined) {
+ return undefined;
+ }
+ // check if the line is within the render range
+ if (this.#renderRange !== undefined && this.#editMode === 'simple') {
+ const { startingLine } = this.#renderRange;
+ const { children } = contentElement;
+ for (let i = line - startingLine; i <= children.length; i++) {
+ const child = children[i] as HTMLElement | undefined;
+ if (
+ child !== undefined &&
+ child.dataset.line !== undefined &&
+ Number(child.dataset.line) - 1 === line
+ ) {
+ return child;
+ }
+ }
+ }
+ // fallback to query selector
+ return (
+ contentElement.querySelector(`[data-line="${line + 1}"]`) ??
+ undefined
+ );
+ }
+
+ #getGutterWidth(): number {
+ const gutterElement = this.#contentElement?.previousElementSibling;
+ if (
+ gutterElement == null ||
+ !(gutterElement instanceof HTMLElement) ||
+ !gutterElement.hasAttribute('data-gutter')
+ ) {
+ return 0;
+ }
+
+ if (this.#gutterWidthCache === undefined) {
+ const diffsColumnNumberWidth =
+ this.#contentElement?.parentElement?.style.getPropertyValue(
+ '--diffs-column-number-width'
+ );
+ if (
+ diffsColumnNumberWidth !== undefined &&
+ diffsColumnNumberWidth.length > 2 &&
+ diffsColumnNumberWidth.endsWith('px')
+ ) {
+ this.#gutterWidthCache = Number(diffsColumnNumberWidth.slice(0, -2));
+ } else {
+ this.#gutterWidthCache = gutterElement.offsetWidth;
+ }
+ }
+
+ return this.#gutterWidthCache;
+ }
+
+ #getContentWidth(): number {
+ if (this.#contentElement === undefined) {
+ return 0;
+ }
+
+ if (this.#contentWidthCache === undefined) {
+ const diffsColumnContentWidth =
+ this.#contentElement.parentElement?.style.getPropertyValue(
+ '--diffs-column-content-width'
+ );
+ if (
+ diffsColumnContentWidth !== undefined &&
+ diffsColumnContentWidth.length > 2 &&
+ diffsColumnContentWidth.endsWith('px')
+ ) {
+ this.#contentWidthCache = Number(diffsColumnContentWidth.slice(0, -2));
+ } else {
+ this.#contentWidthCache = this.#contentElement.offsetWidth;
+ }
+ }
+ return this.#contentWidthCache;
+ }
+
+ // get line top(y-coordinate) position
+ #getLineY(line: number) {
+ const cachedY = this.#lineYCache.get(line);
+ if (cachedY !== undefined) {
+ return cachedY;
+ }
+
+ const lineElement = this.#getLineElement(line);
+ if (lineElement === undefined) {
+ return -1;
+ }
+
+ // cold(slow) path: measure line top position from DOM (will cause reflow)
+ const y = lineElement.offsetTop + this.#codePaddingTop;
+ 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.#metrics.ch; // gutter width + inline padding (1ch)
+ if (lineText === undefined || lineText.length === 0 || char <= 0) {
+ return [offsetLeft, 0];
+ }
+
+ const boundedCharacter = snapTextOffsetToUnicodeBoundary(
+ lineText,
+ Math.min(char, lineText.length)
+ );
+ const textBeforeCharacter = lineText.slice(0, boundedCharacter);
+ const asciiColumns = getExpandedAsciiTextColumns(
+ textBeforeCharacter,
+ this.#metrics.tabSize
+ );
+
+ let left = 0;
+ let wrapLine = 0;
+ if (asciiColumns !== -1) {
+ left = offsetLeft + asciiColumns * this.#metrics.ch;
+ } else {
+ left = offsetLeft + this.#metrics.measureTextWidth(textBeforeCharacter);
+ }
+
+ if (this.#wrap) {
+ const contentWidth = this.#getContentWidth();
+ const textWidth =
+ 2 * this.#metrics.ch + this.#metrics.measureTextWidth(lineText);
+ if (textWidth > 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 segmentAsciiColumns = getExpandedAsciiTextColumns(
+ prefixInSegment,
+ this.#metrics.tabSize
+ );
+ if (segmentAsciiColumns !== -1) {
+ left = offsetLeft + segmentAsciiColumns * this.#metrics.ch;
+ } else {
+ left =
+ offsetLeft + this.#metrics.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];
+ }
+
+ // 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 = h(
+ 'div',
+ {
+ style: {
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ width: '100%',
+ boxSizing: 'border-box',
+ visibility: 'hidden',
+ pointerEvents: 'none',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ font: 'inherit',
+ paddingInline: '1ch',
+ tabSize: this.#metrics.tabSize.toString(),
+ },
+ textContent: lineText,
+ },
+ this.#contentElement
+ );
+ const textNode = div.firstChild as Text;
+ const range = document.createRange();
+ const starts: number[] = [];
+
+ try {
+ const unicodeOffsets = getUnicodeMeasurementOffsets(lineText);
+ const wrapLineStartLeft =
+ div.getBoundingClientRect().left + this.#metrics.ch;
+
+ let previousOffset = 0;
+ let lastTop = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0, offsetIndex = 0; i < lineText.length; ) {
+ const nextOffset =
+ unicodeOffsets === undefined
+ ? i + 1
+ : unicodeOffsets[offsetIndex + 1];
+ range.setStart(textNode, i);
+ range.setEnd(textNode, nextOffset);
+
+ // A new visual line starts whenever the character's top edge moves
+ // below the previous character's top edge.
+ const { left, top } = range.getBoundingClientRect();
+ if (top > lastTop) {
+ // Safari can report the first range on a wrapped visual line as
+ // starting one character past the visual line start. Use the previous
+ // offset so segment-local caret math begins at the actual wrap point.
+ const startsPastLineStart =
+ isSafari() &&
+ starts.length > 0 &&
+ left - wrapLineStartLeft > this.#metrics.ch / 2;
+ starts.push(startsPastLineStart ? previousOffset : i);
+ lastTop = top;
+ }
+ previousOffset = i;
+ i = nextOffset;
+ offsetIndex++;
+ }
+
+ 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 ?? 0;
+ if (line < 0 || 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;
+ }
+}
diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts
new file mode 100644
index 000000000..04f1e421f
--- /dev/null
+++ b/packages/diffs/src/editor/index.ts
@@ -0,0 +1,2 @@
+export * from './editor';
+export * from './textDocument';
diff --git a/packages/diffs/src/editor/lineAnnotations.ts b/packages/diffs/src/editor/lineAnnotations.ts
new file mode 100644
index 000000000..fcc585eea
--- /dev/null
+++ b/packages/diffs/src/editor/lineAnnotations.ts
@@ -0,0 +1,54 @@
+import type { DiffLineAnnotation } from '../types';
+import type { TextDocumentChange } from './textDocument';
+
+export function applyDocumentChangeToLineAnnotations(
+ change: TextDocumentChange,
+ lineAnnotations: DiffLineAnnotation[]
+): DiffLineAnnotation[] | undefined {
+ if (change.lineDelta === 0) {
+ return undefined;
+ }
+
+ 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: DiffLineAnnotation[] = [];
+
+ 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 : undefined;
+}
diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts
new file mode 100644
index 000000000..73e663eee
--- /dev/null
+++ b/packages/diffs/src/editor/pieceTable.ts
@@ -0,0 +1,998 @@
+import { computeLineOffsets } from '../utils/computeFileOffsets';
+import type { SearchParams } from './searchPanel';
+import type { Position, Range, ResolvedTextEdit } from './textDocument';
+
+const MAX_FIND_MATCHES = 100000;
+// TODO(ije): use Intl.Segmenter instead of regex for word separators
+const WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?' as const;
+
+// 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,
+ public readonly lineOffsetStart: number,
+ public readonly lineOffsetEnd: number
+ ) {}
+
+ get lineBreakCount(): number {
+ return this.lineOffsetEnd - this.lineOffsetStart;
+ }
+}
+
+// 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 balanced piece tree.
+class PieceNode {
+ left: PieceNode | null = null;
+ right: PieceNode | null = null;
+ parent: PieceNode | null = null;
+
+ constructor(
+ public piece: Piece,
+ public subtreeLength: number = piece.length,
+ public subtreeLineBreakCount: number = piece.lineBreakCount
+ ) {}
+
+ updateSubtreeLength(): void {
+ this.subtreeLength =
+ (this.left?.subtreeLength ?? 0) +
+ this.piece.length +
+ (this.right?.subtreeLength ?? 0);
+ this.subtreeLineBreakCount =
+ (this.left?.subtreeLineBreakCount ?? 0) +
+ this.piece.lineBreakCount +
+ (this.right?.subtreeLineBreakCount ?? 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 rebuilt as a balanced tree after edits to keep lookups 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;
+ #piecesCache: Piece[] = [];
+ #length = 0;
+ #lineCount = 0;
+ #lastVisitedLine: [number, string] | null = null;
+
+ constructor(originalText: string) {
+ this.#original = new TextBuffer(originalText);
+ this.#setPieces([
+ this.#createPiece(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;
+ }
+
+ search(
+ kind: 'findNext' | 'findPrevious' | 'findAll' | 'replace' | 'replaceAll',
+ searchParams: SearchParams,
+ range?: Range
+ ): [start: number, end: number][] {
+ if (searchParams.text.length === 0 || this.#length === 0) {
+ return [];
+ }
+
+ let pattern: RegExp;
+ try {
+ pattern = compileSearchRegExp(
+ searchParams.text,
+ searchParams.regex,
+ searchParams.caseSensitive
+ );
+ } catch {
+ return [];
+ }
+
+ const matches = this.#collectSearchMatchesLineByLine(
+ pattern,
+ searchParams.wholeWord,
+ MAX_FIND_MATCHES
+ );
+
+ if (kind === 'findAll' || kind === 'replaceAll') {
+ return matches;
+ }
+
+ const caretOffset =
+ range === undefined
+ ? 0
+ : kind === 'findPrevious'
+ ? this.offsetAt(range?.start)
+ : this.offsetAt(range?.end);
+
+ if (kind === 'findPrevious') {
+ const refOffset = getSearchFindPreviousReferenceOffset(range, (p) =>
+ this.offsetAt(p)
+ );
+ let best: [number, number] | undefined;
+ for (const m of matches) {
+ if (m[1] <= refOffset) {
+ best = m;
+ } else {
+ break;
+ }
+ }
+ if (best !== undefined) {
+ return [best];
+ }
+ const last = matches[matches.length - 1];
+ return last !== undefined ? [last] : [];
+ }
+
+ // findNext, replace — forward from caret with wrap
+ for (const m of matches) {
+ if (m[0] >= caretOffset) {
+ return [m];
+ }
+ }
+ const first = matches[0];
+ return first !== undefined ? [first] : [];
+ }
+
+ #collectSearchMatchesLineByLine(
+ pattern: RegExp,
+ wholeWord: boolean,
+ limit: number
+ ): [number, number][] {
+ const out: [number, number][] = [];
+ const docLength = this.#length;
+ const charAt = (offset: number) => this.charAt(offset);
+
+ for (let line = 0; line < this.#lineCount; line++) {
+ const lineText = this.getLineText(line);
+ const lineStart = this.offsetAt({ line, character: 0 });
+ const re = new RegExp(pattern.source, pattern.flags);
+ re.lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(lineText)) !== null) {
+ const rel = match.index;
+ const fragment = match[0];
+ if (fragment.length === 0) {
+ re.lastIndex = advancePastEmptyMatch(lineText, rel);
+ continue;
+ }
+ const docStart = lineStart + rel;
+ if (
+ !wholeWord ||
+ isWholeWordAtDocOffsets(docStart, fragment.length, docLength, charAt)
+ ) {
+ out.push([docStart, docStart + fragment.length]);
+ if (out.length >= limit) {
+ return out;
+ }
+ }
+ if (rel === re.lastIndex) {
+ re.lastIndex = advancePastEmptyMatch(lineText, rel);
+ }
+ }
+ }
+ return out;
+ }
+
+ 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 = this.#createPiece(
+ 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(
+ this.#createPiece(piece.source, piece.offset, splitOffset)
+ );
+ }
+ nextPieces.push(insertedPiece);
+ if (splitOffset < piece.length) {
+ nextPieces.push(
+ this.#createPiece(
+ piece.source,
+ piece.offset + splitOffset,
+ 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(
+ this.#createPiece(piece.source, piece.offset, keepBefore)
+ );
+ }
+ if (keepAfter > 0) {
+ nextPieces.push(
+ this.#createPiece(
+ piece.source,
+ piece.offset + piece.length - keepAfter,
+ keepAfter
+ )
+ );
+ }
+ cursor = pieceEnd;
+ }
+
+ this.#setPieces(nextPieces);
+ this.#lastVisitedLine = null;
+ }
+
+ applyEdits(edits: readonly ResolvedTextEdit[]): void {
+ if (edits.length === 0) {
+ return;
+ }
+
+ let pieceIndex = 0;
+ let pieceStart = 0;
+ let copyCursor = 0;
+
+ const pieces = this.#pieces();
+ const insertedPieces = edits.map((edit) =>
+ edit.text.length === 0
+ ? undefined
+ : this.#createPiece(
+ Piece.Added,
+ this.#add.append(edit.text),
+ edit.text.length
+ )
+ );
+ const nextPieces: Piece[] = [];
+
+ const advancePiece = () => {
+ const piece = pieces[pieceIndex];
+ if (piece !== undefined) {
+ pieceStart += piece.length;
+ pieceIndex++;
+ }
+ };
+
+ const appendRange = (start: number, end: number) => {
+ let rangeStart = clamp(start, 0, this.#length);
+ const rangeEnd = clamp(end, rangeStart, this.#length);
+ while (
+ pieceIndex < pieces.length &&
+ pieceStart + pieces[pieceIndex].length <= rangeStart
+ ) {
+ advancePiece();
+ }
+ while (pieceIndex < pieces.length && rangeStart < rangeEnd) {
+ const piece = pieces[pieceIndex];
+ const pieceEnd = pieceStart + piece.length;
+ const offsetInPiece = clamp(rangeStart - pieceStart, 0, piece.length);
+ const takeEnd = Math.min(pieceEnd, rangeEnd);
+ const takeLength = takeEnd - (pieceStart + offsetInPiece);
+ if (takeLength > 0) {
+ nextPieces.push(
+ offsetInPiece === 0 && takeLength === piece.length
+ ? piece
+ : this.#createPiece(
+ piece.source,
+ piece.offset + offsetInPiece,
+ takeLength
+ )
+ );
+ }
+ rangeStart = takeEnd;
+ if (rangeStart >= pieceEnd) {
+ advancePiece();
+ }
+ }
+ };
+
+ for (let i = 0; i < edits.length; i++) {
+ const edit = edits[i];
+ const start = clamp(edit.start, copyCursor, this.#length);
+ const end = clamp(edit.end, start, this.#length);
+ appendRange(copyCursor, start);
+
+ const insertedPiece = insertedPieces[i];
+ if (insertedPiece !== undefined) {
+ nextPieces.push(insertedPiece);
+ }
+ copyCursor = end;
+ }
+ appendRange(copyCursor, this.#length);
+
+ 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 };
+ }
+ const line = this.#lineAtOffset(clampedOffset);
+ const lineStart = line === 0 ? 0 : this.#lineBreakOffset(line - 1);
+ return {
+ line,
+ character: clampedOffset - lineStart,
+ };
+ }
+
+ positionsAt(offsets: readonly number[]): Position[] {
+ const positions: Position[] = Array.from({ length: offsets.length });
+ if (offsets.length === 0) {
+ return positions;
+ }
+ if (this.#length === 0) {
+ return positions.fill({ line: 0, character: 0 });
+ }
+
+ for (let i = 0; i < offsets.length; i++) {
+ positions[i] = this.positionAt(offsets[i]);
+ }
+
+ return positions;
+ }
+
+ 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;
+ }
+
+ offsetsAt(positions: readonly Position[]): number[] {
+ const offsets: number[] = Array.from({ length: positions.length });
+ if (positions.length === 0) {
+ return offsets;
+ }
+ if (this.#length === 0) {
+ return offsets.fill(0);
+ }
+
+ for (let i = 0; i < positions.length; i++) {
+ offsets[i] = this.offsetAt(positions[i]);
+ }
+
+ return offsets;
+ }
+
+ #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}`);
+ }
+ if (line >= this.#lineCount) {
+ throw new Error(`Line index out of range: ${line}`);
+ }
+
+ const start = line === 0 ? 0 : this.#lineBreakOffset(line - 1);
+ const end =
+ line < this.#lineCount - 1 ? this.#lineBreakOffset(line) : this.#length;
+ return [start, end];
+ }
+
+ #lineAtOffset(offset: number): number {
+ let node = this.#root;
+ let remaining = clamp(offset, 0, this.#length);
+ let line = 0;
+
+ while (node !== null) {
+ const leftLength = node.left?.subtreeLength ?? 0;
+ if (remaining < leftLength) {
+ node = node.left;
+ continue;
+ }
+
+ line += node.left?.subtreeLineBreakCount ?? 0;
+ remaining -= leftLength;
+ if (remaining <= node.piece.length) {
+ const buffer = this.#bufferFor(node.piece.source);
+ line +=
+ upperBound(buffer.lineOffsets, node.piece.offset + remaining) -
+ node.piece.lineOffsetStart;
+ return line;
+ }
+
+ line += node.piece.lineBreakCount;
+ remaining -= node.piece.length;
+ node = node.right;
+ }
+
+ return this.#lineCount - 1;
+ }
+
+ #lineBreakOffset(lineBreakIndex: number): number {
+ let node = this.#root;
+ let remaining = lineBreakIndex;
+ let documentOffset = 0;
+
+ while (node !== null) {
+ const leftLineBreakCount = node.left?.subtreeLineBreakCount ?? 0;
+ if (remaining < leftLineBreakCount) {
+ node = node.left;
+ continue;
+ }
+
+ const leftLength = node.left?.subtreeLength ?? 0;
+ documentOffset += leftLength;
+ remaining -= leftLineBreakCount;
+
+ if (remaining < node.piece.lineBreakCount) {
+ const bufferLineOffset = this.#bufferFor(node.piece.source).lineOffsets[
+ node.piece.lineOffsetStart + remaining
+ ];
+ return documentOffset + (bufferLineOffset - node.piece.offset);
+ }
+
+ documentOffset += node.piece.length;
+ remaining -= node.piece.lineBreakCount;
+ node = node.right;
+ }
+
+ return 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[];
+ readonly lineOffsetStart: number;
+ readonly lineOffsetEnd: number;
+ }) => boolean | void
+ ): void {
+ this.#walk(this.#root, (node) => {
+ const buffer = this.#bufferFor(node.piece.source);
+ return callback({
+ text: buffer.text,
+ lineOffsets: buffer.lineOffsets,
+ lineOffsetStart: node.piece.lineOffsetStart,
+ lineOffsetEnd: node.piece.lineOffsetEnd,
+ start: node.piece.offset,
+ end: node.piece.offset + node.piece.length,
+ });
+ });
+ }
+
+ #bufferFor(source: number): TextBuffer {
+ return source === Piece.Original ? this.#original : this.#add;
+ }
+
+ #createPiece(source: number, offset: number, length: number): Piece {
+ const buffer = this.#bufferFor(source);
+ return new Piece(
+ source,
+ offset,
+ length,
+ upperBound(buffer.lineOffsets, offset),
+ upperBound(buffer.lineOffsets, offset + length)
+ );
+ }
+
+ #pieces(): Piece[] {
+ return this.#piecesCache;
+ }
+
+ #setPieces(pieces: Piece[]): void {
+ const coalescedPieces = coalescePieces(pieces);
+ this.#piecesCache = coalescedPieces;
+ let length = 0;
+ let lineBreakCount = 0;
+ for (const piece of coalescedPieces) {
+ length += piece.length;
+ lineBreakCount += piece.lineBreakCount;
+ }
+ this.#root = this.#buildBalancedTree(
+ coalescedPieces,
+ 0,
+ coalescedPieces.length,
+ null
+ );
+ this.#length = length;
+ this.#lineCount = lineBreakCount + 1;
+ }
+
+ #buildBalancedTree(
+ pieces: Piece[],
+ start: number,
+ end: number,
+ parent: PieceNode | null
+ ): PieceNode | null {
+ if (start >= end) {
+ return null;
+ }
+
+ const middle = start + Math.floor((end - start) / 2);
+ const node = new PieceNode(pieces[middle]);
+ node.parent = parent;
+ node.left = this.#buildBalancedTree(pieces, start, middle, node);
+ node.right = this.#buildBalancedTree(pieces, middle + 1, end, node);
+ node.updateSubtreeLength();
+ return node;
+ }
+
+ #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);
+ }
+}
+
+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] = new Piece(
+ previous.source,
+ previous.offset,
+ previous.length + piece.length,
+ previous.lineOffsetStart,
+ piece.lineOffsetEnd
+ );
+ 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;
+}
+
+function escapeRegExp(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function isWordSeparatorCharCode(charCode: number): boolean {
+ if (charCode <= 32 || charCode === 127) {
+ return true;
+ }
+ const ch = String.fromCharCode(charCode);
+ return WORD_SEPARATORS.includes(ch);
+}
+
+// Checks if the given text is a whole word by checking if the
+// characters before and after are word separators.
+function isWholeWordAtDocOffsets(
+ docStart: number,
+ length: number,
+ docLength: number,
+ charAt: (offset: number) => string
+): boolean {
+ const beforeOk =
+ docStart <= 0 ||
+ isWordSeparatorCharCode(charCodeUnitAt(charAt, docStart - 1));
+ const afterOk =
+ docStart + length >= docLength ||
+ isWordSeparatorCharCode(charCodeUnitAt(charAt, docStart + length));
+ return beforeOk && afterOk;
+}
+
+function charCodeUnitAt(
+ charAt: (offset: number) => string,
+ offset: number
+): number {
+ const unit = charAt(offset);
+ return unit.length === 0 ? 0 : unit.charCodeAt(0);
+}
+
+function compileSearchRegExp(
+ source: string,
+ isRegex: boolean,
+ caseSensitive: boolean
+): RegExp {
+ const body = isRegex ? source : escapeRegExp(source);
+ const flags = `g${caseSensitive ? '' : 'i'}${isRegex ? 'm' : ''}`;
+ return new RegExp(body, flags);
+}
+
+function advancePastEmptyMatch(text: string, index: number): number {
+ if (index + 1 < text.length) {
+ const first = text.charCodeAt(index);
+ const second = text.charCodeAt(index + 1);
+ if (
+ first >= 0xd800 &&
+ first <= 0xdbff &&
+ second >= 0xdc00 &&
+ second <= 0xdfff
+ ) {
+ return index + 2;
+ }
+ }
+ return index + 1;
+}
+
+// Returns the leftmost UTF-16 offset of the selection; used for find-previous
+// so we skip the current match.
+function getSearchFindPreviousReferenceOffset(
+ selection: Range | undefined,
+ offsetAt: (p: Position) => number
+): number {
+ if (selection === undefined) {
+ return 0;
+ }
+ const a = offsetAt(selection.start);
+ const b = offsetAt(selection.end);
+ return Math.min(a, b);
+}
diff --git a/packages/diffs/src/editor/platform.ts b/packages/diffs/src/editor/platform.ts
new file mode 100644
index 000000000..1f2854161
--- /dev/null
+++ b/packages/diffs/src/editor/platform.ts
@@ -0,0 +1,33 @@
+let _isMacLike: boolean | undefined = undefined;
+let _isLinux: boolean | undefined = undefined;
+let _isSafari: boolean | undefined = undefined;
+
+export function isMacLike(): boolean {
+ return (_isMacLike ??= /macOS|MacIntel|iPhone|iPad|iPod/i.test(
+ getPlatform()
+ ));
+}
+
+export function isLinux(): boolean {
+ return (_isLinux ??= /Linux/i.test(getPlatform()));
+}
+
+export function isSafari(): boolean {
+ return (_isSafari ??=
+ ('safari' in window && 'pushNotification' in (window as any).safari) ||
+ /^((?!chrome|android).)*safari/i.test(navigator.userAgent));
+}
+
+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/quickEdit.ts b/packages/diffs/src/editor/quickEdit.ts
new file mode 100644
index 000000000..0ae08ed80
--- /dev/null
+++ b/packages/diffs/src/editor/quickEdit.ts
@@ -0,0 +1,126 @@
+import type { EditorSelection } from './selection';
+import type { TextDocument, TextEdit } from './textDocument';
+import { h } from './utils';
+
+export interface QuickEditContext {
+ /** The current selection. */
+ selection: EditorSelection;
+ /** The text document. */
+ textDocument: TextDocument;
+ /** Applies the edits to the text document. */
+ applyEdits: (edits: TextEdit[]) => void;
+ /** Gets the text of the current selection. */
+ getSelectionText: () => string;
+ /** Replaces the text of the current selection. */
+ replaceSelectionText: (text: string) => void;
+ /** Closes the quick edit. */
+ close: () => void;
+}
+
+export class QuickEditWidget {
+ static renderIcon(
+ x: number,
+ y: number,
+ container: HTMLElement | DocumentFragment,
+ onclick: () => void
+ ): HTMLElement {
+ return h(
+ 'div',
+ {
+ dataset: { quickEditIcon: '', visible: 'false' },
+ title: 'Quick Edit',
+ style: {
+ transform: `translateY(${y}px) translateX(${x}px)`,
+ },
+ innerHTML: `
+ `,
+ onclick,
+ },
+ container
+ );
+ }
+
+ #gutterBuffer: HTMLElement;
+ #quickEditContainer: HTMLElement;
+ #slot: HTMLElement;
+ #observer: ResizeObserver;
+ #handleDomResize: () => void;
+
+ constructor(
+ public line: number,
+ quickEditElement: HTMLElement,
+ fileContainer: HTMLElement,
+ leadingWhitespaces = 0,
+ handleDomResize: () => void
+ ) {
+ const slotName = 'quick-edit-' + line;
+ this.#slot = h(
+ 'div',
+ {
+ dataset: 'quickEditSlot',
+ slot: slotName,
+ style: 'white-space: normal',
+ children: [quickEditElement],
+ },
+ fileContainer
+ );
+ this.#gutterBuffer = h('div', {
+ dataset: { gutterBuffer: 'quickEdit', bufferSize: '1' },
+ style: 'grid-row: span 1',
+ });
+ this.#quickEditContainer = h('div', {
+ dataset: { quickEdit: String(line) },
+ style: {
+ paddingInlineStart: leadingWhitespaces + 1 + 'ch', // +1 align css `padding-inline`
+ },
+ contentEditable: 'false',
+ children: [h('slot', { name: slotName })],
+ });
+ this.#observer = new ResizeObserver(handleDomResize);
+ this.#observer.observe(this.#slot);
+ this.#handleDomResize = handleDomResize;
+ }
+
+ render(containerElement: HTMLElement): void {
+ const gutterElement =
+ containerElement.previousElementSibling as HTMLElement | null;
+ const lineNumber = this.line + 1;
+ const gutterLineElement = gutterElement?.querySelector(
+ `[data-column-number="${lineNumber}"]`
+ );
+ const contentLineElement = containerElement.querySelector(
+ `[data-line="${lineNumber}"]`
+ );
+ if (
+ gutterElement != null &&
+ gutterLineElement != null &&
+ contentLineElement != null
+ ) {
+ gutterLineElement.after(this.#gutterBuffer);
+ contentLineElement.after(this.#quickEditContainer);
+ gutterElement.style.gridRow = 'span ' + gutterElement.children.length;
+ containerElement.style.gridRow =
+ 'span ' + containerElement.children.length;
+ this.#handleDomResize();
+ }
+ }
+
+ cleanup(): void {
+ const gutter = this.#gutterBuffer.parentElement;
+ const content = this.#quickEditContainer.parentElement;
+
+ this.#gutterBuffer.remove();
+ this.#quickEditContainer.remove();
+
+ if (gutter != null && content != null) {
+ gutter.style.gridRow = 'span ' + gutter.children.length;
+ content.style.gridRow = 'span ' + content.children.length;
+ }
+ this.#handleDomResize();
+
+ this.#slot.remove();
+ this.#observer.disconnect();
+ }
+}
diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts
new file mode 100644
index 000000000..1a18b16dd
--- /dev/null
+++ b/packages/diffs/src/editor/searchPanel.ts
@@ -0,0 +1,231 @@
+import { isPrimaryModifier } from './platform';
+import type { Range, TextDocument } from './textDocument';
+import { h } from './utils';
+
+export type SearchKind =
+ | 'findNext'
+ | 'findPrevious'
+ | 'findAll'
+ | 'replace'
+ | 'replaceAll';
+
+export interface SearchParams {
+ text: string;
+ replaceText: string;
+ caseSensitive: boolean;
+ wholeWord: boolean;
+ regex: boolean;
+}
+
+export interface SearchPanelOptions {
+ textDocument: TextDocument;
+ containerElement: HTMLElement;
+ defaultQuery: string;
+ initialMatch?: [number, number];
+ postSearch: (
+ kind: SearchKind,
+ match: [number, number],
+ retainFocus?: boolean
+ ) => void;
+ getCurrentSearchRange: () => Range | undefined;
+ onClose: () => void;
+}
+
+export class SearchPanelWidget {
+ #textDocument: TextDocument;
+ #container: HTMLDivElement;
+ #inputElement: HTMLInputElement;
+ #matchesElement: HTMLDivElement;
+ #searchParams: SearchParams = {
+ text: '',
+ replaceText: '',
+ caseSensitive: false,
+ wholeWord: false,
+ regex: false,
+ };
+ #allMatches: [number, number][] = [];
+
+ constructor(options: SearchPanelOptions) {
+ const {
+ textDocument,
+ containerElement,
+ defaultQuery,
+ initialMatch,
+ postSearch,
+ getCurrentSearchRange,
+ onClose,
+ } = options;
+
+ const close = () => {
+ this.cleanup();
+ onClose();
+ };
+
+ const updateSearchParam = (
+ key: K,
+ value: SearchParams[K]
+ ) => {
+ this.#searchParams[key] = value;
+ updateAllMatches();
+ this.updateMatches();
+ };
+
+ const updateAllMatches = () => {
+ this.#allMatches =
+ this.#searchParams.text !== ''
+ ? this.#textDocument.search('findAll', this.#searchParams)
+ : [];
+ this.#container
+ .querySelectorAll('[data-disabled]')
+ .forEach((element) => {
+ element.dataset.disabled = String(this.#allMatches.length === 0);
+ });
+ };
+
+ const search = (kind: SearchKind, retainFocus?: boolean) => {
+ const matches = this.#textDocument.search(
+ kind,
+ this.#searchParams,
+ getCurrentSearchRange()
+ );
+ if (matches.length === 0) {
+ return;
+ }
+ const firstMatch = matches[0];
+ this.updateMatches(firstMatch);
+ postSearch(kind, firstMatch, retainFocus);
+ };
+
+ // Creates a stateful icon button that toggles a boolean search param on click.
+ const makeToggle = (
+ iconHref: string,
+ title: string,
+ key: 'caseSensitive' | 'wholeWord' | 'regex'
+ ) => {
+ const btn = h('div', {
+ dataset: { icon: key, active: 'false' },
+ title,
+ innerHTML: ``,
+ onclick: () => {
+ const next = !this.#searchParams[key];
+ btn.dataset.active = String(next);
+ updateSearchParam(key, next);
+ },
+ });
+ return btn;
+ };
+
+ const caseSensitiveBtn = makeToggle(
+ '#diffs-icon-type',
+ 'Match Case',
+ 'caseSensitive'
+ );
+ const wholeWordBtn = makeToggle(
+ '#diffs-icon-type-word',
+ 'Whole Word',
+ 'wholeWord'
+ );
+ const regexBtn = makeToggle('#diffs-icon-regex', 'Regexp', 'regex');
+
+ this.#textDocument = textDocument;
+ this.#searchParams.text = defaultQuery;
+
+ this.#inputElement = h('input', {
+ type: 'text',
+ placeholder: 'Search',
+ dataset: 'search',
+ value: defaultQuery,
+ oninput: (e: Event) => {
+ this.#searchParams.text = (e.target as HTMLInputElement).value;
+ updateAllMatches();
+ this.updateMatches();
+ },
+ onkeydown: (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ search('findNext', true);
+ } else if (e.key === 'f' && isPrimaryModifier(e)) {
+ // prevent the default browser search panel open behavior
+ e.preventDefault();
+ }
+ },
+ });
+ this.#matchesElement = h('div', { dataset: 'matches' });
+ this.#container = h('div', {
+ dataset: 'searchPanel',
+ children: [
+ h('div', {
+ dataset: 'searchPanelRow',
+ children: [
+ h('div', {
+ dataset: { icon: 'search' },
+ innerHTML: ``,
+ }),
+ this.#inputElement,
+ this.#matchesElement,
+ caseSensitiveBtn,
+ wholeWordBtn,
+ regexBtn,
+ h('div', { dataset: 'divider' }),
+ h('div', {
+ dataset: { icon: 'arrow-up', disabled: 'true' },
+ title: 'Previous',
+ innerHTML: ``,
+ onclick: () => {
+ search('findPrevious');
+ },
+ }),
+ h('div', {
+ dataset: { icon: 'arrow-down', disabled: 'true' },
+ title: 'Next',
+ innerHTML: ``,
+ onclick: () => {
+ search('findNext');
+ },
+ }),
+ h('div', {
+ dataset: { icon: 'close' },
+ title: 'Close',
+ innerHTML: ``,
+ onclick: close,
+ }),
+ ],
+ }),
+ ],
+ });
+ containerElement.before(this.#container);
+
+ requestAnimationFrame(() => {
+ updateAllMatches();
+ this.updateMatches(initialMatch ?? this.#allMatches[0]);
+ this.#inputElement.select();
+ });
+ }
+
+ updateMatches(currentMatch: [number, number] = this.#allMatches[0]): void {
+ const allMatches = this.#allMatches;
+
+ if (allMatches.length === 0) {
+ this.#matchesElement.textContent = 'No results';
+ this.#matchesElement.dataset.noMatches = '';
+ } else {
+ delete this.#matchesElement.dataset.noMatches;
+ const index = allMatches.findIndex(
+ (m) => m[0] === currentMatch[0] && m[1] === currentMatch[1]
+ );
+ this.#matchesElement.textContent =
+ index !== -1 ? `${index + 1} of ${allMatches.length}` : 'No results';
+ }
+ }
+
+ focus(): void {
+ this.#inputElement.select();
+ }
+
+ cleanup(): void {
+ this.#container.remove();
+ }
+}
diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts
new file mode 100644
index 000000000..272aa93b0
--- /dev/null
+++ b/packages/diffs/src/editor/selection.ts
@@ -0,0 +1,1492 @@
+import type { DiffLineAnnotation } from '../types';
+import { applyDocumentChangeToLineAnnotations } from './lineAnnotations';
+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 the given text document
+ */
+export function applyTextChangeToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ edit: ResolvedTextEdit,
+ lineAnnotations?: DiffLineAnnotation[],
+ tabSize = 2
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ const primarySelection = selections[selections.length - 1];
+ if (primarySelection === undefined) {
+ return { nextSelections: [] };
+ }
+ const selectionPositions: Position[] = [];
+ for (const selection of selections) {
+ selectionPositions.push(selection.start, selection.end);
+ }
+ const selectionOffsets = textDocument.offsetsAt(selectionPositions);
+ const primaryStartOffset = selectionOffsets[(selections.length - 1) * 2];
+ const primaryEndOffset = selectionOffsets[(selections.length - 1) * 2 + 1];
+ const ordered: Array<{
+ index: number;
+ start: number;
+ end: number;
+ }> = [];
+ let isAlreadyOrdered = true;
+ for (let index = 0; index < selections.length; index++) {
+ const entry = {
+ index,
+ start: selectionOffsets[index * 2],
+ end: selectionOffsets[index * 2 + 1],
+ };
+ const previous = ordered[ordered.length - 1];
+ if (
+ previous !== undefined &&
+ (entry.start < previous.start ||
+ (entry.start === previous.start && entry.end < previous.end))
+ ) {
+ isAlreadyOrdered = false;
+ }
+ ordered.push(entry);
+ }
+ if (!isAlreadyOrdered) {
+ ordered.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: ResolvedTextEdit[] = [];
+ const nextSelectionOffsets: Array<[number, number] | undefined> = 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({
+ start: mergedGroup.start,
+ end: mergedGroup.end,
+ text: 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.applyResolvedEdits(edits, true, selections);
+ const nextSelections = createSelectionsFromOffsetPairs(
+ textDocument,
+ nextSelectionOffsets.map((offsets) => {
+ if (offsets === undefined) {
+ throw new Error('Missing next selection offsets');
+ }
+ return offsets;
+ })
+ );
+ textDocument.setLastUndoSelectionsAfter(nextSelections);
+ if (change !== undefined && lineAnnotations !== undefined) {
+ const nextLineAnnotations =
+ applyDocumentChangeToLineAnnotations(
+ change,
+ lineAnnotations
+ );
+ if (nextLineAnnotations !== undefined) {
+ textDocument.setLastUndoLineAnnotations(
+ lineAnnotations,
+ nextLineAnnotations
+ );
+ }
+ }
+ return { nextSelections, change };
+}
+
+/**
+ * Applies a text replace to multiple selections.
+ */
+export function applyTextReplaceToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ texts: string[],
+ lineAnnotations?: DiffLineAnnotation[]
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ if (selections.length !== texts.length) {
+ throw new Error(
+ 'Selection text replacements must match the selection count'
+ );
+ }
+ const selectionPositions: Position[] = [];
+ for (const selection of selections) {
+ selectionPositions.push(selection.start, selection.end);
+ }
+ const selectionOffsets = textDocument.offsetsAt(selectionPositions);
+ const ordered: Array<{
+ index: number;
+ start: number;
+ end: number;
+ text: string;
+ }> = [];
+ let isAlreadyOrdered = true;
+ for (let index = 0; index < selections.length; index++) {
+ const entry = {
+ index,
+ start: selectionOffsets[index * 2],
+ end: selectionOffsets[index * 2 + 1],
+ text: texts[index],
+ };
+ const previous = ordered[ordered.length - 1];
+ if (
+ previous !== undefined &&
+ (entry.start < previous.start ||
+ (entry.start === previous.start && entry.end < previous.end))
+ ) {
+ isAlreadyOrdered = false;
+ }
+ ordered.push(entry);
+ }
+ if (!isAlreadyOrdered) {
+ ordered.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: ResolvedTextEdit[] = [];
+ 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({
+ start: entry.start,
+ end: entry.end,
+ text: newText,
+ });
+ nextSelectionOffsets[entry.index] =
+ entry.start + offsetDelta + newText.length;
+ offsetDelta += newText.length - (entry.end - entry.start);
+ }
+
+ const change = textDocument.applyResolvedEdits(edits, true, selections);
+ const nextSelections = createSelectionsFromOffsetPairs(
+ textDocument,
+ nextSelectionOffsets.map((offset) => [offset, offset])
+ );
+ textDocument.setLastUndoSelectionsAfter(nextSelections);
+ if (change !== undefined && lineAnnotations !== undefined) {
+ const nextLineAnnotations =
+ applyDocumentChangeToLineAnnotations(
+ change,
+ lineAnnotations
+ );
+ if (nextLineAnnotations !== undefined) {
+ textDocument.setLastUndoLineAnnotations(
+ lineAnnotations,
+ nextLineAnnotations
+ );
+ }
+ }
+ return { nextSelections, change };
+}
+
+/**
+ * Swaps the two characters adjacent to a collapsed selection, matching browser
+ * insertTranspose (Ctrl+T) behavior.
+ */
+export function applyTransposeToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ lineAnnotations?: DiffLineAnnotation[]
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ const text = textDocument.getText();
+ const edits: ResolvedTextEdit[] = [];
+ const nextOffsetPairs: Array<[number, number]> = [];
+
+ for (const selection of selections) {
+ const [anchor, focus] = getSelectionAnchorAndFocusOffsets(
+ textDocument,
+ selection
+ );
+ if (!isCollapsedSelection(selection)) {
+ nextOffsetPairs.push([anchor, focus]);
+ continue;
+ }
+
+ const { line, character } = selection.start;
+ const offset = anchor;
+ const lineLength = textDocument.getLineText(line).length;
+ let edit: ResolvedTextEdit | undefined;
+
+ if (character > 0 && character < lineLength) {
+ edit = {
+ start: offset - 1,
+ end: offset + 1,
+ text: text[offset] + text[offset - 1],
+ };
+ nextOffsetPairs.push([offset + 1, offset + 1]);
+ } else if (character === lineLength && lineLength >= 2) {
+ edit = {
+ start: offset - 2,
+ end: offset,
+ text: text[offset - 1] + text[offset - 2],
+ };
+ nextOffsetPairs.push([offset, offset]);
+ } else if (character === 0 && line > 0 && lineLength > 0) {
+ const prevLine = line - 1;
+ const prevLength = textDocument.getLineText(prevLine).length;
+ const prevEnd = textDocument.offsetAt({
+ line: prevLine,
+ character: prevLength,
+ });
+ const prevStart = prevLength > 0 ? prevEnd - 1 : prevEnd;
+ edit = {
+ start: prevStart,
+ end: offset + 1,
+ text:
+ text[offset] +
+ text.slice(prevEnd, offset) +
+ text.slice(prevStart, prevEnd),
+ };
+ nextOffsetPairs.push([offset + 1, offset + 1]);
+ } else {
+ nextOffsetPairs.push([anchor, focus]);
+ continue;
+ }
+
+ edits.push(edit);
+ }
+
+ if (edits.length === 0) {
+ return { nextSelections: selections };
+ }
+
+ edits.sort((a, b) => a.start - b.start);
+ for (let index = 1; index < edits.length; index++) {
+ if (edits[index].start < edits[index - 1].end) {
+ throw new Error('Overlapping multi-selection edits are not supported');
+ }
+ }
+
+ const change = textDocument.applyResolvedEdits(edits, true, selections);
+ const nextSelections = createSelectionsFromOffsetPairs(
+ textDocument,
+ nextOffsetPairs
+ );
+ textDocument.setLastUndoSelectionsAfter(nextSelections);
+ if (change !== undefined && lineAnnotations !== undefined) {
+ const nextLineAnnotations =
+ applyDocumentChangeToLineAnnotations(
+ change,
+ lineAnnotations
+ );
+ if (nextLineAnnotations !== undefined) {
+ textDocument.setLastUndoLineAnnotations(
+ lineAnnotations,
+ nextLineAnnotations
+ );
+ }
+ }
+ return { nextSelections, change };
+}
+
+/**
+ * Deletes from each selection to the end of its line, including the line break
+ * when the caret is already at the end of a non-final line. Non-collapsed
+ * selections delete their selected text instead.
+ */
+export function applyDeleteHardLineForwardToSelections(
+ textDocument: TextDocument,
+ selections: EditorSelection[],
+ lineAnnotations?: DiffLineAnnotation[]
+): {
+ nextSelections: EditorSelection[];
+ change?: TextDocumentChange;
+} {
+ const deleteSelections: EditorSelection[] = selections.map((selection) => {
+ const range = resolveDeleteHardLineForwardRange(textDocument, selection);
+ const deleteSelection: EditorSelection = {
+ start: range.start,
+ end: range.end,
+ direction: DirectionNone,
+ };
+ return deleteSelection;
+ });
+ const hasEffect = deleteSelections.some(
+ (selection) => comparePosition(selection.start, selection.end) !== 0
+ );
+ if (!hasEffect) {
+ return { nextSelections: selections };
+ }
+ return applyTextReplaceToSelections(
+ textDocument,
+ deleteSelections,
+ deleteSelections.map(() => ''),
+ lineAnnotations
+ );
+}
+
+/**
+ * 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.
+ focus = currentStartOrder === 0 ? 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,
+ };
+}
+
+export function extendSelections(
+ selections: EditorSelection[],
+ target: EditorSelection
+): EditorSelection[] {
+ const newSelections = selections.map((selection) => {
+ return extendSelection(selection, target);
+ });
+ return mergeOverlappingSelections(newSelections);
+}
+
+export function mergeOverlappingSelections(
+ selections: EditorSelection[]
+): EditorSelection[] {
+ if (selections.length <= 1) {
+ return selections;
+ }
+
+ const sortedSelections = [...selections].sort((a, b) => {
+ const startOrder = comparePosition(a.start, b.start);
+ if (startOrder !== 0) {
+ return startOrder;
+ }
+ return comparePosition(a.end, b.end);
+ });
+ const mergedSelections: EditorSelection[] = [];
+ for (const selection of sortedSelections) {
+ const previousSelection = mergedSelections.at(-1);
+ if (
+ previousSelection === undefined ||
+ !selectionIntersects(previousSelection, selection)
+ ) {
+ mergedSelections.push(selection);
+ continue;
+ }
+ mergedSelections[mergedSelections.length - 1] = mergeSelections(
+ previousSelection,
+ selection
+ );
+ }
+ return mergedSelections;
+}
+
+function mergeSelections(
+ a: EditorSelection,
+ b: EditorSelection
+): EditorSelection {
+ const start = comparePosition(a.start, b.start) <= 0 ? a.start : b.start;
+ const end = comparePosition(a.end, b.end) >= 0 ? a.end : b.end;
+ return {
+ start,
+ end,
+ direction: getMergedSelectionDirection(start, end, a, b),
+ };
+}
+
+// Choose a direction whose anchor is still one of the merged range endpoints.
+function getMergedSelectionDirection(
+ start: Position,
+ end: Position,
+ a: EditorSelection,
+ b: EditorSelection
+): SelectionDirection {
+ if (comparePosition(start, end) === 0) {
+ return DirectionNone;
+ }
+ return (
+ getSelectionBoundaryDirection(b, start, end) ??
+ getSelectionBoundaryDirection(a, start, end) ??
+ DirectionForward
+ );
+}
+
+function getSelectionBoundaryDirection(
+ selection: EditorSelection,
+ start: Position,
+ end: Position
+): SelectionDirection | undefined {
+ if (
+ selection.direction === DirectionForward &&
+ comparePosition(selection.start, start) === 0
+ ) {
+ return DirectionForward;
+ }
+ if (
+ selection.direction === DirectionBackward &&
+ comparePosition(selection.end, end) === 0
+ ) {
+ return DirectionBackward;
+ }
+ return undefined;
+}
+
+/**
+ * Finds the next matching word and updates the selections.
+ */
+export function findNexMatch(
+ textDocument: TextDocument,
+ selections: EditorSelection[]
+): EditorSelection[] | undefined {
+ if (selections.length === 0) {
+ return undefined;
+ }
+
+ const normalizedSelections = selections.map((selection) =>
+ isCollapsedSelection(selection)
+ ? expandCollapsedSelectionToWord(textDocument, selection)
+ : selection
+ );
+ const texts = normalizedSelections.map((s) => textDocument.getText(s));
+ const needle = texts[0];
+ if (needle.length === 0 || texts.some((t) => t !== needle)) {
+ return undefined;
+ }
+
+ const occupied = normalizedSelections.map(
+ (s) =>
+ [textDocument.offsetAt(s.start), textDocument.offsetAt(s.end)] as [
+ number,
+ number,
+ ]
+ );
+ const nextOffset = textDocument.findNextNonOverlappingSubstring(
+ needle,
+ occupied
+ );
+ if (nextOffset === undefined) {
+ return normalizedSelections.some((selection, index) => {
+ const original = selections[index];
+ return (
+ comparePosition(selection.start, original.start) !== 0 ||
+ comparePosition(selection.end, original.end) !== 0 ||
+ selection.direction !== original.direction
+ );
+ })
+ ? normalizedSelections
+ : undefined;
+ }
+ const added = createSelectionFromAnchorAndFocusOffsets(
+ textDocument,
+ nextOffset,
+ nextOffset + needle.length
+ );
+ return [...normalizedSelections, 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,
+ };
+}
+
+/**
+ * Get the text of the selections for the given text document.
+ */
+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');
+}
+
+/**
+ * Get the anchor node and offset for a selection.
+ */
+export function getSelectionAnchor(
+ lineElement: HTMLElement,
+ character: number
+): [Node, number] {
+ const ch = Math.max(0, character);
+ const tokens = collectTokens(lineElement);
+
+ let last: HTMLElement | null = null;
+ for (const token of tokens) {
+ last = token;
+ const base = getCharacterIndex(token)!;
+ const end = base + (token.textContent?.length ?? 0);
+ if (ch <= end) {
+ const anchor = textAt(token, ch < base ? 0 : ch - base);
+ if (anchor !== null) {
+ return anchor;
+ }
+ }
+ }
+
+ if (last !== null) {
+ const anchor = textAt(last, last.textContent?.length ?? 0);
+ if (anchor !== null) {
+ return anchor;
+ }
+ return [last, 0];
+ }
+
+ let textOffset = 0;
+ let lastTextNode: Text | null = null;
+ for (const child of lineElement.childNodes) {
+ if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
+ return [child, 0];
+ }
+ if (child.nodeType !== 3) {
+ continue;
+ }
+ lastTextNode = child as Text;
+ const len = getTextOffset(
+ lastTextNode.textContent,
+ lastTextNode.textContent?.length ?? 0
+ );
+ if (ch <= textOffset + len) {
+ return [
+ lastTextNode,
+ getTextOffset(lastTextNode.textContent, ch - textOffset),
+ ];
+ }
+ textOffset += len;
+ }
+
+ if (lastTextNode !== null) {
+ return [
+ lastTextNode,
+ getTextOffset(
+ lastTextNode.textContent,
+ lastTextNode.textContent?.length ?? 0
+ ),
+ ];
+ }
+ return [lineElement, 0];
+}
+
+/**
+ * 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;
+ // Match when the cursor is inside the word or immediately touching
+ // one of its boundaries — not when separated by non-word characters.
+ if (character >= lo && character <= hi) {
+ return { start: lo, end: hi };
+ }
+ }
+ return undefined;
+}
+
+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),
+ ];
+}
+
+// Resolves the range removed by deleteHardLineForward for one selection.
+function resolveDeleteHardLineForwardRange(
+ textDocument: TextDocument,
+ selection: EditorSelection
+): Range {
+ if (!isCollapsedSelection(selection)) {
+ return { start: selection.start, end: selection.end };
+ }
+ const { line, character } = selection.start;
+ const lineText = textDocument.getLineText(line);
+ const lineLength = lineText.length;
+ if (character < lineLength) {
+ return {
+ start: { line, character },
+ end: { line, character: lineLength },
+ };
+ }
+ if (line < textDocument.lineCount - 1) {
+ return {
+ start: { line, character },
+ end: { line: line + 1, character: 0 },
+ };
+ }
+ return {
+ start: { line, character },
+ end: { line, character },
+ };
+}
+
+// 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);
+}
+
+function createSelectionsFromOffsetPairs(
+ textDocument: TextDocument,
+ offsetPairs: readonly [anchorOffset: number, focusOffset: number][]
+): EditorSelection[] {
+ const normalizedOffsets: number[] = [];
+ for (const [anchorOffset, focusOffset] of offsetPairs) {
+ normalizedOffsets.push(
+ Math.min(anchorOffset, focusOffset),
+ Math.max(anchorOffset, focusOffset)
+ );
+ }
+ const positions = textDocument.positionsAt(normalizedOffsets);
+ return offsetPairs.map(([anchorOffset, focusOffset], index) => {
+ const direction =
+ anchorOffset === focusOffset
+ ? DirectionNone
+ : anchorOffset < focusOffset
+ ? DirectionForward
+ : DirectionBackward;
+ return {
+ start: positions[index * 2],
+ end: positions[index * 2 + 1],
+ direction,
+ };
+ });
+}
+
+// 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 {
+ const host = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement;
+ let lineEl: HTMLElement | null = host;
+ while (lineEl !== null && getLineIndex(lineEl) === undefined) {
+ lineEl = lineEl.parentElement;
+ }
+ if (lineEl === null) {
+ return null;
+ }
+ const line = getLineIndex(lineEl);
+ if (line === undefined) {
+ return null;
+ }
+
+ if (node.nodeType === 3) {
+ if (node.parentElement === null) {
+ return null;
+ }
+ if (findTokenSpan(node.parentElement) !== null) {
+ return { line, character: getLineChildEnd(node, offset) };
+ }
+ return {
+ line,
+ character:
+ offsetBefore(lineEl, node) + getTextOffset(node.textContent, offset),
+ };
+ }
+
+ if (node.nodeType === 1) {
+ const el = node as HTMLElement;
+ if (el.tagName === 'DIV') {
+ let character = 0;
+ for (let i = 0; i < offset; i++) {
+ character = getLineChildEnd(el.childNodes[i]);
+ }
+ return { line, character };
+ }
+ if (el.tagName === 'BR') {
+ return { line, character: 0 };
+ }
+ if (el.tagName === 'SPAN') {
+ if (offset < el.childNodes.length) {
+ const next = el.childNodes[offset];
+ if (next?.nodeType === 1) {
+ const nextBase = getCharacterIndex(next as HTMLElement);
+ if (nextBase !== undefined) {
+ return { line, character: nextBase };
+ }
+ const token = findTokenSpan(next as HTMLElement);
+ const tokenBase =
+ token === null ? undefined : getCharacterIndex(token);
+ if (tokenBase !== undefined) {
+ return { line, character: tokenBase };
+ }
+ }
+ }
+ return {
+ line,
+ character:
+ offset > 0
+ ? getLineChildEnd(el.childNodes[offset - 1])
+ : offsetBefore(lineEl, el),
+ };
+ }
+ return { line, character: offsetBefore(lineEl, el) };
+ }
+ return null;
+}
+
+function collectTokens(line: HTMLElement): HTMLElement[] {
+ const tokens: HTMLElement[] = [];
+ for (const child of line.childNodes) {
+ if (child.nodeType !== 1) {
+ continue;
+ }
+ const el = child as HTMLElement;
+ if (el.tagName !== 'SPAN') {
+ continue;
+ }
+ const base = getCharacterIndex(el);
+ if (base !== undefined) {
+ tokens.push(el);
+ continue;
+ }
+ for (const nested of el.childNodes) {
+ if (
+ nested.nodeType === 1 &&
+ getCharacterIndex(nested as HTMLElement) !== undefined
+ ) {
+ tokens.push(nested as HTMLElement);
+ }
+ }
+ }
+ return tokens;
+}
+
+function textAt(token: HTMLElement, offset: number): [Node, number] | null {
+ let remaining = Math.max(0, offset);
+ const stack: Array<{ container: Node; index: number }> = [
+ { container: token, index: 0 },
+ ];
+ while (stack.length > 0) {
+ const frame = stack[stack.length - 1];
+ if (frame.index >= frame.container.childNodes.length) {
+ stack.pop();
+ continue;
+ }
+ const walkNode = frame.container.childNodes[frame.index];
+ frame.index++;
+ if (walkNode.nodeType === 3) {
+ const len = getTextOffset(
+ walkNode.textContent,
+ walkNode.textContent?.length ?? 0
+ );
+ if (remaining <= len) {
+ return [walkNode, remaining];
+ }
+ remaining -= len;
+ } else if (walkNode.nodeType === 1) {
+ stack.push({ container: walkNode, index: 0 });
+ }
+ }
+ return null;
+}
+
+function textLengthBefore(root: Node, target: Node): number {
+ let before = 0;
+ const stack: Array<{ container: Node; index: number }> = [
+ { container: root, index: 0 },
+ ];
+ while (stack.length > 0) {
+ const frame = stack[stack.length - 1];
+ if (frame.index >= frame.container.childNodes.length) {
+ stack.pop();
+ continue;
+ }
+ const walkNode = frame.container.childNodes[frame.index];
+ if (walkNode === target) {
+ return before;
+ }
+ frame.index++;
+ if (walkNode.nodeType === 3) {
+ before += getTextOffset(
+ walkNode.textContent,
+ walkNode.textContent?.length ?? 0
+ );
+ } else if (walkNode.nodeType === 1) {
+ stack.push({ container: walkNode, index: 0 });
+ }
+ }
+ return before;
+}
+
+function isInside(token: HTMLElement, node: Node): boolean {
+ let current: Node | null = node;
+ while (current !== null) {
+ if (current === token) {
+ return true;
+ }
+ current = current.parentElement;
+ }
+ return false;
+}
+
+function offsetBefore(line: HTMLElement, node: Node): number {
+ if (node.parentElement === line) {
+ let offset = 0;
+ const index = Array.prototype.indexOf.call(line.childNodes, node);
+ for (let i = 0; i < index; i++) {
+ offset = getLineChildEnd(line.childNodes[i]);
+ }
+ return offset;
+ }
+ for (const token of collectTokens(line)) {
+ if (isInside(token, node)) {
+ const base = getCharacterIndex(token)!;
+ return base + (node.nodeType === 3 ? textLengthBefore(token, node) : 0);
+ }
+ }
+ let offset = 0;
+ let target: HTMLElement | null =
+ node.nodeType === 1 ? (node as HTMLElement) : node.parentElement;
+ while (target !== null && target.parentElement !== null) {
+ if (getLineIndex(target.parentElement) !== undefined) {
+ break;
+ }
+ const parent = target.parentElement;
+ const index = Array.prototype.indexOf.call(parent.childNodes, target);
+ for (let i = 0; i < index; i++) {
+ offset = getLineChildEnd(parent.childNodes[i]);
+ }
+ target = parent;
+ }
+ return offset;
+}
+
+function findTokenSpan(el: HTMLElement): HTMLElement | null {
+ let current: HTMLElement | null = el;
+ while (current !== null) {
+ if (getLineIndex(current) !== undefined) {
+ return null;
+ }
+ if (getCharacterIndex(current) !== undefined) {
+ return current;
+ }
+ current = current.parentElement;
+ }
+ return null;
+}
+
+function getLineChildEnd(
+ child: Node | undefined,
+ textOffsetInChild?: number
+): number {
+ if (child === undefined) {
+ return 0;
+ }
+ if (child.nodeType === 3) {
+ const parent = child.parentElement;
+ if (parent === null) {
+ return 0;
+ }
+ const token = findTokenSpan(parent);
+ if (token === null) {
+ return 0;
+ }
+ const base = getCharacterIndex(token);
+ if (base === undefined) {
+ return 0;
+ }
+ const length =
+ textOffsetInChild === undefined
+ ? getTextOffset(child.textContent, child.textContent?.length ?? 0)
+ : getTextOffset(child.textContent, textOffsetInChild);
+ return base + textLengthBefore(token, child) + length;
+ }
+ if (child.nodeType !== 1) {
+ return 0;
+ }
+ const el = child as HTMLElement;
+ if (el.tagName !== 'SPAN' && el.tagName !== 'BR') {
+ return 0;
+ }
+ const base = getCharacterIndex(el);
+ if (base !== undefined) {
+ return base + (el.textContent?.length ?? 0);
+ }
+ let end = 0;
+ for (const token of el.childNodes) {
+ end = Math.max(end, getLineChildEnd(token));
+ }
+ return end;
+}
+
+function getLineIndex(el: HTMLElement): number | undefined {
+ const { line } = el.dataset;
+ if (line !== undefined) {
+ return parseInt(line) - 1;
+ }
+ return 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/textDocument.ts b/packages/diffs/src/editor/textDocument.ts
new file mode 100644
index 000000000..d8ff9e87b
--- /dev/null
+++ b/packages/diffs/src/editor/textDocument.ts
@@ -0,0 +1,438 @@
+import type { DiffLineAnnotation } from '../types';
+import {
+ coalesceEditStackEntries,
+ createEditStackEntry,
+ EditStack,
+ shouldCoalesceEditStackEntry,
+} from './editStack';
+import { PieceTable } from './pieceTable';
+import type { SearchParams } from './searchPanel';
+import { type EditorSelection } from './selection';
+
+/**
+ * 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;
+ /** Exact rendered line ranges touched by each edit after the edit was applied. */
+ readonly changedLineRanges?: readonly [startLine: number, endLine: 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);
+ }
+
+ positionsAt(offsets: readonly number[]): Position[] {
+ return this.#pieceTable.positionsAt(offsets);
+ }
+
+ offsetAt(position: Position): number {
+ return this.#pieceTable.offsetAt(position);
+ }
+
+ offsetsAt(positions: readonly Position[]): number[] {
+ return this.#pieceTable.offsetsAt(positions);
+ }
+
+ 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);
+ }
+
+ search(
+ kind: 'findNext' | 'findPrevious' | 'findAll' | 'replace' | 'replaceAll',
+ searchParams: SearchParams,
+ selection?: Range
+ ): [start: number, end: number][] {
+ return this.#pieceTable.search(kind, searchParams, selection);
+ }
+
+ applyEdits(
+ edits: TextEdit[],
+ updateHistory = false,
+ selectionsBefore?: EditorSelection[],
+ selectionsAfter?: EditorSelection[]
+ ): TextDocumentChange | undefined {
+ if (edits.length === 0) {
+ return;
+ }
+ return this.applyResolvedEdits(
+ edits.map((edit) => this.#resolveEdit(edit)),
+ updateHistory,
+ selectionsBefore,
+ selectionsAfter
+ );
+ }
+
+ applyResolvedEdits(
+ edits: ResolvedTextEdit[],
+ updateHistory = false,
+ selectionsBefore?: EditorSelection[],
+ selectionsAfter?: EditorSelection[]
+ ): TextDocumentChange | undefined {
+ if (edits.length === 0) {
+ return undefined;
+ }
+ const resolvedEdits = this.#sortAndValidateResolvedEdits(edits);
+ if (updateHistory) {
+ const entry = createEditStackEntry(
+ this,
+ resolvedEdits,
+ this.#version,
+ this.#version + 1,
+ selectionsBefore,
+ selectionsAfter
+ );
+ const previousEntry = this.#editStack.peekUndo();
+ const change = this.#applyResolvedEditsToBuffer(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.#applyResolvedEditsToBuffer(resolvedEdits);
+ this.#version++;
+ return change;
+ }
+
+ setLastUndoSelectionsAfter(selections: EditorSelection[]): void {
+ this.#editStack.setLastUndoSelectionsAfter(selections);
+ }
+
+ setLastUndoLineAnnotations(
+ lineAnnotationsBefore: DiffLineAnnotation[],
+ lineAnnotationsAfter: DiffLineAnnotation[]
+ ): void {
+ this.#editStack.setLastUndoLineAnnotations(
+ lineAnnotationsBefore,
+ lineAnnotationsAfter
+ );
+ }
+
+ undo():
+ | [
+ change: TextDocumentChange,
+ selections?: EditorSelection[],
+ lineAnnotations?: DiffLineAnnotation[],
+ ]
+ | undefined {
+ const entry = this.#editStack.popUndoToRedo();
+ if (entry === undefined) {
+ return undefined;
+ }
+ const change = this.#applyResolvedEditsToBuffer(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?: DiffLineAnnotation[],
+ ]
+ | undefined {
+ const entry = this.#editStack.popRedoToUndo();
+ if (entry === undefined) {
+ return undefined;
+ }
+ const change = this.#applyResolvedEditsToBuffer(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;
+ }
+
+ #applyResolvedEditsToBuffer(edits: ResolvedTextEdit[]): TextDocumentChange {
+ const previousLineCount = this.#pieceTable.lineCount;
+ const editPositions = this.positionsAt(
+ edits.flatMap((edit) => [edit.start, edit.end])
+ );
+ const changedLineRange = this.#computeChangedLineRange(
+ edits,
+ editPositions
+ );
+ const startPosition = editPositions[0];
+ this.#pieceTable.applyEdits(edits);
+ 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,
+ changedLineRanges: changedLineRange.ranges,
+ };
+ return change;
+ }
+
+ #computeChangedLineRange(
+ edits: ResolvedTextEdit[],
+ editPositions: Position[]
+ ): {
+ startLine: number;
+ endLine: number;
+ ranges: [number, number][];
+ } {
+ let startLine = Infinity;
+ let endLine = 0;
+ let lineDeltaBeforeEdit = 0;
+ const ranges: [number, number][] = [];
+ for (let i = 0; i < edits.length; i++) {
+ const edit = edits[i];
+ const editStartLine = editPositions[i * 2].line;
+ const editEndLine = editPositions[i * 2 + 1].line;
+ const insertedLineSpan = lineFeedCount(edit.text);
+ const changedStartLine = editStartLine + lineDeltaBeforeEdit;
+ const changedEndLine = changedStartLine + insertedLineSpan;
+ startLine = Math.min(startLine, editStartLine);
+ endLine = Math.max(endLine, changedEndLine);
+ const lastRange = ranges[ranges.length - 1];
+ if (lastRange !== undefined && changedStartLine <= lastRange[1] + 1) {
+ ranges[ranges.length - 1] = [
+ lastRange[0],
+ Math.max(lastRange[1], changedEndLine),
+ ];
+ } else {
+ ranges.push([changedStartLine, changedEndLine]);
+ }
+ lineDeltaBeforeEdit += insertedLineSpan - (editEndLine - editStartLine);
+ }
+ if (startLine === Infinity) {
+ return {
+ startLine: 0,
+ endLine: 0,
+ ranges: [[0, 0]],
+ };
+ }
+ return { startLine, endLine, ranges };
+ }
+}
+
+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/textMeasure.ts b/packages/diffs/src/editor/textMeasure.ts
new file mode 100644
index 000000000..24c270017
--- /dev/null
+++ b/packages/diffs/src/editor/textMeasure.ts
@@ -0,0 +1,182 @@
+import { h, round } from './utils';
+
+export class Metrics {
+ #root?: HTMLElement;
+ #canvasCtx?: CanvasRenderingContext2D;
+ #font?: string;
+
+ /** Width of the '0' character. */
+ ch: number = -1;
+ /** Size of a tab(\t) character. */
+ tabSize: number = 2;
+ /** Height of the code line. */
+ lineHeight: number = 20;
+
+ /** initialize the metrics */
+ init(root: HTMLElement): void {
+ if (
+ this.#root === root &&
+ this.#canvasCtx !== undefined &&
+ this.ch !== -1
+ ) {
+ // already initialized
+ return;
+ }
+
+ this.#root = root;
+ this.#canvasCtx ??=
+ document.createElement('canvas').getContext('2d') ?? undefined;
+ if (this.#canvasCtx === undefined) {
+ throw new Error('Could not get canvas context');
+ }
+
+ const { fontSize, fontFamily, tabSize, lineHeight } =
+ getComputedStyle(root);
+ if (lineHeight.endsWith('px')) {
+ this.lineHeight = Number(lineHeight.slice(0, -2));
+ } else if (fontSize.endsWith('px')) {
+ this.lineHeight = round(
+ Number(fontSize.slice(0, -2)) * Number(lineHeight)
+ );
+ }
+ const font = fontSize + ' ' + fontFamily;
+ if (this.#font !== font || this.ch === -1) {
+ this.#font = font;
+ this.#canvasCtx.font = font;
+ this.ch = this.canvasMeasureTextWidth('0');
+ }
+ this.tabSize = Number(tabSize);
+ }
+
+ /** measure the width of the text */
+ measureTextWidth(text: string): number {
+ const textWithExpandedTabs = text.replaceAll(
+ '\t',
+ ' '.repeat(this.tabSize)
+ );
+ if (needsDomTextMeasurement(textWithExpandedTabs)) {
+ return this.domMeasureTextWidth(textWithExpandedTabs);
+ }
+ return this.canvasMeasureTextWidth(textWithExpandedTabs);
+ }
+
+ /** measure the width of the text using the canvas measureText API */
+ canvasMeasureTextWidth(text: string): number {
+ if (this.#canvasCtx === undefined) {
+ throw new Error('Metrics not initialized');
+ }
+ return round(this.#canvasCtx.measureText(text).width);
+ }
+
+ /**
+ * measure the width of the text using the DOM
+ * this is slow because it cause a reflow, use it for non-ascii text
+ */
+ domMeasureTextWidth(text: string): number {
+ if (this.#root === undefined) {
+ throw new Error('Metrics not initialized');
+ }
+ const measureEl = h(
+ 'span',
+ {
+ style: {
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ visibility: 'hidden',
+ pointerEvents: 'none',
+ whiteSpace: 'pre',
+ font: 'inherit',
+ },
+ textContent: text,
+ },
+ this.#root
+ );
+ try {
+ return measureEl.getBoundingClientRect().width;
+ } finally {
+ measureEl.remove();
+ }
+ }
+}
+
+/** Check if the text needs DOM text measurement. */
+export function needsDomTextMeasurement(text: string): boolean {
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i);
+ if (
+ (code >= 0xd800 && code <= 0xdfff) ||
+ code === 0x200d ||
+ code === 0xfe0e ||
+ code === 0xfe0f
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/** snap the text offset to the Unicode boundary */
+export function snapTextOffsetToUnicodeBoundary(
+ text: string,
+ offset: number
+): number {
+ const boundedOffset = Math.max(0, Math.min(offset, text.length));
+ if (
+ boundedOffset === 0 ||
+ boundedOffset === text.length ||
+ !needsDomTextMeasurement(text)
+ ) {
+ return boundedOffset;
+ }
+ // Avoid measuring a caret position inside one visual emoji/grapheme.
+ // Browser caret movement can report offsets around UTF-16 surrogate
+ // pairs and emoji joiners; measuring a partial sequence gives a
+ // replacement-glyph width.
+ const segmenter = new Intl.Segmenter(undefined, {
+ granularity: 'grapheme',
+ });
+ for (const segment of segmenter.segment(text)) {
+ const segmentStart = segment.index;
+ const segmentEnd = segmentStart + segment.segment.length;
+ if (boundedOffset > segmentStart && boundedOffset < segmentEnd) {
+ return segmentEnd;
+ }
+ if (boundedOffset <= segmentStart) {
+ break;
+ }
+ }
+ return boundedOffset;
+}
+
+/** get the offsets of the Unicode grapheme clusters in the text */
+export function getUnicodeMeasurementOffsets(
+ text: string
+): number[] | undefined {
+ if (!needsDomTextMeasurement(text)) {
+ return undefined;
+ }
+ const offsets = [0];
+ const segmenter = new Intl.Segmenter(undefined, {
+ granularity: 'grapheme',
+ });
+ for (const segment of segmenter.segment(text)) {
+ offsets.push(segment.index + segment.segment.length);
+ }
+ return offsets;
+}
+
+/** get the number of columns of the ASCII text */
+export function getExpandedAsciiTextColumns(
+ text: string,
+ tabSize: number
+): number {
+ let columns = 0;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) > 127) {
+ return -1;
+ }
+ columns += text.charCodeAt(i) === /* '\t' */ 9 ? tabSize : 1;
+ }
+ return columns;
+}
diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts
new file mode 100644
index 000000000..89c13de76
--- /dev/null
+++ b/packages/diffs/src/editor/tokenzier.ts
@@ -0,0 +1,580 @@
+import {
+ EncodedTokenMetadata,
+ type IGrammar,
+ INITIAL,
+ type StateStack,
+} from 'shiki/textmate';
+
+import { DEFAULT_THEMES } from '../constants';
+import type {
+ BaseCodeOptions,
+ DiffsHighlighter,
+ HighlightedToken,
+ RenderRange,
+ ThemesType,
+} from '../types';
+import type { TextDocument, TextDocumentChange } from './textDocument';
+import { addEventListener, debounce, h } from './utils';
+
+export interface EditorTokenizerProps {
+ highlighter: DiffsHighlighter;
+ textDocument: TextDocument;
+ codeOptions: BaseCodeOptions;
+ onDeferTokenize: (
+ lines: Map>,
+ themeType: 'dark' | 'light'
+ ) => void;
+}
+
+/** Stoppable code tokenizer for the editor */
+export class EditorTokenizer {
+ static TOKENIZE_TIME_LIMIT = 500;
+
+ #highlighter: DiffsHighlighter;
+ #grammar: IGrammar | undefined;
+ #mediaQueryList: MediaQueryList;
+ #themeType: 'light' | 'dark';
+ #colorMap: string[];
+ #textDocument: TextDocument;
+ #tokenizeMaxLineLength: number;
+ #onDeferTokenize: EditorTokenizerProps['onDeferTokenize'];
+ #editorEventDisposes?: (() => void)[];
+
+ // state
+ #stateStackCache: StateStack[] = [INITIAL];
+ #lastLine: number = -1;
+ #isStopped: boolean = true;
+ #backgroundJobId: number = 0;
+ #backgroundChangedLineRanges: readonly [number, number][] | undefined;
+ #backgroundChangedRangeIndex: number = 0;
+
+ #prebuildStateStackMap = debounce(async (renderRange?: RenderRange) => {
+ const { startingLine = 0, totalLines = Infinity } = renderRange ?? {};
+ const endLine = Math.min(
+ totalLines === Infinity ? Infinity : startingLine + totalLines,
+ this.#textDocument.lineCount
+ );
+ if (this.#grammar === undefined) {
+ await this.#highlighter.loadLanguage(this.#textDocument.languageId);
+ this.#grammar = this.#highlighter.getLanguage(
+ this.#textDocument.languageId
+ );
+ }
+ this.#buildStateStackMap(endLine);
+ }, 500);
+
+ #onMessage = ({
+ data,
+ }: MessageEvent<{ type: 'tokenize'; jobId: number }>) => {
+ if (data.type === 'tokenize' && data.jobId === this.#backgroundJobId) {
+ this.#backgroundTokenize(data.jobId);
+ }
+ };
+
+ // By default, diffs components support dual themes, but the tokenizer only renders
+ // the preferred theme. When the theme type is changed, the tokenizer will re-tokenize the document.
+ #onThemeChange = (themeName: string, themeType: 'light' | 'dark') => {
+ this.#themeType = themeType;
+ this.#colorMap = this.#highlighter.setTheme(themeName).colorMap;
+ this.stopBackgroundTokenize();
+ this.#stateStackCache = [INITIAL];
+ if (this.#grammar !== undefined && this.#textDocument.lineCount > 0) {
+ this.#scheduleBackgroundTokenize(0);
+ }
+ };
+
+ #watchColorScheme = (theme: ThemesType) => {
+ const observer = new MutationObserver((mutations) => {
+ for (const { type, attributeName } of mutations) {
+ if (
+ type === 'attributes' &&
+ attributeName !== null &&
+ (attributeName === 'class' || attributeName.startsWith('data-'))
+ ) {
+ const themeType =
+ getComputedStyle(document.body).colorScheme === 'dark'
+ ? 'dark'
+ : 'light';
+ this.#onThemeChange(theme[themeType], themeType);
+ break;
+ }
+ }
+ });
+ observer.observe(document.documentElement, { attributes: true });
+ observer.observe(document.body, { attributes: true });
+ this.#editorEventDisposes = [
+ addEventListener(this.#mediaQueryList, 'change', (e) => {
+ const themeType = e.matches ? 'dark' : 'light';
+ this.#onThemeChange(theme[themeType], themeType);
+ }),
+ () => observer.disconnect(),
+ ];
+ };
+
+ get themeType(): 'light' | 'dark' {
+ return this.#themeType;
+ }
+
+ constructor({
+ codeOptions,
+ highlighter,
+ textDocument,
+ onDeferTokenize,
+ }: EditorTokenizerProps) {
+ const {
+ themeType = 'system',
+ theme = DEFAULT_THEMES,
+ tokenizeMaxLineLength = 1000,
+ } = codeOptions;
+ this.#mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
+ if (themeType === 'system') {
+ this.#themeType = this.#mediaQueryList.matches ? 'dark' : 'light';
+ } else {
+ this.#themeType = themeType;
+ }
+ if (typeof theme !== 'string') {
+ this.#watchColorScheme(theme);
+ }
+ const themeName =
+ typeof theme === 'string' ? theme : theme[this.#themeType];
+ this.#colorMap = highlighter.setTheme(themeName).colorMap;
+ this.#highlighter = highlighter;
+ this.#textDocument = textDocument;
+ this.#tokenizeMaxLineLength = tokenizeMaxLineLength;
+ this.#onDeferTokenize = onDeferTokenize;
+ if (highlighter.getLoadedLanguages().includes(textDocument.languageId)) {
+ this.#grammar = highlighter.getLanguage(textDocument.languageId);
+ }
+ }
+
+ cleanUp(): void {
+ this.stopBackgroundTokenize();
+ this.#editorEventDisposes?.forEach((dispose) => dispose());
+ this.#editorEventDisposes = undefined;
+ }
+
+ // to use `tokenize`, call `prebuildStateStackMap` first to prebuild
+ // the state stack map for the given render range.
+ tokenize(
+ change: TextDocumentChange,
+ renderRange?: RenderRange
+ ): Map> {
+ if (this.#grammar === undefined) {
+ throw new Error('Grammar not loaded');
+ }
+
+ const { lineCount } = this.#textDocument;
+ const { startingLine = 0, totalLines = Infinity } = renderRange ?? {};
+ const renderRangeEndLine =
+ totalLines === Infinity
+ ? lineCount
+ : Math.min(startingLine + totalLines, lineCount);
+
+ const dirtyStart = change.startLine;
+ const viewStart = Math.max(startingLine, dirtyStart);
+ const crossesRenderRangeEnd =
+ renderRange !== undefined &&
+ totalLines !== Infinity &&
+ change.lineDelta > 0 &&
+ dirtyStart < renderRangeEndLine &&
+ change.endLine >= renderRangeEndLine;
+ const canReuseCachedStates = change.lineDelta === 0;
+ const canCacheTokenizedStates =
+ canReuseCachedStates ||
+ renderRange === undefined ||
+ dirtyStart >= viewStart;
+ const changedLineRanges: readonly [number, number][] = canReuseCachedStates
+ ? (change.changedLineRanges ?? [[dirtyStart, change.endLine]])
+ : [[dirtyStart, change.endLine]];
+ let offscreenSyncEnd = -1;
+ if (dirtyStart < viewStart) {
+ for (const [rangeStart, rangeEnd] of changedLineRanges) {
+ if (rangeStart < viewStart) {
+ offscreenSyncEnd = Math.max(
+ offscreenSyncEnd,
+ Math.min(rangeEnd, viewStart - 1)
+ );
+ }
+ }
+ }
+ const shouldFlushOffscreenLines =
+ offscreenSyncEnd >= dirtyStart &&
+ (canReuseCachedStates || change.lineDelta < 0);
+ if (canReuseCachedStates) {
+ this.#buildStateStackMap(dirtyStart);
+ } else {
+ this.#stateStackCache.length = Math.min(
+ this.#stateStackCache.length,
+ dirtyStart + 1
+ );
+ if (renderRange === undefined || dirtyStart >= viewStart) {
+ this.#buildStateStackMap(viewStart);
+ }
+ }
+
+ let changedRangeIndex = 0;
+ let currentChangedRangeEnd = changedLineRanges[changedRangeIndex][1];
+ let backgroundStartLine: number | undefined;
+ let backgroundChangedRangeIndex = 0;
+ let line = canReuseCachedStates
+ ? changedLineRanges[changedRangeIndex][0]
+ : viewStart;
+ let state = this.#stateStackCache[line] ?? INITIAL;
+ let settled = false;
+ const dirtyLines: Map> = new Map();
+ const offscreenDirtyLines:
+ | Map>
+ | undefined = shouldFlushOffscreenLines ? new Map() : undefined;
+ if (offscreenDirtyLines !== undefined && !canReuseCachedStates) {
+ const offscreenEnd = Math.min(
+ offscreenSyncEnd + 1,
+ viewStart,
+ renderRangeEndLine
+ );
+ if (offscreenEnd > dirtyStart) {
+ this.#buildStateStackMap(offscreenEnd);
+ let offscreenLine = dirtyStart;
+ let offscreenState = this.#stateStackCache[offscreenLine] ?? INITIAL;
+ for (; offscreenLine < offscreenEnd; offscreenLine++) {
+ const resolved = this.#tokenizeLineAt(offscreenLine, offscreenState);
+ offscreenState = resolved.state;
+ offscreenDirtyLines.set(offscreenLine, resolved.resolvedTokens);
+ }
+ if (canCacheTokenizedStates) {
+ this.#stateStackCache[offscreenEnd] = offscreenState;
+ }
+ }
+ }
+ for (; line < renderRangeEndLine; ) {
+ const previousNextState = canReuseCachedStates
+ ? this.#stateStackCache[line + 1]
+ : undefined;
+ if (canCacheTokenizedStates) {
+ this.#stateStackCache[line] = state;
+ }
+
+ const { resolvedTokens, state: nextState } = this.#tokenizeLineAt(
+ line,
+ state
+ );
+ state = nextState;
+
+ if (line >= viewStart) {
+ dirtyLines.set(line, resolvedTokens);
+ } else {
+ offscreenDirtyLines?.set(line, resolvedTokens);
+ }
+
+ if (canCacheTokenizedStates) {
+ this.#stateStackCache[line + 1] = state;
+ }
+ settled =
+ line >= currentChangedRangeEnd &&
+ canReuseCachedStates &&
+ previousNextState !== undefined &&
+ state.equals(previousNextState);
+ if (settled) {
+ changedRangeIndex++;
+ const nextRange = changedLineRanges[changedRangeIndex];
+ if (nextRange === undefined) {
+ break;
+ }
+ if (nextRange[0] >= renderRangeEndLine) {
+ backgroundStartLine = nextRange[0];
+ backgroundChangedRangeIndex = changedRangeIndex;
+ break;
+ }
+ if (this.#stateStackCache[nextRange[0]] === undefined) {
+ currentChangedRangeEnd = nextRange[1];
+ line++;
+ } else {
+ line = nextRange[0];
+ state = this.#stateStackCache[line] ?? state;
+ currentChangedRangeEnd = nextRange[1];
+ }
+ settled = false;
+ continue;
+ }
+ line++;
+ }
+
+ if (canCacheTokenizedStates) {
+ if (line < renderRangeEndLine) {
+ this.#stateStackCache[line + 1] = state;
+ } else {
+ this.#stateStackCache[line] = state;
+ }
+ }
+
+ if (offscreenDirtyLines !== undefined && offscreenDirtyLines.size > 0) {
+ this.#onDeferTokenize(offscreenDirtyLines, this.#themeType);
+ }
+
+ if (backgroundStartLine !== undefined) {
+ this.#scheduleBackgroundTokenize(
+ backgroundStartLine,
+ changedLineRanges,
+ backgroundChangedRangeIndex
+ );
+ } else if (!settled && line < lineCount) {
+ const backgroundLine =
+ crossesRenderRangeEnd && dirtyStart >= viewStart
+ ? renderRangeEndLine
+ : dirtyStart < viewStart && !canReuseCachedStates
+ ? dirtyStart
+ : line;
+ this.#scheduleBackgroundTokenize(
+ backgroundLine,
+ canReuseCachedStates ? changedLineRanges : undefined,
+ changedRangeIndex
+ );
+ }
+
+ return dirtyLines;
+ }
+
+ prebuildStateStackMap(renderRange?: RenderRange): void {
+ this.#prebuildStateStackMap(renderRange);
+ }
+
+ stopBackgroundTokenize(): void {
+ removeEventListener('message', this.#onMessage);
+ this.#isStopped = true;
+ this.#lastLine = -1;
+ this.#backgroundChangedLineRanges = undefined;
+ this.#backgroundChangedRangeIndex = 0;
+ }
+
+ #scheduleBackgroundTokenize(
+ startLine: number,
+ changedLineRanges?: readonly [number, number][],
+ changedRangeIndex = 0
+ ): void {
+ const jobId = ++this.#backgroundJobId;
+
+ this.#isStopped = false;
+ this.#lastLine = startLine;
+ this.#backgroundChangedLineRanges = changedLineRanges;
+ this.#backgroundChangedRangeIndex = changedRangeIndex;
+
+ globalThis.addEventListener('message', this.#onMessage);
+ this.#postBackgroundTokenizeMessage(jobId);
+ }
+
+ #postBackgroundTokenizeMessage(jobId: number): void {
+ // use `postMessage` instead of `setTimeout(fn, 0)` to avoid 4ms delay
+ globalThis.postMessage({ type: 'tokenize', jobId });
+ }
+
+ #tokenizeLineAt(
+ line: number,
+ state: StateStack
+ ): { resolvedTokens: Array; state: StateStack } {
+ if (this.#grammar === undefined) {
+ throw new Error('Grammar not loaded');
+ }
+ const lineText = this.#textDocument.getLineText(line);
+ if (lineText.length > this.#tokenizeMaxLineLength) {
+ console.warn(
+ `[diffs] Line(${line}) too long to tokenize: ${lineText.length}`
+ );
+ return { resolvedTokens: [[0, '', lineText]], state };
+ }
+ if (lineText === '' || lineText.trim() === '') {
+ return { resolvedTokens: [[0, '', lineText]], state };
+ }
+ const result = tokenizeLine(
+ this.#grammar,
+ this.#colorMap,
+ lineText,
+ state,
+ EditorTokenizer.TOKENIZE_TIME_LIMIT
+ );
+ return {
+ resolvedTokens: result.resolvedTokens,
+ state: result.ruleStack,
+ };
+ }
+
+ #buildStateStackMap(endAt: number) {
+ const boundedEndAt = Math.min(
+ Math.max(0, endAt),
+ this.#textDocument.lineCount
+ );
+ if (
+ this.#stateStackCache.length > boundedEndAt ||
+ this.#grammar === undefined
+ ) {
+ return;
+ }
+ let line = this.#stateStackCache.length - 1;
+ let state = this.#stateStackCache[line] ?? INITIAL;
+ for (; line < boundedEndAt; line++) {
+ this.#stateStackCache[line] = state;
+ const lineText = this.#textDocument.getLineText(line);
+ if (
+ lineText.length <= this.#tokenizeMaxLineLength &&
+ lineText !== '' &&
+ lineText.trim() !== ''
+ ) {
+ state = this.#grammar.tokenizeLine2(
+ lineText,
+ state,
+ EditorTokenizer.TOKENIZE_TIME_LIMIT
+ ).ruleStack;
+ }
+ }
+ this.#stateStackCache[line] = state;
+ }
+
+ #backgroundTokenize(jobId: number) {
+ if (
+ this.#isStopped ||
+ this.#grammar === undefined ||
+ jobId !== this.#backgroundJobId
+ ) {
+ return;
+ }
+
+ const t = performance.now();
+ const lines = new Map>();
+ const totalLines = this.#textDocument.lineCount;
+ const changedLineRanges = this.#backgroundChangedLineRanges;
+
+ let line = this.#lastLine;
+ let state = this.#stateStackCache[line] ?? INITIAL;
+ let settled = false;
+ let changedRangeIndex = this.#backgroundChangedRangeIndex;
+ let currentChangedRangeEnd = changedLineRanges?.[changedRangeIndex]?.[1];
+ for (; line < totalLines; ) {
+ this.#stateStackCache[line] = state;
+
+ const previousNextState =
+ currentChangedRangeEnd !== undefined
+ ? this.#stateStackCache[line + 1]
+ : undefined;
+ const lineText = this.#textDocument.getLineText(line);
+ if (lineText.length > this.#tokenizeMaxLineLength) {
+ console.warn(
+ `[diffs] Line(${line}) too long to tokenize: ${lineText.length}`
+ );
+ lines.set(line, [[0, '', lineText]]);
+ } else if (lineText === '' || lineText.trim() === '') {
+ lines.set(line, [[0, '', lineText]]);
+ } else {
+ const ret = tokenizeLine(
+ this.#grammar,
+ this.#colorMap,
+ lineText,
+ state,
+ EditorTokenizer.TOKENIZE_TIME_LIMIT
+ );
+ lines.set(line, ret.resolvedTokens);
+ state = ret.ruleStack;
+ }
+
+ this.#stateStackCache[line + 1] = state;
+ settled =
+ currentChangedRangeEnd !== undefined &&
+ line >= currentChangedRangeEnd &&
+ previousNextState !== undefined &&
+ state.equals(previousNextState);
+ line++;
+ if (settled) {
+ changedRangeIndex++;
+ const nextRange = changedLineRanges?.[changedRangeIndex];
+ if (nextRange === undefined) {
+ break;
+ }
+ currentChangedRangeEnd = nextRange[1];
+ if (this.#stateStackCache[nextRange[0]] === undefined) {
+ settled = false;
+ } else {
+ line = nextRange[0];
+ state = this.#stateStackCache[line] ?? state;
+ settled = false;
+ continue;
+ }
+ }
+
+ // limit the time of partial tokenize to 2ms
+ if (performance.now() - t > 2) {
+ break;
+ }
+ }
+
+ this.#onDeferTokenize(lines, this.#themeType);
+ if (this.#isStopped || jobId !== this.#backgroundJobId) {
+ return;
+ }
+
+ if (settled || line >= totalLines) {
+ this.stopBackgroundTokenize();
+ return;
+ }
+
+ this.#lastLine = line;
+ this.#backgroundChangedRangeIndex = changedRangeIndex;
+ this.#postBackgroundTokenizeMessage(jobId);
+ }
+}
+
+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,
+ };
+}
+
+export function renderLineTokens(
+ tokens: Array,
+ themeType: 'light' | 'dark'
+): (HTMLElement | string)[] {
+ return tokens.map(([char, fg, textContent]) => {
+ if (char === 0 && fg === '') {
+ if (textContent === '') {
+ return h('br');
+ }
+ return textContent;
+ }
+ return h('span', {
+ dataset: {
+ char: char.toString(),
+ },
+ style: `--diffs-token-${themeType}:${fg};`,
+ textContent: textContent,
+ });
+ });
+}
diff --git a/packages/diffs/src/editor/utils.ts b/packages/diffs/src/editor/utils.ts
new file mode 100644
index 000000000..c75b43339
--- /dev/null
+++ b/packages/diffs/src/editor/utils.ts
@@ -0,0 +1,92 @@
+export function h(
+ tagName: K,
+ props?: {
+ style?: string | Partial;
+ dataset?: DOMStringMap | string[] | string;
+ children?: (Node | string)[];
+ } & Partial>,
+ parent?: Element | ShadowRoot | DocumentFragment
+): HTMLElementTagNameMap[K] {
+ const { style, dataset, children, ...attrs } = props ?? {};
+ const el = document.createElement(tagName);
+ Object.assign(el, attrs);
+ 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 (children !== undefined) {
+ el.replaceChildren(...children);
+ }
+ if (parent !== undefined) {
+ parent.appendChild(el);
+ }
+ 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: MediaQueryList,
+ event: K,
+ listener: (this: MediaQueryList, evt: MediaQueryListEventMap[K]) => void,
+ options?: AddEventListenerOptions
+): () => void;
+export function addEventListener(
+ el: HTMLElement | Document | ShadowRoot | Window | MediaQueryList,
+ 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);
+}
+
+// oxlint-disable-next-line typescript/no-explicit-any
+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/react/EditorContext.tsx b/packages/diffs/src/react/EditorContext.tsx
new file mode 100644
index 000000000..169b719c9
--- /dev/null
+++ b/packages/diffs/src/react/EditorContext.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import type { Context, PropsWithChildren } from 'react';
+import { createContext, useContext, useEffect } from 'react';
+
+import { Editor } from '../editor';
+
+export const EditorContext: Context | undefined> =
+ createContext | undefined>(undefined);
+
+export function EditorProvider({
+ children,
+ editor,
+}: PropsWithChildren<{ editor: Editor }>): React.JSX.Element {
+ useEffect(() => {
+ return () => {
+ editor.cleanUp();
+ };
+ }, [editor]);
+ return (
+ {children}
+ );
+}
+
+export function useEditor(): Editor | undefined {
+ return useContext(EditorContext) as Editor | undefined;
+}
diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx
index ae3aeda58..c2fd18560 100644
--- a/packages/diffs/src/react/File.tsx
+++ b/packages/diffs/src/react/File.tsx
@@ -24,6 +24,7 @@ export function File({
prerenderedHTML,
renderGutterUtility,
disableWorkerPool = false,
+ contentEditable = false,
}: FileProps): React.JSX.Element {
const { ref, getHoveredLine } = useFileInstance({
file,
@@ -35,6 +36,7 @@ export function File({
hasGutterRenderUtility: renderGutterUtility != null,
hasCustomHeader: renderCustomHeader != null,
disableWorkerPool,
+ contentEditable,
});
const children = renderFileChildren({
file,
diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx
index 052f5b0dd..805454ca2 100644
--- a/packages/diffs/src/react/FileDiff.tsx
+++ b/packages/diffs/src/react/FileDiff.tsx
@@ -14,6 +14,7 @@ export interface FileDiffProps<
> extends DiffBasePropsReact {
fileDiff: FileDiffMetadata;
disableWorkerPool?: boolean;
+ contentEditable?: boolean;
}
export function FileDiff({
@@ -31,6 +32,7 @@ export function FileDiff({
renderHeaderMetadata,
renderGutterUtility,
disableWorkerPool = false,
+ contentEditable = false,
}: FileDiffProps): React.JSX.Element {
const { ref, getHoveredLine } = useFileDiffInstance({
fileDiff,
@@ -42,6 +44,7 @@ export function FileDiff({
hasGutterRenderUtility: renderGutterUtility != null,
hasCustomHeader: renderCustomHeader != null,
disableWorkerPool,
+ contentEditable,
});
const children = renderDiffChildren({
fileDiff,
diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx
index 076288610..5e57e5fc6 100644
--- a/packages/diffs/src/react/MultiFileDiff.tsx
+++ b/packages/diffs/src/react/MultiFileDiff.tsx
@@ -18,6 +18,7 @@ export interface MultiFileDiffProps<
oldFile: FileContents;
newFile: FileContents;
disableWorkerPool?: boolean;
+ contentEditable?: boolean;
}
export function MultiFileDiff({
@@ -36,6 +37,7 @@ export function MultiFileDiff({
renderHeaderMetadata,
renderGutterUtility,
disableWorkerPool = false,
+ contentEditable = false,
}: MultiFileDiffProps): React.JSX.Element {
const fileDiff = useMemo(() => {
return parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions);
@@ -50,6 +52,7 @@ export function MultiFileDiff({
hasGutterRenderUtility: renderGutterUtility != null,
hasCustomHeader: renderCustomHeader != null,
disableWorkerPool,
+ contentEditable,
});
const children = renderDiffChildren({
fileDiff,
diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx
index a57b304b1..7ada0d85b 100644
--- a/packages/diffs/src/react/PatchDiff.tsx
+++ b/packages/diffs/src/react/PatchDiff.tsx
@@ -15,6 +15,7 @@ export interface PatchDiffProps<
> extends DiffBasePropsReact {
patch: string;
disableWorkerPool?: boolean;
+ contentEditable?: boolean;
}
export function PatchDiff({
@@ -32,6 +33,7 @@ export function PatchDiff({
renderHeaderMetadata,
renderGutterUtility,
disableWorkerPool = false,
+ contentEditable = false,
}: PatchDiffProps): React.JSX.Element {
const fileDiff = usePatch(patch);
const { ref, getHoveredLine } = useFileDiffInstance({
@@ -44,6 +46,7 @@ export function PatchDiff({
hasGutterRenderUtility: renderGutterUtility != null,
hasCustomHeader: renderCustomHeader != null,
disableWorkerPool,
+ contentEditable,
});
const children = renderDiffChildren({
fileDiff,
diff --git a/packages/diffs/src/react/index.ts b/packages/diffs/src/react/index.ts
index 7788d71ba..3f16645fe 100644
--- a/packages/diffs/src/react/index.ts
+++ b/packages/diffs/src/react/index.ts
@@ -10,6 +10,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 fb45c43bd..4f38959d4 100644
--- a/packages/diffs/src/react/types.ts
+++ b/packages/diffs/src/react/types.ts
@@ -46,4 +46,9 @@ export interface FileProps {
style?: CSSProperties;
prerenderedHTML?: string;
disableWorkerPool?: boolean;
+ contentEditable?: boolean;
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void;
}
diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts
index 65d988cf4..b03440d0a 100644
--- a/packages/diffs/src/react/utils/useFileDiffInstance.ts
+++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts
@@ -17,6 +17,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';
@@ -34,6 +35,7 @@ interface UseFileDiffInstanceProps {
hasGutterRenderUtility: boolean;
hasCustomHeader: boolean;
disableWorkerPool: boolean;
+ contentEditable: boolean;
}
interface UseFileDiffInstanceReturn {
@@ -51,10 +53,12 @@ export function useFileDiffInstance({
hasGutterRenderUtility,
hasCustomHeader,
disableWorkerPool,
+ contentEditable,
}: UseFileDiffInstanceProps): UseFileDiffInstanceReturn {
const simpleVirtualizer = useVirtualizer();
const controlledSelection = selectedLines !== undefined;
const poolManager = useContext(WorkerPoolContext);
+ const editor = useEditor();
const instanceRef = useRef<
FileDiff | VirtualizedFileDiff | null
>(null);
@@ -110,12 +114,20 @@ export function useFileDiffInstance({
useIsometricEffect(() => {
const { current: instance } = instanceRef;
if (instance == null) return;
- const newOptions = mergeFileDiffOptions({
+ let newOptions = mergeFileDiffOptions({
controlledSelection,
hasCustomHeader,
hasGutterRenderUtility,
options,
});
+ if (editor !== undefined) {
+ newOptions = {
+ ...newOptions,
+ useTokenTransformer: true,
+ enableGutterUtility: false,
+ enableLineSelection: false,
+ };
+ }
const forceRender = !areOptionsEqual(instance.options, newOptions);
instance.setOptions(newOptions);
void instance.render({
@@ -128,6 +140,17 @@ export function useFileDiffInstance({
}
});
+ useIsometricEffect(() => {
+ if (
+ contentEditable &&
+ editor !== undefined &&
+ instanceRef.current != null
+ ) {
+ return editor.edit(instanceRef.current);
+ }
+ return undefined;
+ }, [contentEditable, editor]);
+
const getHoveredLine = useCallback(():
| GetHoveredLineResult<'diff'>
| undefined => {
diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts
index 16e251386..33867111a 100644
--- a/packages/diffs/src/react/utils/useFileInstance.ts
+++ b/packages/diffs/src/react/utils/useFileInstance.ts
@@ -17,6 +17,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';
@@ -34,6 +35,11 @@ interface UseFileInstanceProps {
hasGutterRenderUtility: boolean;
hasCustomHeader: boolean;
disableWorkerPool: boolean;
+ contentEditable: boolean;
+ onChange?: (
+ file: FileContents,
+ lineAnnotations?: LineAnnotation[]
+ ) => void;
}
interface UseFileInstanceReturn {
@@ -51,10 +57,12 @@ export function useFileInstance({
hasGutterRenderUtility,
hasCustomHeader,
disableWorkerPool,
+ contentEditable,
}: UseFileInstanceProps): UseFileInstanceReturn {
const simpleVirtualizer = useVirtualizer();
const controlledSelection = selectedLines !== undefined;
const poolManager = useContext(WorkerPoolContext);
+ const editor = useEditor();
const instanceRef = useRef<
File | VirtualizedFile | null
>(null);
@@ -107,12 +115,20 @@ export function useFileInstance({
useIsometricEffect(() => {
if (instanceRef.current == null) return;
- const newOptions = mergeFileOptions({
+ let newOptions = mergeFileOptions({
controlledSelection,
hasCustomHeader,
hasGutterRenderUtility,
options,
});
+ if (editor !== undefined) {
+ newOptions = {
+ ...newOptions,
+ useTokenTransformer: true,
+ enableGutterUtility: false,
+ enableLineSelection: false,
+ };
+ }
const forceRender = !areOptionsEqual(
instanceRef.current.options,
newOptions
@@ -124,6 +140,17 @@ export function useFileInstance({
}
});
+ useIsometricEffect(() => {
+ if (
+ contentEditable &&
+ editor !== undefined &&
+ instanceRef.current != null
+ ) {
+ return editor.edit(instanceRef.current);
+ }
+ return undefined;
+ }, [contentEditable, editor]);
+
const getHoveredLine = useCallback(():
| GetHoveredLineResult<'file'>
| undefined => {
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index aabc52fc1..e80b5d1bb 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -389,7 +389,7 @@ export class DiffHunksRenderer {
};
}
- private async initializeHighlighter(): Promise {
+ public async initializeHighlighter(): Promise {
this.highlighter = await getSharedHighlighter(
getHighlighterOptions(this.computedLang, this.options)
);
diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts
index 2a569052e..5c5eb8092 100644
--- a/packages/diffs/src/renderers/FileRenderer.ts
+++ b/packages/diffs/src/renderers/FileRenderer.ts
@@ -16,8 +16,10 @@ import { hasResolvedThemes } from '../highlighter/themes/hasResolvedThemes';
import type {
BaseCodeOptions,
DiffsHighlighter,
+ DiffsTextDocument,
FileContents,
FileHeaderRenderMode,
+ HighlightedToken,
LineAnnotation,
RenderedFileASTCache,
RenderFileOptions,
@@ -29,6 +31,7 @@ import type {
import { areFileRenderOptionsEqual } from '../utils/areFileRenderOptionsEqual';
import { areFilesEqual } from '../utils/areFilesEqual';
import { areRenderRangesEqual } from '../utils/areRenderRangesEqual';
+import { computeLineOffsets } from '../utils/computeFileOffsets';
import { createAnnotationElement } from '../utils/createAnnotationElement';
import { createContentColumn } from '../utils/createContentColumn';
import { createFileHeaderElement } from '../utils/createFileHeaderElement';
@@ -44,10 +47,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<
@@ -74,11 +75,6 @@ export interface FileRenderResult {
bufferAfter: number;
}
-interface LineCache {
- cacheKey: string | undefined;
- lines: string[];
-}
-
export interface FileRendererOptions extends BaseCodeOptions {
headerRenderMode?: FileHeaderRenderMode;
}
@@ -92,7 +88,11 @@ export class FileRenderer {
private renderCache: RenderedFileASTCache | undefined;
private computedLang: SupportedLanguages = 'text';
private lineAnnotations: AnnotationLineMap = {};
- private lineCache: LineCache | undefined;
+ private lineOffsetsCache = new WeakMap<
+ FileContents,
+ { offsets: number[]; cacheKey?: string }
+ >();
+ private textDoucmentCache = new WeakMap();
constructor(
public options: FileRendererOptions = { theme: DEFAULT_THEMES },
@@ -132,10 +132,17 @@ export class FileRenderer {
}
public recycle(): void {
+ const renderCache = this.renderCache;
this.clearRenderCache();
this.highlighter = undefined;
this.workerManager?.cleanUpTasks(this);
- this.lineCache = undefined;
+ if (
+ renderCache != null &&
+ renderCache.isDirty === true &&
+ renderCache.file.cacheKey != null
+ ) {
+ this.workerManager?.evictFileFromCache(renderCache.file.cacheKey);
+ }
}
public clearRenderCache(): void {
@@ -144,7 +151,7 @@ export class FileRenderer {
public hydrate(file: FileContents): void {
const { options } = this.getRenderOptions(file);
- const lines = this.getOrCreateLineCache(file);
+ const lines = this.getOrCreateLineOffsets(file);
const massiveFile = isFileMassive(
lines.length,
this.getTokenizeMaxLength()
@@ -200,23 +207,115 @@ export class FileRenderer {
return { options, forceHighlight: 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[] {
+ const cacheKey = file.cacheKey;
+ const cached = this.lineOffsetsCache.get(file);
+ if (cached === undefined || cached.cacheKey !== cacheKey) {
+ const offsets = computeLineOffsets(file.contents);
+ this.lineOffsetsCache.set(file, { offsets, cacheKey });
+ return offsets;
}
+ return cached.offsets;
+ }
+
+ // when a emitLineCountChange is called,
+ // calculate the line count using the cached text document
+ public getLineCount(file: FileContents): number {
+ return (
+ this.textDoucmentCache.get(file)?.lineCount ??
+ this.getOrCreateLineOffsets(file).length
+ );
+ }
- let { lineCache } = this;
- if (lineCache == null || lineCache.cacheKey !== file.cacheKey) {
- lineCache = {
- cacheKey: file.cacheKey,
- lines: splitFileContents(file.contents),
+ public applyDirtyLines(
+ lines: Map>,
+ themeType: 'dark' | 'light'
+ ): void {
+ if (this.renderCache == null) {
+ return;
+ }
+ const { result } = this.renderCache;
+ if (result == null) {
+ return;
+ }
+ for (const [line, tokens] of lines) {
+ 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 === '') {
+ if (text === '') {
+ return {
+ type: 'element',
+ tagName: 'br',
+ properties: {},
+ children: [],
+ };
+ }
+ 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;
+ this.renderCache.isDirty = true;
+ }
+
+ public applyLayoutChange(
+ textDocument: DiffsTextDocument,
+ newLineAnnotations?: LineAnnotation[]
+ ): void {
+ if (this.renderCache == null) {
+ return undefined;
+ }
+ const { file, result } = this.renderCache;
+ if (result != null && result.code.length !== textDocument.lineCount) {
+ for (let i = result.code.length; i < textDocument.lineCount; i++) {
+ // prefill lines with plain text content
+ result.code.push({
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ 'data-line': i + 1,
+ 'data-line-type': 'context',
+ 'data-line-index': i,
+ },
+ children: [
+ {
+ type: 'element',
+ tagName: 'span',
+ properties: {
+ 'data-char': 0,
+ },
+ children: [
+ {
+ type: 'text',
+ value: textDocument.getLineText(i),
+ },
+ ],
+ },
+ ],
+ });
+ }
+ this.renderCache.isDirty = true;
+ }
+ if (newLineAnnotations != null) {
+ this.setLineAnnotations(newLineAnnotations);
+ }
+ this.textDoucmentCache.set(file, textDocument);
}
public renderFile(
@@ -244,7 +343,7 @@ export class FileRenderer {
result: undefined,
renderRange: undefined,
};
- const lines = this.getOrCreateLineCache(file);
+ const lines = this.getOrCreateLineOffsets(file);
const hasContent = file.contents.length > 0;
const forcePlainText =
!hasContent ||
@@ -355,9 +454,9 @@ export class FileRenderer {
}
private async asyncHighlight(file: FileContents): Promise {
- const lines = this.getOrCreateLineCache(file);
+ const lineOffsets = this.getOrCreateLineOffsets(file);
const forcePlainText = isFileMassive(
- lines.length,
+ lineOffsets.length,
this.getTokenizeMaxLength()
);
this.computedLang = forcePlainText
@@ -398,66 +497,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);
- }
+ 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);
+ }
- 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++;
- }
- }
- },
- });
+ // 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: totalLines,
rowCount,
themeStyles: themeStyles,
baseThemeType,
diff --git a/packages/diffs/src/sprite.ts b/packages/diffs/src/sprite.ts
index da56b833f..709ffe23b 100644
--- a/packages/diffs/src/sprite.ts
+++ b/packages/diffs/src/sprite.ts
@@ -1,4 +1,5 @@
export type SVGSpriteNames =
+ | 'diffs-icon-arrow'
| 'diffs-icon-arrow-right-short'
| 'diffs-icon-brand-github'
| 'diffs-icon-chevron'
@@ -9,15 +10,23 @@ export type SVGSpriteNames =
| 'diffs-icon-expand-all'
| 'diffs-icon-file-code'
| 'diffs-icon-plus'
+ | 'diffs-icon-regex'
+ | 'diffs-icon-search'
| 'diffs-icon-symbol-added'
| 'diffs-icon-symbol-deleted'
| 'diffs-icon-symbol-diffstat'
| 'diffs-icon-symbol-ignored'
| 'diffs-icon-symbol-modified'
| 'diffs-icon-symbol-moved'
- | 'diffs-icon-symbol-ref';
+ | 'diffs-icon-symbol-ref'
+ | 'diffs-icon-type'
+ | 'diffs-icon-type-word'
+ | 'diffs-icon-x';
export const SVGSpriteSheet = `