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

+ + + + + + + + @pierre/diffs +

+
+
+
+ +
+ + + +
+ + + diff --git a/apps/demo/index.html b/apps/demo/index.html index aba77e5e8..2266839ac 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -30,10 +30,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/src/editor.ts b/apps/demo/src/editor.ts new file mode 100644 index 000000000..64378a4ab --- /dev/null +++ b/apps/demo/src/editor.ts @@ -0,0 +1,139 @@ +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( + {}, + virtualizer, + undefined, + poolManager +); +const [paths, gitStatus] = await Promise.all([ + API.getPaths(), + API.getGitStatus(), +]); +const fileTree = new FileTree({ + paths, + gitStatus, + 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); 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..5bd94edb3 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -253,3 +253,92 @@ diffs-container { align-items: center; gap: 4px; } + +[data-icon-sprite] { + display: none; +} + +#editor { + display: grid; + grid-template-columns: 280px 1fr; + grid-template-rows: 1fr; + gap: 10px; + background-color: light-dark(white, black); + height: 100vh; + width: 100vw; + overflow: hidden; +} + +#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; +} + +.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; +} 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/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/bun.lock b/bun.lock index 3ae9085d5..c06e3ba95 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "version": "0.0.0", "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/trees": "workspace:*", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:", diff --git a/packages/diffs/package.json b/packages/diffs/package.json index baa258182..e875776b5 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 f75354b3a..d178a54f5 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, @@ -57,8 +61,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; @@ -117,7 +119,9 @@ interface HydrationSetup { let instanceId = -1; -export class File { +export class File< + LAnnotation = undefined, +> implements DiffsEditableComponent { static LoadedCustomComponent: boolean = DiffsContainerLoaded; readonly __id: string = `file:${++instanceId}`; @@ -159,6 +163,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, @@ -314,6 +320,10 @@ export class File { } this.enabled = false; + + // Clean up the editor + this.editor?.cleanUp(); + this.editor = undefined; } public virtualizedSetup(): void { @@ -420,12 +430,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.emitTokenize(lines, themeType); + } + + public emitLayoutChange( + textDocument: DiffsTextDocument, + newLineAnnotations?: LineAnnotation[] + ): void { + this.fileRenderer.emitLineCountChange(textDocument, newLineAnnotations); + if ( + newLineAnnotations != null && + newLineAnnotations !== this.lineAnnotations + ) { + this.annotationCache.forEach(({ element }) => element.remove()); + this.annotationCache.clear(); + this.lineAnnotations = newLineAnnotations; + this.rerender(); + } } public render({ @@ -570,6 +628,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, + lineAnnotations, + nextRenderRange + ); + }); + } } catch (error: unknown) { if (disableErrorHandling) { throw error; @@ -659,7 +730,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 38ba91c4d..3437338a2 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, @@ -167,7 +170,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; @@ -218,6 +223,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, @@ -524,6 +532,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 { @@ -897,6 +912,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, + lineAnnotations, + nextRenderRange, + 'advanced' + ); + }); + } } catch (error: unknown) { if (disableErrorHandling) { throw error; @@ -918,6 +948,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); + }, 250); + } + } + + 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/FileStream.ts b/packages/diffs/src/components/FileStream.ts index 28ab1155f..a7ed3aaa3 100644 --- a/packages/diffs/src/components/FileStream.ts +++ b/packages/diffs/src/components/FileStream.ts @@ -152,9 +152,6 @@ export class FileStream { this.abortController?.abort(); this.abortController = new AbortController(); const { onStreamStart, onStreamClose, onStreamAbort } = this.options; - // Cancel the prior source so upstream producers stop generating tokens. - // Swallow AbortError / locked-stream rejections since we're tearing down. - this.stream?.cancel().catch(() => {}); this.stream = stream; this.stream .pipeThrough( diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 81bbe119b..9b992e604 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,19 +1,21 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; import type { + DiffsTextDocument, FileContents, + LineAnnotation, NumericScrollLineAnchor, RenderRange, RenderWindow, StickySpecs, 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'; @@ -242,8 +244,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) { @@ -297,8 +298,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; } @@ -418,7 +418,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 @@ -432,18 +432,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; } @@ -488,16 +485,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 fileChanged = this.file == null || !areFilesEqual(this.file, file); const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; - this.file ??= file; + this.file = file; fileContainer = this.getOrCreateFileContainerNode(fileContainer); @@ -529,6 +555,10 @@ export class VirtualizedFile< this.isSetup = true; } else { this.top ??= this.getVirtualizedTop(); + if (fileChanged) { + this.getSimpleVirtualizer()?.markDOMDirty(); + this.resetLayoutCache(true); + } } if (!this.isVisible && this.isSimpleMode()) { @@ -674,8 +704,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, @@ -768,51 +797,45 @@ 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--; - } - } + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const isAtHunkBoundary = currentLine % hunkLineCount === 0; - const lineHeight = this.getLineHeight(lineIndex, false); + if (isAtHunkBoundary) { + hunkOffsets.push(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); + const currentHunk = Math.floor(currentLine / hunkLineCount); - // 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) { @@ -864,18 +887,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..28d5ec5ff --- /dev/null +++ b/packages/diffs/src/editor/css.ts @@ -0,0 +1,190 @@ +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-code] { + padding-bottom: 0; + overflow: auto; + } + [data-content] { + background-color: transparent; + caret-color: var(--diffs-bg-caret); + outline: none; + } + @media (min-width: 480px) { + [data-content] { + caret-color: ${DEBUG_SELECTION ? 'blue' : '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: var(--diffs-bg-caret); + animation: blinking 1.2s infinite; + animation-delay: 0.6s; + visibility: hidden; + } + [data-selection-range] { + height: 1lh; + z-index: -10; + background-color: var(--diffs-line-bg); + } + [data-editor-overlay] { + display: contents; + } + @media (min-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] { + position: sticky; + top: 8px; + left: 0; + z-index: 100; + display: flex; + flex-direction: column; + gap: 4px; + margin-inline: 16px; + 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) 8%, var(--diffs-bg)); + padding: 6px; + border-radius: 6px; + box-shadow: 0 0 12px 0 color-mix(in lab, var(--diffs-fg) 16% var(--diffs-bg)); + backdrop-filter: blur(8px); + } + [data-search-panel-row] { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 2px; + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + } + [data-search-panel-row] input { + font-size: 14px; + line-height: 24px; + max-width: 50%; + padding-inline: 4px; + border: none; + outline: none; + background-color: transparent; + color: var(--diffs-fg); + field-sizing: content; + } + [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] { + font-size: 12px; + font-weight: 500; + line-height: 20px; + padding-inline-start: 4px; + padding-inline-end: 8px; + color: color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)); + } + [data-search-panel-row] [data-matches][data-no-matches] { + color: color-mix(in lab, var(--diffs-deletion-base) 90%, var(--diffs-bg)); + } + [data-search-panel-row] [data-icon] { + width: 24px; + height: 24px; + display: flex; + color: color-mix(in lab, var(--diffs-fg) 40%, 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][data-disabled='true'] { + visibility: hidden; + } + [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-settings] { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + padding: 0 8px; + } + [data-search-panel-row] [data-checkbox] { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + color: color-mix(in lab, var(--diffs-fg) 60%, var(--diffs-bg)); + } + [data-search-panel-row] [data-checkbox] input { + margin: 0; + } + [data-search-panel-row] [data-checkbox]:hover, + [data-search-panel-row] [data-checkbox]:has(input:checked) { + color: var(--diffs-fg); + } + [data-search-panel-row] [data-spacer] { + flex: 1; + } +`; diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts new file mode 100644 index 000000000..9379da77a --- /dev/null +++ b/packages/diffs/src/editor/editStack.ts @@ -0,0 +1,346 @@ +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. */ + setLastUndoLineAnnotationsAfter( + lineAnnotations: DiffLineAnnotation[] + ): void { + const lastEntry = this.#undoStack[this.#undoStack.length - 1]; + if (lastEntry !== undefined) { + lastEntry.lineAnnotationsAfter = lineAnnotations.slice(); + } + } + + /** Returns the last undo entry, or `undefined` if empty. */ + peekUndo(): EditStackEntry | undefined { + return this.#undoStack[this.#undoStack.length - 1]; + } + + /** Replaces the last undo entry with the given entry. */ + replaceLastUndo(entry: EditStackEntry): void { + if (this.#undoStack.length === 0) { + this.push(entry); + return; + } + this.#undoStack[this.#undoStack.length - 1] = entry; + this.clearRedo(); + } + + /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ + popUndoToRedo(): EditStackEntry | void { + const entry = this.#undoStack.pop(); + if (entry !== undefined) { + this.#redoStack.push(entry); + return entry; + } + } + + /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */ + popRedoToUndo(): EditStackEntry | void { + const entry = this.#redoStack.pop(); + if (entry !== undefined) { + this.#undoStack.push(entry); + return entry; + } + } +} + +export function createEditStackEntry( + textDocument: TextDocument, + resolvedEdits: ResolvedTextEdit[], + versionBefore: number, + versionAfter: number, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: 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..950210a5c --- /dev/null +++ b/packages/diffs/src/editor/editor.ts @@ -0,0 +1,2271 @@ +import { DEFAULT_THEMES } from '../constants'; +import { + type ResolvedTextEdit, + TextDocument, + type TextDocumentChange, + type TextEdit, +} from '../editor/textDocument'; +import type { + DiffLineAnnotation, + DiffsEditableComponent, + DiffsEditor, + DiffsEditorSearchParams, + DiffsEditorSelection, + DiffsHighlighter, + FileContents, + HighlightedToken, + RenderRange, +} from '../types'; +import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; +import { + type EditorCommand, + resolveEditorCommandFromKeyboardEvent, +} from './command'; +import { editorCSS } from './css'; +import { applyDocumentChangeToLineAnnotations } from './lineAnnotations'; +import { isPrimaryModifier } from './platform'; +import { 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 { + getUnicodeMeasurementOffsets, + measureDomTextWidth, + needsDomTextMeasurement, + 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: { + selection: EditorSelection; + textDocument: TextDocument; + replaceSelectionText: (text: string) => void; + close: () => void; + }) => HTMLElement; + onChange?: ( + file: FileContents, + lineAnnotations?: DiffLineAnnotation[] + ) => void; +} + +export class Editor implements DiffsEditor { + #options: EditorOptions; + #tokenizer?: EditorTokenizer; + + // event handlers + #editorEventDisposes?: (() => void)[]; + #globalEventDisposes?: (() => void)[]; + #removeEditorFromComponent?: () => void; + + // metrics + #charWidth = -1; + #lineHeight = 20; + #tabSize = 2; + #wrap = false; + #editMode: 'simple' | 'advanced' = 'simple'; + + // file + #component?: DiffsEditableComponent; + #fileContents?: FileContents; + #lineAnnotations?: DiffLineAnnotation[]; + #textDocument?: TextDocument; + #renderRange?: RenderRange; + + // cache + #gutterWidthCache?: number; + #contentWidthCache?: number; + #lineYCache = new Map(); + #wrapLineOffsetsCache = new Map(); + #lastCharX?: [line: number, character: number, x: number, wrapLine: number]; + + // dom + #componentContainer?: HTMLElement; + #contentElement?: HTMLElement; + #styleElement?: HTMLStyleElement; + #overlayElement?: HTMLElement; + #primaryCaretElement?: HTMLElement; + #selectionElements?: Map; + #quickEdit?: QuickEditWidget; + #searchPanel?: SearchPanelWidget; + #measureCtx?: CanvasRenderingContext2D; + #contentResizeObserver?: ResizeObserver; + + // state + #ready = Promise.withResolvers(); + #shouldIgnoreSelectionChange = false; + #isMouseDown = false; + #shiftKeyPressed = false; + #selectionStart: EditorSelection | undefined; + #reservedSelections?: EditorSelection[]; + #selections?: EditorSelection[]; + #scrollingToLine?: number; + #scrollingForceFocus?: boolean; + #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) { + // Ensure the component uses token transformer that adds + // `data-char` attribute to the tokens + const options = { + ...component.options, + useTokenTransformer: true, + }; + component.setOptions(options); + component.rerender(); + } + this.#removeEditorFromComponent = component.setupEditor(this); + return () => this.cleanUp(); + } + + setSelections(selections: DiffsEditorSelection[]): void { + void this.#ready.promise.then(() => { + const textDocument = this.#textDocument; + if (textDocument !== undefined) { + const resolvedSelections = selections.map( + (selection) => { + const start = textDocument.normalizePosition(selection.start); + const end = textDocument.normalizePosition(selection.end); + const direction = + selection.direction === 'none' + ? DirectionNone + : selection.direction === 'backward' + ? DirectionBackward + : DirectionForward; + return { direction, start, end }; + } + ); + this.#updateSelections(resolvedSelections, true); + this.#contentElement?.focus(); + } + }); + } + + cleanUp(): void { + this.#tokenizer?.stopBackgroundTokenize(); + 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.#componentContainer = undefined; + this.#contentElement?.removeAttribute('contentEditable'); + this.#contentElement = undefined; + this.#styleElement?.remove(); + this.#styleElement = 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.#measureCtx = undefined; + this.#contentResizeObserver?.disconnect(); + this.#contentResizeObserver = 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; + } + } + const contentEl = codeElement?.children[1] as HTMLElement | undefined; + if (contentEl === undefined) { + console.error('[editor] Could not find the content element.'); + return; + } + + this.#editMode = editMode ?? 'simple'; + this.#wrap = this.#component?.options.overflow === 'wrap'; + + if (editMode === 'advanced') { + 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'; + } + } + // normalize the render range + if (startingLine !== undefined && endLine !== undefined) { + renderRange = { + startingLine: startingLine, + totalLines: endLine - startingLine + 1, + bufferBefore: 0, + bufferAfter: 0, + }; + } + } + + if (this.#componentContainer !== fileContainer) { + this.#componentContainer = fileContainer; + // inject editor css to the file container + 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?.stopBackgroundTokenize(); + this.#tokenizer = new EditorTokenizer({ + highlighter, + theme: this.#getTheme(), + textDocument, + tokenizeMaxLineLength: + this.#component?.options.tokenizeMaxLineLength ?? 1000, + 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.#scrollingForceFocus = undefined; + this.#reservedSelections = undefined; + this.#searchPanel?.cleanup(); + this.#searchPanel = undefined; + this.#quickEdit?.cleanup(); + this.#quickEdit = undefined; + } + + if (this.#contentElement !== contentEl) { + const targetIsContentElement = (e: Event) => { + const target = e.composedPath()[0] as HTMLElement; + if (this.#contentElement === undefined) { + return false; + } + return ( + target === this.#contentElement || + this.#contentElement.contains(target) + ); + }; + 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, + '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); + } + }, + { passive: false } + ), + + addEventListener( + contentEl, + 'copy', + (e) => { + if (!targetIsContentElement(e)) { + return; + } + e.preventDefault(); + e.clipboardData?.setData('text', this.#getSelectionText()); + }, + { passive: false } + ), + + addEventListener( + contentEl, + 'cut', + (e) => { + if (!targetIsContentElement(e)) { + return; + } + e.preventDefault(); + e.clipboardData?.setData('text', this.#getSelectionText()); + this.#replaceSelectionText(''); + }, + { passive: false } + ), + + 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); + } + }, + { passive: false } + ), + + addEventListener( + contentEl, + 'beforeinput', + (e) => { + if (!targetIsContentElement(e)) { + return; + } + e.preventDefault(); + this.#handleInput(e.inputType, e.data); + }, + { passive: false } + ), + + 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 } + ), + ]; + + this.#contentResizeObserver?.disconnect(); + this.#contentResizeObserver = new ResizeObserver(() => { + this.#handleLayoutResize(); + }); + this.#contentResizeObserver.observe(contentEl); + this.#contentResizeObserver.observe(contentEl.parentElement!); + } + + // measure the font width, line height, and tab size + // purge the lineY cache if the line height or line annotations change + const style = getComputedStyle(contentEl); + const { fontSize, fontFamily, tabSize, lineHeight } = style; + let lineHeighPx = 20; + if (lineHeight.endsWith('px')) { + lineHeighPx = Number(lineHeight.slice(0, -2)); + } else if (fontSize.endsWith('px')) { + lineHeighPx = round( + Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)) + ); + } + this.#lastCharX = undefined; + this.#lineHeight = lineHeighPx; + this.#tabSize = Number(tabSize); + this.#measureCtx ??= + document.createElement('canvas').getContext('2d') ?? undefined; + const font = fontSize + ' ' + fontFamily; + if ( + this.#measureCtx !== undefined && + (this.#measureCtx.font !== font || this.#charWidth === -1) + ) { + this.#measureCtx.font = font; + this.#charWidth = round(this.#measureCtx.measureText('0').width); + } + + this.#lineYCache.clear(); + this.#wrapLineOffsetsCache.clear(); + this.#lastCharX = undefined; + + this.#lineAnnotations = lineAnnotations; + this.#renderRange = renderRange; + this.#tokenizer?.prebuildStateStackMap(renderRange); + + if (this.#selections !== undefined && this.#selections.length > 0) { + // when re-rendering triggered by viewport scroll, + // re-render the existing selections + this.#updateSelections(this.#selections, true); + } + + 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.#scrollingForceFocus); + } + + 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); + } + + this.#ready.resolve(); + } + + #getTheme(): { + name: string; + type: 'dark' | 'light'; + } { + let { themeType = 'system', theme = DEFAULT_THEMES } = + this.#component?.options ?? {}; + if (themeType === 'system') { + themeType = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return { + name: typeof theme === 'string' ? theme : theme[themeType], + type: themeType, + }; + } + + #initialize(): void { + this.#styleElement = h('style', { + dataset: 'editorCss', + textContent: editorCSS, + }); + + 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; + } + + if ( + this.#isMouseDown && + this.#shiftKeyPressed && + this.#selections !== undefined && + this.#selections.length > 0 + ) { + const primarySelection = this.#selections.at(-1)!; + // before shift + click, the window selection has been cleared, + // so we need to set the window selection manually with the new + // selection + this.#updateSelections( + [extendSelection(primarySelection, selection)], + true + ); + return; + } + + if (this.#isMouseDown) { + if (this.#selectionStart !== undefined) { + selection = createSelectionFrom(this.#selectionStart, selection); + } else { + this.#selectionStart = selection; + } + } else if (this.#selectionStart !== undefined) { + selection.direction = createSelectionFrom( + this.#selectionStart, + selection + ).direction; + } + + if (this.#reservedSelections !== undefined) { + this.#updateSelections([ + ...this.#reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); + } else { + if ( + this.#isMouseDown || + this.#selections === undefined || + this.#selections.length === 0 || + this.#textDocument === undefined + ) { + this.#updateSelections([selection]); + } else { + // The selection change is triggered by the keyboard + // For example, moving the cursor by arrow keys. + if (isCollapsedSelection(selection)) { + this.#updateSelections( + mapCursorMove( + this.#textDocument, + this.#selections, + selection.start + ) + ); + } else { + // shift key is pressed when moving the cursor by + const newSelections = mapSelectionShift( + this.#textDocument, + this.#selections, + selection + ); + const hasMergedSelections = + newSelections.length !== this.#selections.length; + this.#updateSelections(newSelections, false); + if (hasMergedSelections) { + this.#updateWindowSelection(newSelections.at(-1)!); + } + } + } + } + }, + { passive: true } + ), + + addEventListener( + document, + 'mousedown', + (e) => { + const target = e.composedPath()[0]; + if (target === undefined || !(target instanceof HTMLElement)) { + return; + } + const { tagName, dataset } = target; + if ( + !( + (tagName === 'DIV' && dataset.line !== undefined) || + (tagName === 'SPAN' && dataset.char !== undefined) + ) + ) { + return; + } + + this.#isMouseDown = true; + this.#selectionStart = undefined; + if (e.button === 0 && isPrimaryModifier(e)) { + this.#reservedSelections = this.#selections?.map((selection) => ({ + ...selection, + })); + } + if (e.shiftKey) { + window.getSelection()?.empty(); + this.#shiftKeyPressed = true; + } else { + this.#selections = undefined; + } + }, + { passive: true } + ), + + addEventListener( + document, + 'mouseup', + () => { + this.#isMouseDown = false; + this.#shiftKeyPressed = false; + this.#selectionStart = undefined; + this.#reservedSelections = undefined; + 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 } + ), + + addEventListener( + window, + 'resize', + () => { + this.#handleLayoutResize(); + }, + { 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, true); + } else { + const nextMatch = findNexMatch(textDocument, selections); + if (nextMatch !== undefined) { + this.#updateSelections(nextMatch, true); + 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.#tabSize, + outdent + ); + edits.push(...ret[0]); + nextSelections.push(ret[1]); + } else { + const lineChar0 = textDocument.charAt({ + line: startLine, + character: 0, + }); + this.#replaceSelectionText( + lineChar0 === '\t' ? '\t' : ' '.repeat(this.#tabSize) + ); + } + } + if (edits.length > 0) { + const change = textDocument.applyEdits( + edits, + true, + this.#selections, + nextSelections + ); + if (change !== undefined) { + this.#applyChange(change, nextSelections); + } + } + } + break; + + case 'selectAll': + this.#updateSelections([getDocumentFullSelection(textDocument)]); + break; + + case 'moveCursorToDocStart': + case 'moveCursorToDocEnd': + { + const atEnd = command === 'moveCursorToDocEnd'; + this.#updateSelections( + [getDocumentBoundarySelection(textDocument, atEnd)], + true + ); + this.#scrollToLine(atEnd ? textDocument.lineCount - 1 : 0, true); + } + break; + + case 'expandSelectionDocStart': + case 'expandSelectionDocEnd': + { + const atEnd = command === 'expandSelectionDocEnd'; + const selections = this.#selections; + if (selections !== undefined && selections.length > 0) { + this.#updateSelections( + extendSelections( + selections, + getDocumentBoundarySelection(textDocument, atEnd) + ), + true + ); + this.#scrollToLine(atEnd ? textDocument.lineCount - 1 : 0, true); + } + } + 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, true); + } + } + + #rerender( + change: TextDocumentChange, + nextLineAnnotations?: 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 isAdvancedMode = this.#editMode === 'advanced'; + const dirtyLines = tokenizer.tokenize(change, renderRange); + const t = 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, + nextLineAnnotations, + shouldUpdateBuffer + ); + } + + console.debug( + `[diffs/editor] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, + 'lastChange:', + change, + 'dirtyLines:', + dirtyLines.size + ); + } + + #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; + case 'deleteHardLineForward': + this.#deleteHardLineForward(); + break; + case 'insertTranspose': + this.#insertTranspose(); + break; + default: + console.warn(`[diffs] Unknown input type: ${inputType}`); + break; + } + } + + #updateSelections( + selections: EditorSelection[], + updateWindowSelection = false + ) { + 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); + requestAnimationFrame(() => { + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + this.#selectionElements = renderCtx.elements; + if (updateWindowSelection) { + this.#updateWindowSelection(primarySelection); + this.#contentElement?.focus({ preventScroll: true }); + } + }); + } + + #updateWindowSelection(primarySelection: EditorSelection) { + const winSelection = window.getSelection(); + if (winSelection === null) { + return; + } + let { start, end, direction } = primarySelection; + if (comparePosition(start, end) > 0) { + [start, end] = [end, start]; + } + const startLineElement = this.#getLineElement(start.line); + const endLineElement = this.#getLineElement(end.line); + if (startLineElement === undefined || endLineElement === undefined) { + return; + } + let [anchorNode, anchorOffset] = getSelectionAnchor( + startLineElement, + start.character + ); + let [focusNode, focusOffset] = getSelectionAnchor( + endLineElement, + end.character + ); + if (direction === DirectionBackward) { + [anchorNode, anchorOffset, focusNode, focusOffset] = [ + focusNode, + focusOffset, + anchorNode, + anchorOffset, + ]; + } + this.#shouldIgnoreSelectionChange = true; + try { + winSelection.setBaseAndExtent( + anchorNode, + clampDomOffset(anchorNode, anchorOffset), + focusNode, + clampDomOffset(focusNode, focusOffset) + ); + } finally { + setTimeout(() => { + this.#shouldIgnoreSelectionChange = false; + }, 0); + } + } + + #scrollToPrimaryCaret() { + if (this.#primaryCaretElement !== undefined) { + this.#primaryCaretElement.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } else if (this.#selections !== undefined && this.#selections.length > 0) { + const primarySelection = this.#selections.at(-1)!; + const { start, end, direction } = primarySelection; + const isBackward = direction === DirectionBackward; + this.#scrollToLine(isBackward ? start.line : end.line, false); + } + } + + #scrollToLine(line: number, forceFocus = false) { + const lineElement = this.#getLineElement(line); + if (lineElement !== undefined) { + const scrollAndFoucus = () => { + lineElement.scrollIntoView({ block: 'center', inline: 'start' }); + if (forceFocus) { + requestAnimationFrame(() => { + this.#contentElement?.focus({ preventScroll: true }); + }); + } + }; + if (this.#scrollingToLine !== undefined) { + this.#scrollingToLine = undefined; + this.#scrollingForceFocus = undefined; + requestAnimationFrame(scrollAndFoucus); + } else { + scrollAndFoucus(); + } + } + // 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.#lineHeight; + const anchor = h('span', { + style: { + position: 'absolute', + top: approximateLineY + 'px', + left: '0', + width: '1px', + height: '1px', + }, + }); + this.#componentContainer?.shadowRoot?.appendChild(anchor); + this.#scrollingToLine = line; + this.#scrollingForceFocus = forceFocus; + anchor.scrollIntoView({ block: 'center', inline: 'start' }); + requestAnimationFrame(() => anchor.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.#charWidth; // 1ch, align to diff css: padding-inline: 1ch + const contentWidth = this.#getContentWidth(); + const textWidth = 2 * paddingInline + this.#measureTextWidth(lineText); + if (textWidth > contentWidth) { + this.#renderWrappedSelection( + renderCtx, + selection, + ln, + lineText, + startChar, + endChar, + paddingInline + ); + continue; + } + } + + let left = 0; + let width = 0; + if (startChar === endChar && startChar === 0) { + left = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch) + width = ln === end.line ? 0 : this.#charWidth; + } else { + left = this.#getCharX(ln, startChar)[0]; + width = + endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left; + } + this.#renderSelectionRange( + renderCtx, + selection, + ln, + 0, + startChar, + endChar, + width, + left + ); + } + } + + // Render the selection on a wrapped logical line by splitting it into one + // selection-range div per visual sub-line. For each wrap segment, we compute + // the intersection with the line's selection range and render the slice in + // segment-local coordinates so left/width line up with the visually wrapped + // text. Zero-width slices that fall on intermediate segment boundaries are + // skipped to avoid duplicate markers across consecutive visual lines. + #renderWrappedSelection( + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, + selection: EditorSelection, + line: number, + lineText: string, + startChar: number, + endChar: number, + paddingInline: number + ) { + const wrapOffsets = this.#wrapLineText(line); + const segmentCount = wrapOffsets.length - 1; + const lastSegmentIndex = segmentCount - 1; + const offsetLeft = this.#getGutterWidth() + paddingInline; + + for (let w = 0; w < segmentCount; w++) { + const segmentStart = wrapOffsets[w]; + const segmentEnd = wrapOffsets[w + 1]; + const wrapStartChar = Math.max(startChar, segmentStart); + const wrapEndChar = Math.min(endChar, segmentEnd); + + // Selection range doesn't reach this visual segment. + if (wrapStartChar > wrapEndChar) { + continue; + } + + // Zero-width slices on segment boundaries can appear on two consecutive + // segments (end of one, start of the next). Only render at the natural + // anchor positions: the very beginning of the first visual line, or the + // very end of the last visual line. + if (wrapStartChar === wrapEndChar) { + const isAtLineStart = wrapStartChar === 0 && w === 0; + const isAtLineEnd = + wrapEndChar === lineText.length && w === lastSegmentIndex; + if (!isAtLineStart && !isAtLineEnd) { + continue; + } + } + + let segmentLeft: number; + let segmentWidth: number; + if (wrapStartChar === 0 && wrapEndChar === 0) { + // Empty range pinned to line start (e.g. multi-line selection ending + // with end.character === 0). Mirrors the non-wrap path. + segmentLeft = offsetLeft; + segmentWidth = line === selection.end.line ? 0 : paddingInline; + } else { + const prefixInSegment = lineText.slice(segmentStart, wrapStartChar); + const prefixAsciiWidth = + this.#getExpandedAsciiTextWidth(prefixInSegment); + segmentLeft = + offsetLeft + + (prefixAsciiWidth !== -1 + ? prefixAsciiWidth + : this.#measureTextWidth(prefixInSegment)); + + if (wrapStartChar === wrapEndChar) { + segmentWidth = 0; + } else { + const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); + const selectionAsciiWidth = + this.#getExpandedAsciiTextWidth(selectionInSegment); + segmentWidth = + selectionAsciiWidth !== -1 + ? selectionAsciiWidth + : this.#measureTextWidth(selectionInSegment); + } + } + + this.#renderSelectionRange( + renderCtx, + selection, + line, + w, + wrapStartChar, + wrapEndChar, + segmentWidth, + segmentLeft, + w === lastSegmentIndex + ); + } + } + + // Render one selection range div for a single visual line. `applyEolSpacing` + // controls whether the trailing one-character "line continuation" marker is + // appended at the end. For wrapped logical lines this must be false on every + // visual segment except the last one, since an intra-line wrap is not a real + // newline and shouldn't visually extend past the wrapped content. + #renderSelectionRange( + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, + selection: EditorSelection, + ln: number, + wrapLine: number, + startChar: number, + endChar: number, + width: number, + left: number, + applyEolSpacing = true + ) { + const spacing = + !applyEolSpacing || + selection.end.line === ln || + (startChar === endChar && ln !== selection.start.line) + ? 0 + : this.#charWidth; + const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`; + const cacheKey = 'selection-range-' + css; + const selectionEls = this.#selectionElements; + + if (renderCtx.elements.has(cacheKey)) { + return; + } + + let rangeEl: HTMLElement | undefined; + if (selectionEls?.has(cacheKey) === true) { + rangeEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); + } else { + rangeEl = 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.#lineHeight}px) translateX(${left - 1}px)`, + }, + }, + renderCtx.fragment + ); + renderCtx.elements.set(cacheKey, caretEl); + if (isPrimary) { + 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.#lineHeight, + renderCtx.fragment, + () => { + const cleanUpQuickEdit = () => { + this.#quickEdit?.cleanup(); + this.#quickEdit = undefined; + }; + + const handleResize = () => { + // the line y cache is invalidated by the DOM change, + // clear the line y cache and rerender the selection + this.#lineYCache.clear(); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections, true); + } + }; + + // 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: selection, + close: () => { + cleanUpQuickEdit(); + handleResize(); + }, + replaceSelectionText: (text: string) => { + this.#replaceSelectionText(text); + }, + }); + 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.#tabSize; + } else { + break; + } + } + this.#selections = [selection]; + this.#quickEdit = new QuickEditWidget( + line, + quickEditElement, + fileContainer, + leadingWhitespaces, + handleResize + ); + if (this.#isLineVisible(line) && this.#contentElement !== undefined) { + this.#quickEdit.render(this.#contentElement); + } + } + ); + renderCtx.elements.set(cacheKey, quickEditIcon); + } + + #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, true); + 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( + preElement, + defaultQuery, + initialMatch, + (kind, params, retainFocus) => this.#search(kind, params, retainFocus), + (params) => textDocument.search('findAll', params), + () => { + this.#searchPanel = undefined; + this.#retainSearchPanelFocus = false; + } + ); + this.#retainSearchPanelFocus = false; + } + + #search( + kind: 'findNext' | 'findPrevious' | 'findAll' | 'replace' | 'replaceAll', + searchParams: DiffsEditorSearchParams, + retainSearchPanelFocus: boolean = false + ): [number, number] | undefined { + const primarySelection = this.#selections?.at(-1); + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return undefined; + } + const matches = textDocument.search(kind, searchParams, primarySelection); + if (matches.length === 0) { + return undefined; + } + + const [startOffset, endOffset] = matches[0]; + const startPosition = textDocument.positionAt(startOffset); + + if (kind === 'findNext' || kind === 'findPrevious' || kind === 'replace') { + const nextSelection = createSelectionFromAnchorAndFocusOffsets( + textDocument, + startOffset, + endOffset + ); + this.#updateSelections([nextSelection], true); + this.#scrollToPrimaryCaret(); + if (retainSearchPanelFocus) { + this.#retainSearchPanelFocus = true; + requestAnimationFrame(() => { + this.#searchPanel?.focus(); + }); + } + return [startOffset, endOffset]; + } else if (kind === 'findAll' || kind === 'replaceAll') { + this.#scrollToLine(startPosition.line); + } + return undefined; + } + + #getSelectionText() { + const textDocument = this.#textDocument; + const selections = this.#selections; + if (textDocument === undefined || selections === undefined) { + return ''; + } + return getSelectionText(textDocument, selections); + } + + // replace the selection text + #replaceSelectionText(text: string | string[]) { + const selections = this.#selections; + if (selections === undefined) { + return; + } + const textDocument = this.#textDocument; + const primarySelection = selections.at(-1); + if (textDocument == null || primarySelection == null) { + return; + } + const lineAnnotations = this.#lineAnnotations; + const { nextSelections, change } = + Array.isArray(text) && text.length === selections.length + ? applyTextReplaceToSelections( + textDocument, + selections, + text, + lineAnnotations + ) + : applyTextChangeToSelections( + textDocument, + selections, + { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: Array.isArray(text) ? text.join('\n') : text, + }, + lineAnnotations + ); + + if (change !== undefined) { + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change) + ); + } + } + + #deleteSelectionText(forward: boolean = false) { + const selections = this.#selections; + const textDocument = this.#textDocument; + if (selections === undefined || textDocument === undefined) { + return; + } + + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + + const edit = isCollapsedSelection(primarySelection) + ? (() => { + const offset = textDocument.offsetAt(primarySelection.start); + const nextOffset = forward + ? Math.min(textDocument.getText().length, offset + 1) + : Math.max(0, offset - 1); + return { + start: Math.min(offset, nextOffset), + end: Math.max(offset, nextOffset), + text: '', + }; + })() + : { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: '', + }; + + 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) { + const selections = this.#selections; + const textDocument = this.#textDocument; + if (selections === undefined || textDocument === undefined) { + return; + } + const { nextSelections, change } = applyTextChangeToSelections( + textDocument, + selections, + edit, + this.#lineAnnotations, + this.#tabSize + ); + if (change !== undefined) { + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change) + ); + } + } + + #applyChange( + change: TextDocumentChange, + selections?: EditorSelection[], + lineAnnotations?: DiffLineAnnotation[] + ) { + const fileContents = this.#fileContents; + const textDocument = this.#textDocument; + const onChange = this.#options.onChange; + if ( + fileContents !== undefined && + textDocument !== undefined && + onChange !== undefined + ) { + const { name, lang, cacheKey } = fileContents; + const file = { name, lang, cacheKey } as FileContents; + Object.defineProperty(file, 'contents', { + get: () => textDocument.getText(), + }); + this.#emitChange(file, lineAnnotations ?? 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, lineAnnotations, renderRange, shouldUpdateBuffer); + + if (selections !== undefined) { + // since we prevent the default input event, + // we need to update the window selection manually + // and scroll to the primary caret to mock the input behavior. + this.#updateSelections(selections, true); + this.#scrollToPrimaryCaret(); + } + } + + #applyChangeToLineAnnotations( + change: TextDocumentChange + ): DiffLineAnnotation[] | undefined { + if (this.#lineAnnotations !== undefined) { + const nextLineAnnotations = + applyDocumentChangeToLineAnnotations( + change, + this.#lineAnnotations + ); + if (nextLineAnnotations !== this.#lineAnnotations) { + this.#textDocument?.setLastUndoLineAnnotationsAfter( + nextLineAnnotations + ); + return nextLineAnnotations; + } + } + return undefined; + } + + #getLineElement(line: number): HTMLElement | undefined { + const 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.#lineYCache.set(line, y); + return y; + } + + // Return the visual position for a character. Wrapped lines include the + // visual line index so carets can be placed on the correct row. + #getCharX(line: number, char: number): [x: number, wrapLine: number] { + if ( + this.#lastCharX !== undefined && + this.#lastCharX[0] === line && + this.#lastCharX[1] === char + ) { + return [this.#lastCharX[2], this.#lastCharX[3]]; + } + + const lineText = this.#textDocument?.getLineText(line); + const offsetLeft = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch) + if (lineText === undefined || lineText.length === 0 || char <= 0) { + return [offsetLeft, 0]; + } + + const boundedCharacter = snapTextOffsetToUnicodeBoundary( + lineText, + Math.min(char, lineText.length) + ); + const textBeforeCharacter = lineText.slice(0, boundedCharacter); + const asciiWidth = this.#getExpandedAsciiTextWidth(textBeforeCharacter); + + let left = 0; + let wrapLine = 0; + if (asciiWidth !== -1) { + left = offsetLeft + asciiWidth; + } else { + left = offsetLeft + this.#measureTextWidth(textBeforeCharacter); + } + + if (this.#wrap) { + const contentWidth = this.#getContentWidth(); + const width = 2 * offsetLeft + this.#measureTextWidth(lineText); + if (width > contentWidth) { + const wrapOffsets = this.#wrapLineText(line); + for (let w = 0; w + 1 < wrapOffsets.length; w++) { + const segmentStart = wrapOffsets[w]; + const segmentEnd = wrapOffsets[w + 1]; + if (boundedCharacter <= segmentEnd) { + wrapLine = w; + const prefixInSegment = lineText.slice( + segmentStart, + boundedCharacter + ); + const segmentAsciiWidth = + this.#getExpandedAsciiTextWidth(prefixInSegment); + if (segmentAsciiWidth !== -1) { + left = offsetLeft + segmentAsciiWidth; + } else { + left = offsetLeft + this.#measureTextWidth(prefixInSegment); + } + break; + } + } + } + } + + if (this.#lastCharX !== undefined) { + this.#lastCharX[0] = line; + this.#lastCharX[1] = char; + this.#lastCharX[2] = left; + this.#lastCharX[3] = wrapLine; + } else { + this.#lastCharX = [line, char, left, wrapLine]; + } + + return [left, wrapLine]; + } + + #getExpandedAsciiTextWidth(text: string) { + let columns = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 127) { + return -1; + } + columns += text.charCodeAt(i) === /* '\t' */ 9 ? this.#tabSize : 1; + } + return columns * this.#charWidth; + } + + #measureTextWidth(text: string) { + if (this.#measureCtx === undefined) { + throw new Error('Measure context not initialized'); + } + const textWithExpandedTabs = text.replaceAll( + '\t', + ' '.repeat(this.#tabSize) + ); + if (needsDomTextMeasurement(textWithExpandedTabs)) { + return measureDomTextWidth( + textWithExpandedTabs, + this.#contentElement, + this.#measureCtx + ); + } + return this.#measureCtx.measureText(textWithExpandedTabs).width; + } + + // Compute how a logical line of text is broken into visual lines when line + // wrapping is enabled. + #wrapLineText(line: number): Uint32Array { + const cachedOffsets = this.#wrapLineOffsetsCache.get(line); + if (cachedOffsets !== undefined) { + return cachedOffsets; + } + + const lineText = this.#textDocument?.getLineText(line); + if (lineText === undefined || lineText.length === 0) { + const offsets = new Uint32Array([0]); + this.#wrapLineOffsetsCache.set(line, offsets); + return offsets; + } + + const div = h( + 'div', + { + style: { + position: 'absolute', + top: '0', + left: '0', + width: '100%', + visibility: 'hidden', + pointerEvents: 'none', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + font: 'inherit', + paddingInline: '1ch', + tabSize: this.#tabSize.toString(), + }, + textContent: lineText, + }, + this.#contentElement + ); + const textNode = div.firstChild as Text; + const range = document.createRange(); + const starts: number[] = []; + + try { + const unicodeOffsets = getUnicodeMeasurementOffsets(lineText); + 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 { top } = range.getBoundingClientRect(); + if (top > lastTop) { + starts.push(i); + lastTop = top; + } + 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 { + if (this.#editMode === 'advanced') { + return true; + } + 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..17c1cb919 --- /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[] { + if (change.lineDelta === 0) { + return lineAnnotations; + } + + const startCharacter = change.startCharacter; + const removedLineCount = Math.max(0, -change.lineDelta); + const deletedStartLine = + removedLineCount === 0 + ? undefined + : change.startLine + (startCharacter === 0 ? 0 : 1); + const deletedEndLine = + deletedStartLine === undefined + ? undefined + : deletedStartLine + removedLineCount; + const shiftFromLine = + removedLineCount > 0 + ? change.startLine + removedLineCount + : change.startLine + (startCharacter === 0 ? 0 : 1); + const nextLineAnnotations: 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 : lineAnnotations; +} diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts new file mode 100644 index 000000000..6f386c12b --- /dev/null +++ b/packages/diffs/src/editor/pieceTable.ts @@ -0,0 +1,998 @@ +import type { DiffsEditorSearchParams } from '../types'; +import { computeLineOffsets } from '../utils/computeFileOffsets'; +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: DiffsEditorSearchParams, + 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..e2cd6a313 --- /dev/null +++ b/packages/diffs/src/editor/platform.ts @@ -0,0 +1,27 @@ +let _isMacLike: boolean | undefined = undefined; +let _isLinux: boolean | undefined = undefined; + +export function isMacLike(): boolean { + return ( + _isMacLike ?? + (_isMacLike = /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform())) + ); +} + +export function isLinux(): boolean { + return _isLinux ?? (_isLinux = /Linux/i.test(getPlatform())); +} + +export function isPrimaryModifier( + { metaKey, ctrlKey }: MouseEvent | KeyboardEvent, + isMac: boolean = isMacLike() +): boolean { + return isMac ? metaKey && !ctrlKey : ctrlKey && !metaKey; +} + +function getPlatform(): string { + const navigator = globalThis.navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return navigator?.platform ?? navigator?.userAgentData?.platform ?? 'unknown'; +} diff --git a/packages/diffs/src/editor/quickEdit.ts b/packages/diffs/src/editor/quickEdit.ts new file mode 100644 index 000000000..a2a22c885 --- /dev/null +++ b/packages/diffs/src/editor/quickEdit.ts @@ -0,0 +1,108 @@ +import { h } from './utils'; + +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(contentElement: HTMLElement): void { + const gutterElement = + contentElement.previousElementSibling as HTMLElement | null; + const lineNumber = this.line + 1; + const gutterLineElement = gutterElement?.querySelector( + `[data-column-number="${lineNumber}"]` + ); + const contentLineElement = contentElement.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; + contentElement.style.gridRow = 'span ' + contentElement.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..513910e03 --- /dev/null +++ b/packages/diffs/src/editor/searchPanel.ts @@ -0,0 +1,266 @@ +import type { DiffsEditorSearchParams } from '../types'; +import { isPrimaryModifier } from './platform'; +import { h } from './utils'; + +export class SearchPanelWidget { + #container: HTMLDivElement; + #inputElement: HTMLInputElement; + #matchesElement: HTMLDivElement; + #searchParams: DiffsEditorSearchParams; + #allMatches: [number, number][] = []; + + constructor( + preElement: HTMLElement, + defaultQuery: string, + initialMatch: [number, number] | undefined, + search: ( + action: + | 'findNext' + | 'findPrevious' + | 'findAll' + | 'replace' + | 'replaceAll', + params: DiffsEditorSearchParams, + retainFocus?: boolean + ) => [number, number] | undefined, + findAll: (params: DiffsEditorSearchParams) => [number, number][], + onClose: () => void + ) { + this.#searchParams = { + text: defaultQuery, + replaceText: '', + caseSensitive: false, + wholeWord: false, + regex: false, + }; + + const close = () => { + this.cleanup(); + onClose(); + }; + + const updateAllMatches = () => { + this.#allMatches = + this.#searchParams.text !== '' ? findAll(this.#searchParams) : []; + this.#container + .querySelectorAll('[data-disabled]') + .forEach((element) => { + element.dataset.disabled = String(this.#allMatches.length === 0); + }); + }; + + const updatSearchParam = ( + key: K, + value: DiffsEditorSearchParams[K] + ) => { + this.#searchParams[key] = value; + updateAllMatches(); + this.updateMatches(); + }; + + const settingsSwitch = h('div', { + dataset: { icon: 'settings' }, + title: 'Settings', + innerHTML: ` + + + + + + + + `, + onclick: () => { + settingsSwitch.replaceWith(settingsPanel); + }, + }); + const settingsPanel = h('div', { + dataset: 'settings', + children: [ + h('label', { + dataset: 'checkbox', + children: [ + h('input', { + type: 'checkbox', + checked: this.#searchParams.caseSensitive, + onchange: (e: Event) => { + updatSearchParam( + 'caseSensitive', + (e.target as HTMLInputElement).checked + ); + }, + }), + 'Match Case', + ], + }), + h('label', { + dataset: 'checkbox', + children: [ + h('input', { + type: 'checkbox', + checked: this.#searchParams.wholeWord, + onchange: (e: Event) => { + updatSearchParam( + 'wholeWord', + (e.target as HTMLInputElement).checked + ); + }, + }), + 'Whole Word', + ], + }), + h('label', { + dataset: 'checkbox', + children: [ + h('input', { + type: 'checkbox', + checked: this.#searchParams.regex, + onchange: (e: Event) => { + updatSearchParam( + 'regex', + (e.target as HTMLInputElement).checked + ); + }, + }), + 'Regexp', + ], + }), + ], + onmouseleave: () => { + closeSettingsPanelTimeout = setTimeout(() => { + settingsPanel.replaceWith(settingsSwitch); + }, 500); + }, + onmouseenter: () => { + clearTimeout(closeSettingsPanelTimeout); + closeSettingsPanelTimeout = undefined; + }, + }); + let closeSettingsPanelTimeout: ReturnType | undefined; + + 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(); + const match = search('findNext', this.#searchParams, true); + this.updateMatches(match); + } 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, + h('div', { + dataset: { icon: 'arrow-up', disabled: 'true' }, + title: 'Previous', + innerHTML: ` + + + `, + onclick: () => { + const match = search('findPrevious', this.#searchParams, true); + this.updateMatches(match); + }, + }), + h('div', { + dataset: { icon: 'arrow-down', disabled: 'true' }, + title: 'Next', + innerHTML: ` + + + `, + onclick: () => { + const match = search('findNext', this.#searchParams, true); + this.updateMatches(match); + }, + }), + h('div', { dataset: 'spacer' }), + settingsSwitch, + h('div', { + dataset: { icon: 'close' }, + title: 'Close', + innerHTML: ` + + + + `, + onclick: close, + }), + ], + }), + ], + }); + + preElement.before(this.#container); + + requestAnimationFrame(() => { + if (initialMatch !== undefined) { + updateAllMatches(); + this.updateMatches(initialMatch); + } + this.#inputElement.select(); + }); + } + + updateMatches(currentMatch: [number, number] = this.#allMatches[0]): void { + const allMatches = this.#allMatches; + const searchText = this.#searchParams.text; + + if (searchText === '') { + this.#matchesElement.textContent = ''; + delete this.#matchesElement.dataset.noMatches; + return; + } + + 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..2fea26ab3 --- /dev/null +++ b/packages/diffs/src/editor/selection.ts @@ -0,0 +1,1471 @@ +import type { DiffLineAnnotation } from '../types'; +import type { + Position, + Range, + ResolvedTextEdit, + TextDocument, + TextDocumentChange, + TextEdit, +} from './textDocument'; + +export const DirectionBackward = -1; +export const DirectionNone = 0; +export const DirectionForward = 1; + +export type SelectionDirection = + | typeof DirectionBackward + | typeof DirectionNone + | typeof DirectionForward; + +export interface EditorSelection extends Range { + direction: SelectionDirection; +} + +/** + * Converts a selection from a web selection to an editor selection. + */ +export function convertSelection( + range: StaticRange, + direction: SelectionDirection = DirectionNone +): EditorSelection | undefined { + const start = boundaryToPosition(range.startContainer, range.startOffset); + const end = boundaryToPosition(range.endContainer, range.endOffset); + if (start === null || end === null) { + return undefined; + } + return { + start, + end, + direction, + }; +} + +/** + * Resolves the indent edits for a selection. + */ +export function resolveIndentEdits( + textDocument: TextDocument, + selection: EditorSelection, + tabSize: number, + outdent: boolean +): [edits: TextEdit[], nextSelection: EditorSelection] { + if (textDocument === undefined) { + return [[], selection]; + } + const { start, end } = selection; + const edits: TextEdit[] = []; + let newSelection: EditorSelection = { ...selection }; + let endLine = end.line; + if (start.line < end.line && end.character === 0) { + endLine--; + } + for (let line = start.line; line <= endLine; line++) { + const lineText = textDocument.getLineText(line); + if (lineText === undefined) { + continue; + } + const indentUnit = lineText.startsWith('\t') ? '\t' : ' '.repeat(tabSize); + let deleteLength = 0; + let newText = indentUnit; + if (outdent) { + if (lineText.startsWith('\t')) { + deleteLength = 1; + } else if (lineText.startsWith(' ')) { + const leadingSpacesLength = + lineText.length - lineText.trimStart().length; + deleteLength = Math.min(indentUnit.length, leadingSpacesLength); + } + if (deleteLength === 0) { + continue; + } + newText = ''; + } + edits.push({ + range: { + start: { line, character: 0 }, + end: { line, character: deleteLength }, + }, + newText, + }); + const delta = newText.length - deleteLength; + if (line === start.line) { + newSelection = { + ...newSelection, + start: { + ...start, + character: Math.max(0, start.character + delta), + }, + }; + } + if (line === end.line) { + newSelection = { + ...newSelection, + end: { + ...end, + character: Math.max(0, end.character + delta), + }, + }; + } + } + return [edits, newSelection]; +} + +/** + * Maps the cursor move to all selections. + * TODO(@ije): use move cursor commands + */ +export function mapCursorMove( + textDocument: TextDocument, + selections: EditorSelection[], + nextPosition: Position +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const deltaOffset = + textDocument.offsetAt(nextPosition) - + textDocument.offsetAt(primarySelection.start); + const deltaLine = nextPosition.line - primarySelection.start.line; + const movedOneChar = deltaOffset === 1 || deltaOffset === -1; + const newSelections: EditorSelection[] = []; + for (const selection of selections) { + let newPosition = nextPosition; + if (selection !== primarySelection) { + if (deltaLine === 0 || movedOneChar) { + newPosition = textDocument.positionAt( + textDocument.offsetAt(selection.start) + deltaOffset + ); + } else { + newPosition = { + line: clamp( + selection.start.line + deltaLine, + 0, + textDocument.lineCount - 1 + ), + character: selection.start.character, + }; + } + } + const newSelection: EditorSelection = { + start: newPosition, + end: newPosition, + direction: DirectionNone, + }; + const previousSelection = newSelections.at(-1); + if ( + previousSelection === undefined || + comparePosition(previousSelection.start, newSelection.start) !== 0 + ) { + newSelections.push(newSelection); + } + } + return newSelections; +} + +/** + * Maps the selection shift to all selections. + */ +export function mapSelectionShift( + textDocument: TextDocument, + selections: EditorSelection[], + selectionShift: EditorSelection +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const [primaryAnchorOffset, primaryFocusOffset] = + getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); + const [shiftAnchorOffset, shiftFocusOffset] = + getSelectionAnchorAndFocusOffsets(textDocument, selectionShift); + const anchorDelta = shiftAnchorOffset - primaryAnchorOffset; + const focusDelta = shiftFocusOffset - primaryFocusOffset; + const mappedSelections: EditorSelection[] = []; + for (const selection of selections) { + const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( + textDocument, + selection + ); + const mappedOffsets = createSelectionFromAnchorAndFocusOffsets( + textDocument, + anchorOffset + anchorDelta, + focusOffset + focusDelta + ); + const newSelection = + !isCollapsedSelection(mappedOffsets) && + selectionShift.direction !== DirectionNone + ? { ...mappedOffsets, direction: selectionShift.direction } + : mappedOffsets; + const previousSelection = mappedSelections.at(-1); + if ( + previousSelection !== undefined && + selectionIntersects(previousSelection, newSelection) + ) { + Object.assign( + previousSelection, + createSelectionFrom(previousSelection, newSelection) + ); + } else { + mappedSelections.push(newSelection); + } + } + return mappedSelections; +} + +/** + * Applies a text change to 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, + undefined, + lineAnnotations + ); + const nextSelections = createSelectionsFromOffsetPairs( + textDocument, + nextSelectionOffsets.map((offsets) => { + if (offsets === undefined) { + throw new Error('Missing next selection offsets'); + } + return offsets; + }) + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + + return { nextSelections, change }; +} + +/** + * Applies a text replace to a selection. + */ +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, + undefined, + lineAnnotations + ); + const nextSelections = createSelectionsFromOffsetPairs( + textDocument, + nextSelectionOffsets.map((offset) => [offset, offset]) + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + 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, + undefined, + lineAnnotations + ); + const nextSelections = createSelectionsFromOffsetPairs( + textDocument, + nextOffsetPairs + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + 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..88abd4765 --- /dev/null +++ b/packages/diffs/src/editor/textDocument.ts @@ -0,0 +1,441 @@ +import type { DiffLineAnnotation, DiffsEditorSearchParams } from '../types'; +import { + coalesceEditStackEntries, + createEditStackEntry, + EditStack, + shouldCoalesceEditStackEntry, +} from './editStack'; +import { PieceTable } from './pieceTable'; +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: DiffsEditorSearchParams, + selection?: Range + ): [start: number, end: number][] { + return this.#pieceTable.search(kind, searchParams, selection); + } + + applyEdits( + edits: TextEdit[], + updateHistory = false, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: DiffLineAnnotation[], + lineAnnotationsAfter?: DiffLineAnnotation[] + ): TextDocumentChange | undefined { + if (edits.length === 0) { + return; + } + return this.applyResolvedEdits( + edits.map((edit) => this.#resolveEdit(edit)), + updateHistory, + selectionsBefore, + selectionsAfter, + lineAnnotationsBefore, + lineAnnotationsAfter + ); + } + + applyResolvedEdits( + edits: ResolvedTextEdit[], + updateHistory = false, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: DiffLineAnnotation[], + lineAnnotationsAfter?: DiffLineAnnotation[] + ): TextDocumentChange | undefined { + if (edits.length === 0) { + return; + } + const resolvedEdits = this.#sortAndValidateResolvedEdits(edits); + if (updateHistory) { + const entry = createEditStackEntry( + this, + resolvedEdits, + this.#version, + this.#version + 1, + selectionsBefore, + selectionsAfter, + lineAnnotationsBefore, + lineAnnotationsAfter + ); + 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); + } + + setLastUndoLineAnnotationsAfter( + lineAnnotations: DiffLineAnnotation[] + ): void { + this.#editStack.setLastUndoLineAnnotationsAfter(lineAnnotations); + } + + 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..7c11bbe67 --- /dev/null +++ b/packages/diffs/src/editor/textMeasure.ts @@ -0,0 +1,94 @@ +import { h } from './utils'; + +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; +} + +// 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. +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; + } + 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; +} + +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; +} + +export function measureDomTextWidth( + text: string, + containerElement: HTMLElement | undefined, + measureCtx: CanvasRenderingContext2D | undefined +): number { + if (containerElement === undefined || measureCtx === undefined) { + return measureCtx?.measureText(text).width ?? 0; + } + const measureEl = h( + 'span', + { + style: { + position: 'absolute', + top: '0', + left: '0', + visibility: 'hidden', + pointerEvents: 'none', + whiteSpace: 'pre', + font: 'inherit', + }, + textContent: text, + }, + containerElement + ); + try { + return measureEl.getBoundingClientRect().width; + } finally { + measureEl.remove(); + } +} diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts new file mode 100644 index 000000000..cdc8d65bd --- /dev/null +++ b/packages/diffs/src/editor/tokenzier.ts @@ -0,0 +1,517 @@ +import { + EncodedTokenMetadata, + type IGrammar, + INITIAL, + type StateStack, +} from 'shiki/textmate'; + +import type { DiffsHighlighter, HighlightedToken, RenderRange } from '../types'; +import type { TextDocument, TextDocumentChange } from './textDocument'; +import { debounce, h } from './utils'; + +export interface EditorTokenizerProps { + highlighter: DiffsHighlighter; + theme: { name: string; type: 'dark' | 'light' }; + textDocument: TextDocument; + tokenizeMaxLineLength?: number; + 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; + #themeType: 'light' | 'dark'; + #colorMap: string[]; + #textDocument: TextDocument; + #tokenizeMaxLineLength: number; + #onDeferTokenize: EditorTokenizerProps['onDeferTokenize']; + + // state + #stateStackMap: 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); + } + }; + + constructor({ + highlighter, + theme, + textDocument, + tokenizeMaxLineLength, + onDeferTokenize: onTokenize, + }: EditorTokenizerProps) { + this.#highlighter = highlighter; + this.#themeType = theme.type; + this.#colorMap = highlighter.setTheme(theme.name).colorMap; + this.#textDocument = textDocument; + this.#tokenizeMaxLineLength = tokenizeMaxLineLength ?? 1000; + this.#onDeferTokenize = onTokenize; + if (highlighter.getLoadedLanguages().includes(textDocument.languageId)) { + this.#grammar = highlighter.getLanguage(textDocument.languageId); + } + } + + get themeType(): 'light' | 'dark' { + return this.#themeType; + } + + // 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 t = performance.now(); + 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.#stateStackMap.length = Math.min( + this.#stateStackMap.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.#stateStackMap[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.#stateStackMap[offscreenLine] ?? INITIAL; + for (; offscreenLine < offscreenEnd; offscreenLine++) { + const resolved = this.#tokenizeLineAt(offscreenLine, offscreenState); + offscreenState = resolved.state; + offscreenDirtyLines.set(offscreenLine, resolved.resolvedTokens); + } + if (canCacheTokenizedStates) { + this.#stateStackMap[offscreenEnd] = offscreenState; + } + } + } + for (; line < renderRangeEndLine; ) { + const previousNextState = canReuseCachedStates + ? this.#stateStackMap[line + 1] + : undefined; + if (canCacheTokenizedStates) { + this.#stateStackMap[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.#stateStackMap[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.#stateStackMap[nextRange[0]] === undefined) { + currentChangedRangeEnd = nextRange[1]; + line++; + } else { + line = nextRange[0]; + state = this.#stateStackMap[line] ?? state; + currentChangedRangeEnd = nextRange[1]; + } + settled = false; + continue; + } + line++; + } + + if (canCacheTokenizedStates) { + if (line < renderRangeEndLine) { + this.#stateStackMap[line + 1] = state; + } else { + this.#stateStackMap[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 + ); + } + + console.debug( + `[diffs/editor] tokenize time: ${Math.round((performance.now() - t) * 1000) / 1000}ms` + ); + + 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; + + addEventListener('message', this.#onMessage); + this.#postBackgroundTokenizeMessage(jobId); + } + + #postBackgroundTokenizeMessage(jobId: number): void { + // use `postMessage` instead of `setTimeout(fn, 0)` to avoid 4ms delay + 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.#stateStackMap.length > boundedEndAt || + this.#grammar === undefined + ) { + return; + } + let line = this.#stateStackMap.length - 1; + let state = this.#stateStackMap[line] ?? INITIAL; + for (; line < boundedEndAt; line++) { + this.#stateStackMap[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.#stateStackMap[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.#stateStackMap[line] ?? INITIAL; + let settled = false; + let changedRangeIndex = this.#backgroundChangedRangeIndex; + let currentChangedRangeEnd = changedLineRanges?.[changedRangeIndex]?.[1]; + for (; line < totalLines; ) { + this.#stateStackMap[line] = state; + + const previousNextState = + currentChangedRangeEnd !== undefined + ? this.#stateStackMap[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.#stateStackMap[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.#stateStackMap[nextRange[0]] === undefined) { + settled = false; + } else { + line = nextRange[0]; + state = this.#stateStackMap[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..a1c4605de --- /dev/null +++ b/packages/diffs/src/editor/utils.ts @@ -0,0 +1,86 @@ +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: HTMLElement | Document | ShadowRoot | Window, + event: string, + listener: EventListener, + options?: AddEventListenerOptions +) { + el.addEventListener(event, listener, options); + return () => el.removeEventListener(event, listener); +} + +export function extend(obj: T, attrs: Partial): T { + return Object.assign(obj, attrs); +} + +// 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/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/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 16e251386..a46fea971 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); @@ -124,6 +132,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 97397e05e..26f59ed52 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -385,7 +385,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 cc9106d75..0f1cb58c4 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 }, @@ -135,12 +135,11 @@ export class FileRenderer { this.renderCache = undefined; this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); - this.lineCache = undefined; } 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() @@ -196,23 +195,111 @@ 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 emitTokenize( + lines: Map>, + themeType: 'dark' | 'light' + ): void { + const result = this.renderCache?.result; + 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; + } + + public emitLineCountChange( + textDocument: DiffsTextDocument, + newLineAnnotations?: LineAnnotation[] + ): void { + const renderCache = this.renderCache; + if (renderCache == null) { + return undefined; + } + const result = renderCache.result; + 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), + }, + ], + }, + ], + }); + } + } + if (newLineAnnotations != null) { + this.setLineAnnotations(newLineAnnotations); + } + this.textDoucmentCache.set(renderCache.file, textDocument); } public renderFile( @@ -233,9 +320,9 @@ export class FileRenderer { }; forceHighlight = false; } - const lines = this.getOrCreateLineCache(file); + const lineOffsets = this.getOrCreateLineOffsets(file); const forcePlainText = isFileMassive( - lines.length, + lineOffsets.length, this.getTokenizeMaxLength() ); this.renderCache ??= { @@ -261,7 +348,7 @@ export class FileRenderer { file, renderRange.startingLine, renderRange.totalLines, - lines + lineOffsets ); this.renderCache.renderRange = renderRange; } @@ -342,9 +429,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 @@ -385,66 +472,68 @@ export class FileRenderer { renderRange: RenderRange, { code, themeStyles, baseThemeType }: ThemedFileResult ): FileRenderResult { + const totalLines = this.getLineCount(file); const { disableFileHeader = false } = this.options; const contentArray: ElementContent[] = []; const gutter = createGutterWrapper(); - const lines = this.getOrCreateLineCache(file); + const endLine = Math.min( + renderRange.startingLine + renderRange.totalLines, + totalLines + ); let rowCount = 0; - iterateOverFile({ - lines, - startingLine: renderRange.startingLine, - totalLines: renderRange.totalLines, - callback: ({ lineIndex, lineNumber }) => { - // Sparse array - directly indexed by lineIndex - const line = code[lineIndex]; - if (line == null) { - const message = 'FileRenderer.processFileResult: Line doesnt exist'; - console.error(message, { - name: file.name, - lineIndex, - lineNumber, - lines, - }); - throw new Error(message); - } - - if (line != null) { - // Add gutter line number - gutter.children.push( - createGutterItem('context', lineNumber, `${lineIndex}`) - ); - contentArray.push(line); - rowCount++; - - // Check annotations using ACTUAL line number from file - const annotations = this.lineAnnotations[lineNumber]; - if (annotations != null) { - gutter.children.push(createGutterGap('context', 'annotation', 1)); - contentArray.push( - createAnnotationElement({ - type: 'annotation', - hunkIndex: 0, - lineIndex: lineNumber, - annotations: annotations.map((annotation) => - getLineAnnotationName(annotation) - ), - }) - ); - rowCount++; - } - } - }, - }); + for ( + let lineIndex = renderRange.startingLine; + lineIndex < endLine; + lineIndex++ + ) { + const lineNumber = lineIndex + 1; + + // Sparse array - directly indexed by lineIndex + const line = code[lineIndex]; + if (line == null) { + const message = 'FileRenderer.processFileResult: Line doesnt exist'; + console.error(message, { + name: file.name, + lineIndex, + lineNumber, + }); + throw new Error(message); + } + + // Add gutter line number + gutter.children.push( + createGutterItem('context', lineNumber, `${lineIndex}`) + ); + contentArray.push(line); + rowCount++; + + // Check annotations using ACTUAL line number from file + const annotations = this.lineAnnotations[lineNumber]; + if (annotations != null) { + gutter.children.push(createGutterGap('context', 'annotation', 1)); + contentArray.push( + createAnnotationElement({ + type: 'annotation', + hunkIndex: 0, + lineIndex: lineNumber, + annotations: annotations.map((annotation) => + getLineAnnotationName(annotation) + ), + }) + ); + rowCount++; + } + } // Finalize: wrap gutter and content gutter.properties.style = `grid-row: span ${rowCount}`; return { gutterAST: gutter.children ?? [], contentAST: contentArray, - preAST: this.createPreElement(lines.length), + preAST: this.createPreElement(totalLines), headerAST: !disableFileHeader ? this.renderHeader(file) : undefined, - totalLines: lines.length, + totalLines: totalLines, rowCount, themeStyles: themeStyles, baseThemeType, diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index 85a15f21c..44cccdf0b 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -34,6 +34,7 @@ --diffs-bg-context-override --diffs-bg-context-gutter-override --diffs-bg-separator-override + --diffs-bg-caret-override --diffs-fg-number-override --diffs-fg-number-addition-override @@ -131,6 +132,14 @@ var(--diffs-fg-number) ); + --diffs-bg-caret: var( + --diffs-bg-caret-override, + light-dark( + color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)), + color-mix(in lab, var(--diffs-fg) 75%, var(--diffs-bg)) + ) + ); + --diffs-deletion-base: var( --diffs-deletion-color-override, light-dark( @@ -326,7 +335,8 @@ [data-line-annotation], [data-no-newline], [data-merge-conflict], - [data-merge-conflict-actions] { + [data-merge-conflict-actions], + [data-editor-overlay] { /* Pre-fill css variables for appropriate up-mixing */ --diffs-computed-decoration-bg: var(--diffs-bg); --diffs-computed-diff-line-bg: var(--diffs-bg); @@ -676,12 +686,14 @@ [data-line-annotation], [data-merge-conflict], [data-merge-conflict-actions], - [data-no-newline] { + [data-no-newline], + [data-editor-overlay] { --diffs-selection-mix-target: var( --diffs-bg-selection-override, var(--diffs-selection-base) ); + &:where([data-editor-overlay]), &:where( [data-line], [data-line-annotation], @@ -724,6 +736,7 @@ } } + &:where([data-editor-overlay]), &[data-selected-line] { --diffs-computed-selected-line-bg: light-dark( color-mix( diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index b8972b490..e34098b7a 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -39,6 +39,8 @@ export interface FileContents { export type HighlighterTypes = 'shiki-js' | 'shiki-wasm'; +export type HighlightedToken = [char: number, fg: string, text: string]; + export type { BundledLanguage, CodeToHastOptions, @@ -705,7 +707,7 @@ export interface ForceFilePlainTextOptions { startingLine?: number; totalLines?: number; // Pre-split lines for caching in windowing scenarios - lines?: string[]; + lineOffsets?: number[]; } export interface RenderFileOptions { @@ -875,3 +877,65 @@ export interface StickySpecs { topOffset: number; height: number; } + +export interface DiffsEditor { + emitRender( + highlighter: DiffsHighlighter, + fileContainer: HTMLElement, + fileContents: FileContents, + lineAnnotations: LineAnnotation[] | undefined, + renderRange: RenderRange | undefined, + editMode?: 'simple' | 'advanced' + ): void; + cleanUp(): void; +} + +export interface DiffsBaseComponent { + readonly options: BaseCodeOptions; + setOptions: (options: Partial) => void; + setSelectedLines: (range: { start: number; end: number } | null) => void; + rerender(): void; + cleanUp(): void; +} + +export interface DiffsEditableComponent< + LAnnotation, +> extends DiffsBaseComponent { + setupEditor: (editor: DiffsEditor) => () => void; + emitLineChange?: ( + lines: Map>, + themeType: 'dark' | 'light' + ) => void; + emitLayoutChange: ( + textDocument: DiffsTextDocument, + newLineAnnotations?: DiffLineAnnotation[], + shouldUpdateBuffer?: boolean + ) => void; +} + +export interface DiffsTextDocument { + lineCount: number; + getLineText: (lineNumber: number) => string; + getText: () => string; +} + +export interface DiffsEditorSelection { + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; + direction: 'none' | 'backward' | 'forward'; +} + +export interface DiffsEditorSearchParams { + text: string; + replaceText: string; + caseSensitive: boolean; + wholeWord: boolean; + regex: boolean; + // searchInSelection: false, +} diff --git a/packages/diffs/src/utils/cleanLastNewline.ts b/packages/diffs/src/utils/cleanLastNewline.ts index 7a6220247..be3a51a52 100644 --- a/packages/diffs/src/utils/cleanLastNewline.ts +++ b/packages/diffs/src/utils/cleanLastNewline.ts @@ -1,3 +1,10 @@ export function cleanLastNewline(contents: string): string { - return contents.replace(/\n$|\r\n$/, ''); + let end = contents.length; + if (contents.charCodeAt(end - 1) === /* \n */ 10) { + end--; + if (contents.charCodeAt(end - 1) === /* \r */ 13) { + end--; + } + } + return contents.slice(0, end); } diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts new file mode 100644 index 000000000..d9877d6aa --- /dev/null +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -0,0 +1,23 @@ +const LINE_FEED = 10; // \n +const CARRIAGE_RETURN = 13; // \r + +/** + * Computes line start offsets for a string. + */ +export function computeLineOffsets(contents: string): number[] { + const offsets: number[] = [0]; + for (let i = 0; i < contents.length; i++) { + const char = contents.charCodeAt(i); + if (char === LINE_FEED || char === CARRIAGE_RETURN) { + if ( + char === CARRIAGE_RETURN && + i + 1 < contents.length && + contents.charCodeAt(i + 1) === LINE_FEED + ) { + i++; + } + offsets.push(i + 1); + } + } + return offsets; +} diff --git a/packages/diffs/src/utils/createTransformerWithState.ts b/packages/diffs/src/utils/createTransformerWithState.ts index 931bddfb8..ffc6ee676 100644 --- a/packages/diffs/src/utils/createTransformerWithState.ts +++ b/packages/diffs/src/utils/createTransformerWithState.ts @@ -82,6 +82,23 @@ export function createTransformerWithState( if (useCSSClasses) { transformers.push(tokenStyleNormalizer, toClass); } + if (useTokenTransformer) { + // shiki renders empty lines as " " that breaks the editor selection. + // We replace them with
tags. + transformers.push({ + line: (node) => { + if (node.type === 'element' && node.children.length === 0) { + node.children.push({ + type: 'element', + tagName: 'br', + properties: {}, + children: [], + }); + } + return node; + }, + }); + } return { state, transformers, toClass }; } diff --git a/packages/diffs/src/utils/iterateOverFile.ts b/packages/diffs/src/utils/iterateOverFile.ts deleted file mode 100644 index 60347eedf..000000000 --- a/packages/diffs/src/utils/iterateOverFile.ts +++ /dev/null @@ -1,82 +0,0 @@ -export interface IterateOverFileProps { - lines: string[]; - startingLine?: number; - totalLines?: number; - callback: FileLineCallback; -} - -export interface FileLineCallbackProps { - lineIndex: number; // 0-based index into lines array - lineNumber: number; // 1-based line number (for display) - content: string; // The line content string - isLastLine: boolean; // True if this is the last line -} - -export type FileLineCallback = (props: FileLineCallbackProps) => boolean | void; - -/** - * Iterates over lines in a file with optional windowing support. - * - * Similar to `iterateOverDiff` but simplified for linear file content. - * Supports viewport windowing for virtualization scenarios. - * - * @param props - Configuration for iteration - * @param props.lines - Pre-split array of lines (use splitFileContents() to create from string) - * @param props.startingLine - Optional starting line index (0-based, default: 0) - * @param props.totalLines - Optional max lines to iterate (default: Infinity) - * @param props.callback - Callback invoked for each line in the window. - * Return `true` to stop iteration early. - * - * @example - * ```typescript - * const lines = splitFileContents('line1\nline2\nline3'); - * iterateOverFile({ - * lines, - * startingLine: 0, - * totalLines: 10, - * callback: ({ lineIndex, lineNumber, content, isLastLine }) => { - * console.log(`Line ${lineNumber}: ${content}`); - * if (content.includes('stop')) return true; // Stop iteration - * } - * }); - * ``` - */ -export function iterateOverFile({ - lines, - startingLine = 0, - totalLines = Infinity, - callback, -}: IterateOverFileProps): void { - // Calculate viewport window - const len = Math.min(startingLine + totalLines, lines.length); - // CLAUDE: DO NOT CHANGE THIS LOGIC UNDER ANY - // CIRCUMSTANCE CHEESE N RICE - const lastLineIndex = (() => { - const lastLine = lines.at(-1); - if ( - lastLine === '' || - lastLine === '\n' || - lastLine === '\r\n' || - lastLine === '\r' - ) { - return Math.max(0, lines.length - 2); - } - return lines.length - 1; - })(); - - // Iterate through windowed range - for (let lineIndex = startingLine; lineIndex < len; lineIndex++) { - const isLastLine = lineIndex === lastLineIndex; - if ( - callback({ - lineIndex, - lineNumber: lineIndex + 1, - content: lines[lineIndex], - isLastLine, - }) === true || - isLastLine - ) { - break; - } - } -} diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index 5b4f83656..e9a5a1089 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -8,14 +8,12 @@ import type { RenderFileOptions, ThemedFileResult, } from '../types'; -import { cleanLastNewline } from './cleanLastNewline'; +import { computeLineOffsets } from './computeFileOffsets'; import { createTransformerWithState } from './createTransformerWithState'; import { formatCSSVariablePrefix } from './formatCSSVariablePrefix'; import { getFiletypeFromFileName } from './getFiletypeFromFileName'; import { getHighlighterThemeStyles } from './getHighlighterThemeStyles'; import { getLineNodes } from './getLineNodes'; -import { iterateOverFile } from './iterateOverFile'; -import { splitFileContents } from './splitFileContents'; const DEFAULT_PLAIN_TEXT_OPTIONS: ForceFilePlainTextOptions = { forcePlainText: false, @@ -33,7 +31,7 @@ export function renderFileWithHighlighter( forcePlainText, startingLine, totalLines, - lines, + lineOffsets, }: ForceFilePlainTextOptions = DEFAULT_PLAIN_TEXT_OPTIONS ): ThemedFileResult { if (forcePlainText) { @@ -88,11 +86,12 @@ export function renderFileWithHighlighter( highlighter.codeToHast( isWindowedHighlight ? extractWindowedFileContent( - lines ?? splitFileContents(file.contents), + file, + lineOffsets ?? computeLineOffsets(file.contents), startingLine, totalLines ) - : cleanLastNewline(file.contents), + : file.contents, hastConfig ) ); @@ -107,18 +106,16 @@ export function renderFileWithHighlighter( } function extractWindowedFileContent( - lines: string[], + file: FileContents, + lineOffsets: number[], startingLine: number, totalLines: number ): string { - let windowContent: string = ''; - iterateOverFile({ - lines, - startingLine, - totalLines, - callback({ content }) { - windowContent += content; - }, - }); - return windowContent; + if (lineOffsets.length === 0) { + return ''; + } + const endLine = Math.min(startingLine + totalLines, lineOffsets.length); + const startOffset = lineOffsets[startingLine] ?? file.contents.length; + const endOffset = lineOffsets[endLine] ?? file.contents.length; + return file.contents.slice(startOffset, endOffset); } diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index ddfdd50d4..4ccb183b8 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -601,7 +601,7 @@ export class WorkerPoolManager { file: FileContents, startingLine: number, totalLines: number, - lines?: string[] + lineOffsets?: number[] ): ThemedFileResult | undefined { if (this.highlighter == null) { this.queueInitialization(); @@ -611,7 +611,7 @@ export class WorkerPoolManager { file, this.highlighter, this.renderOptions, - { forcePlainText: true, startingLine, totalLines, lines } + { forcePlainText: true, startingLine, totalLines, lineOffsets } ); } diff --git a/packages/diffs/test/FileRenderer.ast.test.ts b/packages/diffs/test/FileRenderer.ast.test.ts index d5d04f058..639f00cb5 100644 --- a/packages/diffs/test/FileRenderer.ast.test.ts +++ b/packages/diffs/test/FileRenderer.ast.test.ts @@ -152,6 +152,20 @@ describe('FileRenderer AST Structure', () => { expect(result2.totalLines).toBe(file2Lines); }); + test('should render one content line when the buffer ends with a newline', async () => { + const instance = new FileRenderer(); + const result = await instance.asyncRender({ + name: 'single-line.txt', + contents: 'hello\n', + }); + const [gutter, contentColumn] = instance.renderCodeAST(result) as Element[]; + + expect(result.totalLines).toBe(2); + expect(result.rowCount).toBe(2); + expect(gutter.children).toHaveLength(2); + expect(contentColumn.children).toHaveLength(2); + }); + test('should include CSS property in result', async () => { const instance = new FileRenderer(); const result = await instance.asyncRender(mockFiles.file2); diff --git a/packages/diffs/test/computeLineOffsets.test.ts b/packages/diffs/test/computeLineOffsets.test.ts new file mode 100644 index 000000000..28ed2412e --- /dev/null +++ b/packages/diffs/test/computeLineOffsets.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; + +import { computeLineOffsets } from '../src/utils/computeFileOffsets'; + +describe('computeLineOffsets', () => { + test('returns a single start offset for empty contents', () => { + const result = computeLineOffsets(''); + + expect([...result]).toEqual([0]); + expect(result.length).toBe(1); + }); + + test('computes offsets for single line', () => { + const result = computeLineOffsets('hello'); + + expect([...result]).toEqual([0]); + expect(result.length).toBe(1); + }); + + test('computes offsets for LF files', () => { + const withTerminalNewline = computeLineOffsets('a\nb\n'); + const withoutTerminalNewline = computeLineOffsets('a\nb'); + + expect([...withTerminalNewline]).toEqual([0, 2, 4]); + expect(withTerminalNewline.length).toBe(3); + expect([...withoutTerminalNewline]).toEqual([0, 2]); + expect(withoutTerminalNewline.length).toBe(2); + }); + + test('computes offsets for CRLF and lone CR line endings', () => { + const crlf = computeLineOffsets('a\r\nb\r\n'); + const mixed = computeLineOffsets('a\rb\r\nc\n'); + + expect([...crlf]).toEqual([0, 3, 6]); + expect(crlf.length).toBe(3); + expect([...mixed]).toEqual([0, 2, 5, 7]); + expect(mixed.length).toBe(4); + }); + + test('treats newline-only contents as two offset boundaries', () => { + const lines = computeLineOffsets('\n'); + + expect([...lines]).toEqual([0, 1]); + expect(lines.length).toBe(2); + }); +}); diff --git a/packages/diffs/test/editorCommand.test.ts b/packages/diffs/test/editorCommand.test.ts new file mode 100644 index 000000000..34e9cecc9 --- /dev/null +++ b/packages/diffs/test/editorCommand.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test'; + +import { + type EditorCommand, + resolveEditorCommandFromKeyboardEvent, +} from '../src/editor/command'; + +type ShortcutKeyboardEvent = Pick< + KeyboardEvent, + 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' +>; +type ShortcutCase = { + event: Partial & Pick; + expected: EditorCommand | undefined; +}; + +function event({ + key, + ...overrides +}: Partial & + Pick): KeyboardEvent { + return { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + key, + } as KeyboardEvent; +} + +function withPlatform(platform: string, run: () => void): void { + const navigator = globalThis.navigator; + const originalPlatform = navigator.platform; + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: platform, + }); + + try { + run(); + } finally { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: originalPlatform, + }); + } +} + +function expectShortcuts(platform: string, cases: ShortcutCase[]): void { + const isMac = /macOS|MacIntel|iPhone|iPad|iPod/i.test(platform); + withPlatform(platform, () => { + for (const { event: shortcutEvent, expected } of cases) { + expect( + resolveEditorCommandFromKeyboardEvent(event(shortcutEvent), isMac) + ).toBe(expected); + } + }); +} + +describe('resolveEditorShortcutCommand', () => { + test('uses command shortcuts on macOS', () => { + expectShortcuts('MacIntel', [ + { event: { key: 'z', metaKey: true }, expected: 'undo' }, + { event: { key: 'z', metaKey: true, shiftKey: true }, expected: 'redo' }, + { event: { key: 'a', metaKey: true }, expected: 'selectAll' }, + { + event: { key: 'ArrowUp', metaKey: true }, + expected: 'moveCursorToDocStart', + }, + { + event: { key: 'ArrowDown', metaKey: true }, + expected: 'moveCursorToDocEnd', + }, + ]); + }); + + test('uses control shortcuts on windows and linux', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'z', ctrlKey: true }, expected: 'undo' }, + { event: { key: 'z', ctrlKey: true, shiftKey: true }, expected: 'redo' }, + { event: { key: 'y', ctrlKey: true }, expected: 'redo' }, + { event: { key: 'a', ctrlKey: true }, expected: 'selectAll' }, + { + event: { key: 'Home', ctrlKey: true }, + expected: 'moveCursorToDocStart', + }, + { event: { key: 'End', ctrlKey: true }, expected: 'moveCursorToDocEnd' }, + ]); + }); + + test('ignores modified alt shortcuts and unsupported navigation', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'ArrowUp', ctrlKey: true }, expected: undefined }, + { event: { key: 'z', ctrlKey: true, altKey: true }, expected: undefined }, + ]); + }); + + test('maps tab and shift+tab without primary modifier', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'Tab' }, expected: 'indent' }, + { event: { key: 'Tab', shiftKey: true }, expected: 'outdent' }, + { event: { key: 'Tab', ctrlKey: true }, expected: undefined }, + ]); + }); +}); diff --git a/packages/diffs/test/editorEditStack.test.ts b/packages/diffs/test/editorEditStack.test.ts new file mode 100644 index 000000000..7fcca9c70 --- /dev/null +++ b/packages/diffs/test/editorEditStack.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from 'bun:test'; + +import { createEditStackEntry, EditStack } from '../src/editor/editStack'; +import type { EditorSelection } from '../src/editor/selection'; +import { + DirectionNone, + type SelectionDirection, +} from '../src/editor/selection'; +import { TextDocument } from '../src/editor/textDocument'; + +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = DirectionNone +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + +function caret(character: number) { + return createSelection(0, character, 0, character, DirectionNone); +} + +function stackEntry( + textBeforeEdit: string, + resolvedEdits: { start: number; end: number; text: string }[], + versionBefore: number, + versionAfter: number, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[] +) { + const doc = new TextDocument( + 'inmemory://edit-stack-test', + textBeforeEdit, + 'plain', + versionBefore + ); + return createEditStackEntry( + doc, + resolvedEdits, + versionBefore, + versionAfter, + selectionsBefore, + selectionsAfter + ); +} + +describe('EditHistory', () => { + test('push stores cloned selections and pop methods move entries between stacks', () => { + const editStack = new EditStack(); + const selectionBefore = [caret(0), caret(1)]; + const selectionAfter = [caret(2), caret(3)]; + + editStack.push( + stackEntry( + 'ab', + [{ start: 1, end: 1, text: 'X' }], + 4, + 5, + selectionBefore, + selectionAfter + ) + ); + + selectionBefore[0] = caret(99); + selectionAfter[0] = caret(99); + + expect(editStack.canUndo).toBe(true); + expect(editStack.canRedo).toBe(false); + + const entry = editStack.popUndoToRedo(); + + expect(entry).toEqual({ + forwardEdits: [{ start: 1, end: 1, text: 'X' }], + inverseEdits: [{ start: 1, end: 2, text: '' }], + versionBefore: 4, + versionAfter: 5, + selectionsBefore: [caret(0), caret(1)], + selectionsAfter: [caret(2), caret(3)], + }); + expect(editStack.canUndo).toBe(false); + expect(editStack.canRedo).toBe(true); + + expect(editStack.popRedoToUndo()).toEqual(entry); + expect(editStack.canUndo).toBe(true); + expect(editStack.canRedo).toBe(false); + }); + + test('setLastUndoSelectionsAfter stores cloned redo selections', () => { + const editStack = new EditStack(); + let selectionAfter = caret(2); + + editStack.push( + stackEntry( + 'a', + [{ start: 1, end: 1, text: 'b' }], + 1, + 2, + [caret(1)], + [selectionAfter] + ) + ); + selectionAfter = caret(99); + + expect(editStack.popUndoToRedo()).toMatchObject({ + selectionsAfter: [caret(2)], + }); + }); + + test('push clears redo history when recording a new undo entry', () => { + const editStack = new EditStack(); + + editStack.push( + stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)]) + ); + editStack.push( + stackEntry('a', [{ start: 1, end: 1, text: 'b' }], 1, 2, [caret(1)]) + ); + + expect(editStack.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 1, end: 1, text: 'b' }], + }); + expect(editStack.canRedo).toBe(true); + + editStack.push( + stackEntry('a', [{ start: 1, end: 1, text: 'c' }], 1, 2, [caret(1)]) + ); + + expect(editStack.canRedo).toBe(false); + expect(editStack.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 1, end: 1, text: 'c' }], + }); + expect(editStack.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 0, end: 0, text: 'a' }], + }); + }); + + test('maxEntries drops oldest undo history first', () => { + const editStack = new EditStack({ maxEntries: 3 }); + + for (let i = 0; i < 4; i++) { + editStack.push( + stackEntry('', [{ start: 0, end: 0, text: `${i}` }], i, i + 1, [ + caret(0), + ]) + ); + } + + const third = editStack.popUndoToRedo(); + expect(third?.forwardEdits[0]?.text).toBe('3'); + expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('2'); + expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('1'); + expect(editStack.popUndoToRedo()).toBeUndefined(); + }); + + test('clear resets both undo and redo stacks', () => { + const editStack = new EditStack(); + + editStack.push( + stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)]) + ); + editStack.popUndoToRedo(); + editStack.clear(); + + expect(editStack.canUndo).toBe(false); + expect(editStack.canRedo).toBe(false); + expect(editStack.popUndoToRedo()).toBeUndefined(); + expect(editStack.popRedoToUndo()).toBeUndefined(); + }); +}); diff --git a/packages/diffs/test/editorLineAnnotations.test.ts b/packages/diffs/test/editorLineAnnotations.test.ts new file mode 100644 index 000000000..665fe67d7 --- /dev/null +++ b/packages/diffs/test/editorLineAnnotations.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; + +import { applyDocumentChangeToLineAnnotations } from '../src/editor/lineAnnotations'; +import { TextDocument } from '../src/editor/textDocument'; +import type { DiffLineAnnotation } from '../src/types'; + +describe('applyDocumentChangeToLineAnnotations', () => { + test('deletes annotations attached to deleted lines', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'one' }, + { side: 'additions', lineNumber: 2, metadata: 'two' }, + { side: 'additions', lineNumber: 3, metadata: 'three' }, + ]; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([ + { side: 'additions', lineNumber: 1, metadata: 'one' }, + { side: 'additions', lineNumber: 2, metadata: 'three' }, + ]); + }); + + test('moves annotations down when lines are inserted above them', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'one' }, + { side: 'additions', lineNumber: 2, metadata: 'two' }, + { side: 'additions', lineNumber: 3, metadata: 'three' }, + ]; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: 'inserted\n', + }, + ]); + + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([ + { side: 'additions', lineNumber: 1, metadata: 'one' }, + { side: 'additions', lineNumber: 3, metadata: 'two' }, + { side: 'additions', lineNumber: 4, metadata: 'three' }, + ]); + }); + + test('returns null when annotations do not move', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'one' }, + ]; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: 'inserted\n', + }, + ]); + + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual( + annotations + ); + }); +}); diff --git a/packages/diffs/test/editorPieceTable.test.ts b/packages/diffs/test/editorPieceTable.test.ts new file mode 100644 index 000000000..f927ff7e7 --- /dev/null +++ b/packages/diffs/test/editorPieceTable.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, test } from 'bun:test'; + +import { PieceTable } from '../src/editor/pieceTable'; +import type { Position } from '../src/editor/textDocument'; + +function lineTexts(text: string): string[] { + if (text === '') { + return ['']; + } + + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + lines.push(text.slice(start, i + 1)); + start = i + 1; + } + } + if (start <= text.length) { + lines.push(text.slice(start)); + } + return lines; +} + +/** Trailing CR/LF removed, matching `PieceTable.getLineText` / `getTextSlice(..., true)`. */ +function trimLineEndings(text: string): string { + let end = text.length; + while (end > 0 && isLineEnding(text.charCodeAt(end - 1))) { + end--; + } + return text.slice(0, end); +} + +function isLineEnding(c: number): boolean { + return c === 10 || c === 13; +} + +function positionAt(text: string, offset: number): Position { + const clampedOffset = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) !== 10) { + continue; + } + + const lineEnd = i + 1; + if (clampedOffset < lineEnd) { + return { line, character: clampedOffset - lineStart }; + } + line++; + lineStart = lineEnd; + } + + return { + line, + character: clampedOffset - lineStart, + }; +} + +function offsetAt(text: string, position: Position): number { + if (position.line < 0 || text.length === 0) { + return 0; + } + + const lines = lineTexts(text); + if (position.line >= lines.length) { + return text.length; + } + + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length; + } + + const lineLength = lines[position.line].length; + return offset + Math.min(Math.max(position.character, 0), lineLength); +} + +function expectTableToMatchText(table: PieceTable, text: string): void { + const lines = lineTexts(text); + + expect(table.getText()).toBe(text); + expect(table.lineCount).toBe(lines.length); + + for (let line = 0; line < lines.length; line++) { + expect(table.getLineText(line)).toBe(trimLineEndings(lines[line])); + } + + for (let offset = 0; offset <= text.length; offset++) { + expect(table.positionAt(offset)).toEqual(positionAt(text, offset)); + } + + for (let line = 0; line < lines.length; line++) { + const lineLength = lines[line].length; + for (let character = 0; character <= lineLength; character++) { + expect(table.offsetAt({ line, character })).toBe( + offsetAt(text, { line, character }) + ); + } + } +} + +function createRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +describe('PieceTable', () => { + test('returns the original text', () => { + const table = new PieceTable('hello'); + + expect(table.getText()).toBe('hello'); + expect(table.lineCount).toBe(1); + }); + + test('reads text ranges by positions', () => { + const table = new PieceTable('aa\nbb\ncc'); + + expect( + table.getText({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + }) + ).toBe('bb'); + }); + + test('getLineText omits trailing CR/LF', () => { + const table = new PieceTable('first\r\nsecond\n'); + + expect(table.getLineText(0)).toBe('first'); + expect(table.getLineText(1)).toBe('second'); + expect(table.getLineText(2)).toBe(''); + expect(() => table.getLineText(99)).toThrow('Line index out of range: 99'); + }); + + test('maps between offsets and positions', () => { + const table = new PieceTable('ab\nc'); + + expect(table.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); + expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); + expect(table.positionAt(table.getText().length)).toEqual({ + line: 1, + character: 1, + }); + expect(table.offsetAt({ line: 1, character: 0 })).toBe(3); + expect(table.offsetAt({ line: 1, character: 99 })).toBe(4); + }); + + test('inserts at the start, middle, and end', () => { + const table = new PieceTable('bc'); + + table.insert('a', 0); + table.insert('X', 2); + table.insert('d', table.getText().length); + + expect(table.getText()).toBe('abXcd'); + }); + + test('deletes across original and added pieces', () => { + const table = new PieceTable('hello world'); + + table.insert(' brave', 5); + table.delete(5, 6); + + expect(table.getText()).toBe('hello world'); + }); + + test('handles mixed edits over multiple lines', () => { + const table = new PieceTable('one\ntwo\nthree'); + + table.insert(' zero', 3); + table.delete(9, 3); + table.insert('TWO', table.offsetAt({ line: 1, character: 0 })); + + expect(table.getText()).toBe('one zero\nTWO\nthree'); + expect(table.lineCount).toBe(3); + expect(table.getLineText(1)).toBe('TWO'); + }); + + test('handles CRLF split across piece boundaries', () => { + const table = new PieceTable('a\r\nb'); + + table.insert('X', 2); + table.delete(2, 1); + + expect(table.getText()).toBe('a\r\nb'); + expect(table.lineCount).toBe(2); + expect(table.getLineText(0)).toBe('a'); + expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); + expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); + }); + + test('handles an empty document', () => { + const table = new PieceTable(''); + + expect(table.getText()).toBe(''); + expect(table.lineCount).toBe(1); + expect(table.getLineText(0)).toBe(''); + expect(table.positionAt(99)).toEqual({ line: 0, character: 0 }); + expect(table.offsetAt({ line: 99, character: 99 })).toBe(0); + }); + + test('clamps insert and delete offsets', () => { + const table = new PieceTable('middle'); + + table.insert('start-', -10); + table.insert('-end', 999); + table.delete(-10, 6); + table.delete(6, 999); + + expectTableToMatchText(table, 'middle'); + }); + + test('reads ranges spanning original and added pieces', () => { + const table = new PieceTable('abcd'); + + table.insert('XX', 2); + + expectTableToMatchText(table, 'abXXcd'); + expect( + table.getText({ + start: { line: 0, character: 1 }, + end: { line: 0, character: 5 }, + }) + ).toBe('bXXc'); + }); + + test('reads single characters from piece boundaries', () => { + const table = new PieceTable('ab\nef'); + + table.insert('CD', 3); + + expect(table.charAt(0)).toBe('a'); + expect(table.charAt(3)).toBe('C'); + expect(table.charAt(4)).toBe('D'); + expect(table.charAt(5)).toBe('e'); + expect(table.charAt(-1)).toBe(''); + expect(table.charAt(table.getText().length)).toBe(''); + }); + + test('searches text across piece boundaries', () => { + const table = new PieceTable('a\nb'); + + table.insert('\r', 1); + + expect(table.includes('\r\n')).toBe(true); + expect(table.includes('missing')).toBe(false); + expect(table.includes('')).toBe(true); + }); + + test('finds the next non-overlapping match across piece boundaries', () => { + const table = new PieceTable('foo x fo'); + + table.insert('o foo', table.getText().length); + + expect(table.findNextNonOverlappingSubstring('foo', [[0, 3]])).toBe(6); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [0, 3], + [6, 9], + ]) + ).toBe(10); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [6, 9], + [10, 13], + ]) + ).toBe(0); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [0, 3], + [6, 9], + [10, 13], + ]) + ).toBeUndefined(); + }); + + test('tracks trailing newline as an empty final line', () => { + const table = new PieceTable('a\n'); + + expectTableToMatchText(table, 'a\n'); + expect(table.getLineText(1)).toBe(''); + expect(table.positionAt(2)).toEqual({ line: 1, character: 0 }); + }); + + test('updates line metadata for inserted multiline text', () => { + const table = new PieceTable('before\nafter'); + + table.insert('\ninserted\r\nlines', 6); + + expectTableToMatchText(table, 'before\ninserted\r\nlines\nafter'); + }); + + test('deletes across several pieces', () => { + const table = new PieceTable('0123456789'); + + table.insert('aa', 2); + table.insert('bb', 6); + table.insert('cc', 12); + table.delete(0, table.getText().length - 1); + + expectTableToMatchText(table, '9'); + }); + + test('deletes all content', () => { + const table = new PieceTable('a\nb'); + + table.insert('c', 1); + table.delete(0, table.getText().length); + + expectTableToMatchText(table, ''); + expect(table.getLineText(0)).toBe(''); + }); + + test('matches plain string edits across many insertions and deletions', () => { + const table = new PieceTable('start\r\nmiddle\nend'); + const random = createRandom(42); + const inserts = ['a', 'BC', '\n', '\r\nx', '🙂', '']; + let text = 'start\r\nmiddle\nend'; + + for (let i = 0; i < 80; i++) { + if (random() < 0.6) { + const insert = inserts[Math.floor(random() * inserts.length)]; + const offset = Math.floor(random() * (text.length + 1)); + table.insert(insert, offset); + text = text.slice(0, offset) + insert + text.slice(offset); + } else { + const offset = Math.floor(random() * (text.length + 1)); + const length = Math.floor(random() * 5); + table.delete(offset, length); + text = text.slice(0, offset) + text.slice(offset + length); + } + } + + expectTableToMatchText(table, text); + }); +}); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts new file mode 100644 index 000000000..9cb59b0f4 --- /dev/null +++ b/packages/diffs/test/editorSelection.test.ts @@ -0,0 +1,1420 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyDeleteHardLineForwardToSelections, + applyTextChangeToSelections, + applyTextReplaceToSelections, + applyTransposeToSelections, + convertSelection, + createSelectionFrom, + DirectionForward, + DirectionNone, + type EditorSelection, + expandCollapsedSelectionToWord, + extendSelection, + findNexMatch, + getSelectionAnchor, + mapCursorMove, + mapSelectionShift, + mergeOverlappingSelections, + selectionIntersects, +} from '../src/editor/selection'; +import { + DirectionBackward, + type SelectionDirection, +} from '../src/editor/selection'; +import { TextDocument } from '../src/editor/textDocument'; + +type MockNode = { + nodeType: number; + tagName?: string; + parentElement?: MockElement | null; + children?: MockElement[]; + childNodes?: MockNode[]; + textContent?: string | null; +}; + +type MockElement = MockNode & { + tagName: string; + parentElement?: MockElement | null; + children: MockElement[]; + childNodes: MockNode[]; + dataset: Record; +}; + +function composedRange( + startContainer: Node, + startOffset: number, + endContainer = startContainer, + endOffset = startOffset +): StaticRange { + return { + startContainer, + startOffset, + endContainer, + endOffset, + collapsed: startContainer === endContainer && startOffset === endOffset, + } as StaticRange; +} + +function editorSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction: DirectionForward, + }; +} + +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = DirectionNone +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + +function pre(line: number, children: MockElement[] = []): MockElement { + const element: MockElement = { + nodeType: 1, + tagName: 'DIV', + parentElement: null, + children, + childNodes: children, + textContent: null, + dataset: { line: String(line + 1) }, + }; + for (const child of children) { + child.parentElement = element; + } + return element; +} + +function text(textContent: string): MockNode { + return { + nodeType: 3, + textContent, + }; +} + +function line(line: number, childNodes: MockNode[]): MockElement { + const element = pre( + line, + childNodes.filter((child): child is MockElement => child.nodeType === 1) + ); + element.childNodes = childNodes; + element.textContent = childNodes + .map((child) => child.textContent ?? '') + .join(''); + for (const child of childNodes) { + child.parentElement = element; + } + return element; +} + +function br(): MockElement { + return { + nodeType: 1, + tagName: 'BR', + parentElement: null, + children: [], + childNodes: [], + textContent: '', + dataset: {}, + }; +} + +function span(text: string, char?: number): MockElement { + const textNode: MockNode = { + nodeType: 3, + textContent: text, + }; + const element: MockElement = { + nodeType: 1, + tagName: 'SPAN', + parentElement: null, + children: [], + childNodes: [textNode], + textContent: text, + dataset: {}, + }; + textNode.parentElement = element; + if (char !== undefined) { + element.dataset.char = String(char); + } + return element; +} + +// div > span[data-diff-span] > span[data-char] (nested diff tokens) +function diffSpan(...tokenSpans: MockElement[]): MockElement { + const element: MockElement = { + nodeType: 1, + tagName: 'SPAN', + parentElement: null, + children: tokenSpans, + childNodes: tokenSpans, + textContent: tokenSpans.map((child) => child.textContent ?? '').join(''), + dataset: { diffSpan: '' }, + }; + for (const child of tokenSpans) { + child.parentElement = element; + } + return element; +} + +function button(text: string): MockElement { + const textNode: MockNode = { + nodeType: 3, + textContent: text, + }; + const element: MockElement = { + nodeType: 1, + tagName: 'BUTTON', + parentElement: null, + children: [], + childNodes: [textNode], + textContent: text, + dataset: {}, + }; + textNode.parentElement = element; + return element; +} + +function element(tagName: string, children: MockNode[] = []): MockElement { + const el: MockElement = { + nodeType: 1, + tagName, + parentElement: null, + children: children.filter( + (child): child is MockElement => child.nodeType === 1 + ), + childNodes: children, + textContent: children.map((child) => child.textContent ?? '').join(''), + dataset: {}, + }; + for (const child of children) { + child.parentElement = el; + } + return el; +} + +describe('convertSelection', () => { + test('maps a caret on an empty rendered line to character zero', () => { + const line = pre(1, [br()]); + expect(convertSelection(composedRange(line as unknown as Node, 0))).toEqual( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + direction: DirectionNone, + } + ); + }); + + test('treats a placeholder br boundary as the start of the line', () => { + const line = pre(2, [br()]); + expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual( + { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + direction: DirectionNone, + } + ); + }); + + test('ignores the line number gutter span on an empty line', () => { + const line = pre(3, [span('4'), br()]); + expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual( + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: DirectionNone, + } + ); + expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual( + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: DirectionNone, + } + ); + }); + + test('ignores the fold toggle button in the gutter', () => { + const line = pre(4, [span('5'), button('>'), span('color', 0)]); + expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual( + { + start: { line: 4, character: 0 }, + end: { line: 4, character: 0 }, + direction: DirectionNone, + } + ); + }); + + test('maps a direct line text node to its character offset', () => { + const textNode = text('abcdef'); + line(6, [textNode]); + expect( + convertSelection(composedRange(textNode as unknown as Node, 2)) + ).toEqual({ + start: { line: 6, character: 2 }, + end: { line: 6, character: 2 }, + direction: DirectionNone, + }); + }); + + test('maps div>span token text from data-char', () => { + const token = span('abcdef', 10); + const textNode = token.childNodes[0]; + pre(7, [token]); + expect( + convertSelection(composedRange(textNode as unknown as Node, 3)) + ).toEqual({ + start: { line: 7, character: 13 }, + end: { line: 7, character: 13 }, + direction: DirectionNone, + }); + }); + + test('maps div>span>span nested diff-span boundaries', () => { + const diffToken = span('_diff', 15); + const diff = diffSpan(diffToken, span(':', 20)); + const line = pre(8, [span(' ', 0), span('async', 2), diff]); + const textNode = diffToken.childNodes[0]; + + expect( + convertSelection(composedRange(textNode as unknown as Node, 2)) + ).toEqual({ + start: { line: 8, character: 17 }, + end: { line: 8, character: 17 }, + direction: DirectionNone, + }); + expect(convertSelection(composedRange(diff as unknown as Node, 1))).toEqual( + { + start: { line: 8, character: 20 }, + end: { line: 8, character: 20 }, + direction: DirectionNone, + } + ); + expect(convertSelection(composedRange(line as unknown as Node, 3))).toEqual( + { + start: { line: 8, character: 21 }, + end: { line: 8, character: 21 }, + direction: DirectionNone, + } + ); + }); + + test('ignores newline placeholders in direct line text nodes', () => { + const textNode = text('\n'); + line(8, [textNode]); + expect( + convertSelection(composedRange(textNode as unknown as Node, 1)) + ).toEqual({ + start: { line: 8, character: 0 }, + end: { line: 8, character: 0 }, + direction: DirectionNone, + }); + }); + + test('maps clicks inside a fold button on an empty line to character zero', () => { + const icon = element('SVG', [element('POLYLINE')]); + const toggle = element('BUTTON', [icon]); + pre(5, [span('6'), toggle, br()]); + expect( + convertSelection(composedRange(toggle as unknown as Node, 0)) + ).toEqual({ + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: DirectionNone, + }); + expect(convertSelection(composedRange(icon as unknown as Node, 0))).toEqual( + { + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: DirectionNone, + } + ); + }); + + test('maps a text node inside a nested diff-span token', () => { + const diffToken = span('_diff', 15); + const diff = diffSpan(diffToken, span(':', 20), span(' FileMetadata', 22)); + const textNode = diffToken.childNodes[0]; + pre(9, [span(' ', 0), span('async', 2), span(' render', 8), diff]); + expect( + convertSelection(composedRange(textNode as unknown as Node, 2)) + ).toEqual({ + start: { line: 9, character: 17 }, + end: { line: 9, character: 17 }, + direction: DirectionNone, + }); + }); + + test('maps a boundary at the start of a nested diff-span wrapper', () => { + const diff = diffSpan(span('_diff', 15), span(':', 20)); + pre(10, [span(' render', 8), diff]); + expect(convertSelection(composedRange(diff as unknown as Node, 0))).toEqual( + { + start: { line: 10, character: 15 }, + end: { line: 10, character: 15 }, + direction: DirectionNone, + } + ); + }); + + test('maps a boundary between nested diff-span tokens', () => { + const diff = diffSpan(span('_diff', 15), span(':', 20)); + pre(11, [diff]); + expect(convertSelection(composedRange(diff as unknown as Node, 1))).toEqual( + { + start: { line: 11, character: 20 }, + end: { line: 11, character: 20 }, + direction: DirectionNone, + } + ); + }); + + test('maps a text node inside a wrapped token fragment', () => { + const fragment = span('diff', undefined); + const token = span('', 15); + token.childNodes = [fragment]; + token.children = [fragment]; + token.textContent = 'diff'; + fragment.parentElement = token; + const textNode = fragment.childNodes[0]; + pre(12, [token]); + expect( + convertSelection(composedRange(textNode as unknown as Node, 1)) + ).toEqual({ + start: { line: 12, character: 16 }, + end: { line: 12, character: 16 }, + direction: DirectionNone, + }); + }); +}); + +describe('getSelectionAnchor', () => { + test('returns a text node offset inside a nested diff-span token', () => { + const diffToken = span('_diff', 15); + const line = pre(9, [span('4'), diffSpan(diffToken, span(':', 20))]); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 17 + ); + expect(node.nodeType).toBe(3); + expect(offset).toBe(2); + }); + + test('ignores gutter spans when mapping character positions', () => { + const token = span('code', 0); + const line = pre(3, [span('112'), token]); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 2 + ); + expect(node).toBe(token.childNodes[0] as unknown as Node); + expect(offset).toBe(2); + }); + + test('returns br anchor on an empty rendered line', () => { + const placeholder = br(); + const line = pre(4, [placeholder]); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 0 + ); + expect(node).toBe(placeholder as unknown as Node); + expect(offset).toBe(0); + }); + + test('returns span anchor for an empty pre-tokenized line placeholder', () => { + const placeholder = span('', 0); + const line = pre(5, [placeholder]); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 0 + ); + expect(node).toBe(placeholder.childNodes[0] as unknown as Node); + expect(offset).toBe(0); + }); + + test('returns token span when it has no text nodes', () => { + const placeholder: MockElement = { + nodeType: 1, + tagName: 'SPAN', + parentElement: null, + children: [], + childNodes: [], + textContent: '', + dataset: { char: '0' }, + }; + const line = pre(8, [placeholder]); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 0 + ); + expect(node).toBe(placeholder as unknown as Node); + expect(offset).toBe(0); + }); + + test('maps direct line text nodes used for whitespace-only lines', () => { + const textNode = text(' '); + const lineEl = line(6, [textNode]); + const [node, offset] = getSelectionAnchor( + lineEl as unknown as HTMLElement, + 2 + ); + expect(node).toBe(textNode as unknown as Node); + expect(offset).toBe(2); + }); + + test('falls back to the line element when it has no anchorable children', () => { + const line = pre(7, []); + const [node, offset] = getSelectionAnchor( + line as unknown as HTMLElement, + 0 + ); + expect(node).toBe(line as unknown as Node); + expect(offset).toBe(0); + }); +}); + +describe('selectionIntersects', () => { + test('detects overlapping ranges on the same line', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 4, 0, 8) + ) + ).toBe(true); + }); + + test('detects overlapping ranges across lines', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 2, 3), + editorSelection(1, 0, 3, 1) + ) + ).toBe(true); + }); + + test('does not treat adjacent range boundaries as intersections', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 6, 0, 8) + ) + ).toBe(false); + }); + + test('does not intersect separated ranges', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 4), + editorSelection(1, 0, 1, 2) + ) + ).toBe(false); + }); + + test('treats a caret inside a range as an intersection', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 4, 0, 4) + ) + ).toBe(true); + }); + + test('treats a caret on a range boundary as an intersection', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 6, 0, 6) + ) + ).toBe(true); + }); + + test('matches collapsed selections only at the same position', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 2), + editorSelection(0, 2, 0, 2) + ) + ).toBe(true); + expect( + selectionIntersects( + editorSelection(0, 2, 0, 2), + editorSelection(0, 3, 0, 3) + ) + ).toBe(false); + }); +}); + +describe('mergeOverlappingSelections', () => { + test('sorts selections and merges overlapping ranges', () => { + expect( + mergeOverlappingSelections([ + createSelection(2, 0, 2, 4, DirectionForward), + createSelection(0, 6, 0, 8, DirectionForward), + createSelection(0, 2, 0, 7, DirectionForward), + ]) + ).toEqual([ + createSelection(0, 2, 0, 8, DirectionForward), + createSelection(2, 0, 2, 4, DirectionForward), + ]); + }); + + test('keeps adjacent non-empty ranges separate', () => { + expect( + mergeOverlappingSelections([ + createSelection(0, 2, 0, 6, DirectionForward), + createSelection(0, 6, 0, 8, DirectionForward), + ]) + ).toEqual([ + createSelection(0, 2, 0, 6, DirectionForward), + createSelection(0, 6, 0, 8, DirectionForward), + ]); + }); + + test('merges a caret on a range boundary', () => { + expect( + mergeOverlappingSelections([ + createSelection(0, 2, 0, 6, DirectionForward), + createSelection(0, 6, 0, 6, DirectionNone), + ]) + ).toEqual([createSelection(0, 2, 0, 6, DirectionForward)]); + }); + + test('preserves forward direction for ranges extended to the same end', () => { + expect( + mergeOverlappingSelections([ + createSelection(1, 2, 3, 0, DirectionForward), + createSelection(2, 0, 3, 0, DirectionForward), + ]) + ).toEqual([createSelection(1, 2, 3, 0, DirectionForward)]); + }); + + test('preserves backward direction for ranges extended to the same start', () => { + expect( + mergeOverlappingSelections([ + createSelection(0, 0, 1, 4, DirectionBackward), + createSelection(0, 0, 2, 3, DirectionBackward), + ]) + ).toEqual([createSelection(0, 0, 2, 3, DirectionBackward)]); + }); +}); + +describe('extendSelection', () => { + test('extends a collapsed selection forward', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 3, DirectionNone), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); + }); + + test('extends a collapsed selection backward', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 3, DirectionNone), + createSelection(2, 1, 2, 1, DirectionNone) + ) + ).toEqual(createSelection(2, 1, 2, 3, DirectionBackward)); + }); + + test('extends forward when shift-click lands after the original anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); + }); + + test('left extend spans from target through original end (forward original)', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 1, 2, 1, DirectionNone) + ) + ).toEqual(createSelection(2, 1, 2, 8, DirectionBackward)); + }); + + test('right extend spans from original start through target (backward original)', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); + }); + + test('keeps the original anchored edge when shift-click lands inside the range', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 5, 2, 5, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 5, DirectionForward)); + }); + + test('keeps the backward anchor stable when shift-click lands inside the range', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 5, 2, 5, DirectionNone) + ) + ).toEqual(createSelection(2, 5, 2, 8, DirectionBackward)); + }); + + test('collapses a forward selection when shift-click lands on its anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 3, 2, 3, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 3, DirectionNone)); + }); + + test('collapses a backward selection when shift-click lands on its anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 8, 2, 8, DirectionNone) + ) + ).toEqual(createSelection(2, 8, 2, 8, DirectionNone)); + }); +}); + +describe('createSelectionFrom', () => { + test('keeps forward direction when drag focus moves after anchor', () => { + const start = createSelection(2, 3, 2, 3, DirectionNone); + const current = createSelection(2, 3, 2, 8, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(2, 3, 2, 8, DirectionForward) + ); + }); + + test('produces backward direction when drag focus moves before anchor', () => { + const start = createSelection(2, 8, 2, 8, DirectionNone); + const current = createSelection(2, 3, 2, 8, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(2, 3, 2, 8, DirectionBackward) + ); + }); + + test('uses backward start anchor when selection already has direction', () => { + const start = createSelection(1, 2, 1, 6, DirectionBackward); + const current = createSelection(1, 0, 1, 6, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(1, 0, 1, 6, DirectionBackward) + ); + }); +}); + +describe('applyTextChangeToSelections', () => { + test('inserts the same text at multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '!', + } + ); + + expect(textDocument.getText()).toBe('a!\nb!\nc!'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); + + test('replaces each selected range with the typed text', () => { + const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); + const selections = [ + createSelection(0, 0, 0, 3, DirectionForward), + createSelection(0, 4, 0, 7, DirectionForward), + createSelection(0, 8, 0, 11, DirectionForward), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 8, + end: 11, + text: 'x', + } + ); + + expect(textDocument.getText()).toBe('x x x'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(0, 3, 0, 3), + createSelection(0, 5, 0, 5), + ]); + }); + + test('mirrors backspace for multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'ax\nbx\ncx'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 7, + text: '', + } + ); + + expect(textDocument.getText()).toBe('x\nx\nx'); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('mirrors delete for multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'xa\nxb\nxc'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 7, + end: 8, + text: '', + } + ); + + expect(textDocument.getText()).toBe('x\nx\nx'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]); + }); + + test('deletes explicit ranges across multiple selections', () => { + const textDocument = new TextDocument('inmemory://1', 'abc def ghi'); + const selections = [ + createSelection(0, 1, 0, 3), + createSelection(0, 5, 0, 7), + createSelection(0, 9, 0, 11), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 9, + end: 11, + text: '', + } + ); + + expect(textDocument.getText()).toBe('a d g'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(0, 3, 0, 3), + createSelection(0, 5, 0, 5), + ]); + }); + + test('coalesces transformed edits that would overlap', () => { + const textDocument = new TextDocument('inmemory://1', ' '); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(0, 2, 0, 2), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 0, + end: 2, + text: '', + } + ); + + expect(textDocument.getText()).toBe(' '); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(0, 0, 0, 0), + ]); + }); + + test('places the caret on the inserted blank line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); + const selections = [createSelection(0, 3, 0, 3)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 3, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe('foo\n\nbar'); + expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]); + }); + + test('copies leading indentation onto the new line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', ' foo\nbar'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' foo\n \nbar'); + expect(nextSelections).toEqual([createSelection(1, 2, 1, 2)]); + }); + + test("uses each line's indent when inserting a newline at multiple carets", () => { + const textDocument = new TextDocument('inmemory://1', ' a\n\tb'); + const selections = [ + createSelection(0, 3, 0, 3), + createSelection(1, 2, 1, 2), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 6, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' a\n \n\tb\n\t'); + expect(nextSelections).toEqual([ + createSelection(1, 2, 1, 2), + createSelection(3, 1, 3, 1), + ]); + }); + + test('moves the caret to the previous line end after deleting a line break', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); + const selections = [createSelection(1, 0, 1, 0)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 4, + text: '', + } + ); + + expect(textDocument.getText()).toBe('foo\nbar'); + expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); + }); + + test('deletes one hard tab when backspacing in leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', '\tfoo'); + const selections = [createSelection(0, 1, 0, 1)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 0, + end: 1, + text: '', + }, + undefined, + 2 + ); + + expect(textDocument.getText()).toBe('foo'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('deletes one soft tab when backspacing in leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', ' foo'); + const selections = [createSelection(0, 4, 0, 4)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 4, + text: '', + }, + undefined, + 4 + ); + + expect(textDocument.getText()).toBe('foo'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('does not expand deletion outside leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', ' foo'); + const selections = [createSelection(0, 3, 0, 3)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 2, + end: 3, + text: '', + }, + undefined, + 2 + ); + + expect(textDocument.getText()).toBe(' oo'); + expect(nextSelections).toEqual([createSelection(0, 2, 0, 2)]); + }); +}); + +describe('mapSelectionMove', () => { + test('moves all carets when the primary caret moves', () => { + const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + + expect( + mapCursorMove(textDocument, selections, { line: 2, character: 0 }) + ).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('extends all selections when the primary selection grows', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 2, DirectionForward), + createSelection(1, 1, 1, 2, DirectionForward), + ]; + + expect( + mapCursorMove(textDocument, selections, { line: 1, character: 1 }) + ).toEqual([ + createSelection(0, 1, 0, 1, DirectionNone), + createSelection(1, 1, 1, 1, DirectionNone), + ]); + }); +}); + +describe('mapSelectionRangeMove', () => { + test('extends all carets when the primary textarea selection becomes a range', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + ]; + + expect( + mapSelectionShift(textDocument, selections, createSelection(1, 1, 1, 3)) + ).toEqual([ + createSelection(0, 1, 0, 3, DirectionForward), + createSelection(1, 1, 1, 3, DirectionForward), + ]); + }); + + test('preserves backward selection direction from the textarea focus', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + ]; + + expect( + mapSelectionShift(textDocument, selections, createSelection(1, 2, 1, 0)) + ).toEqual([ + createSelection(0, 0, 0, 2, DirectionBackward), + createSelection(1, 0, 1, 2, DirectionBackward), + ]); + }); + + test('maps a normalized backward range using selection direction', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + ]; + const shift: EditorSelection = { + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + direction: DirectionBackward, + }; + + expect(mapSelectionShift(textDocument, selections, shift)).toEqual([ + createSelection(0, 0, 0, 2, DirectionBackward), + createSelection(1, 0, 1, 2, DirectionBackward), + ]); + }); +}); + +describe('applyDeleteHardLineForwardToSelections', () => { + test('deletes from the caret to the end of the line', () => { + const textDocument = new TextDocument('inmemory://1', 'hello world'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections, change } = applyDeleteHardLineForwardToSelections( + textDocument, + selections + ); + + expect(change).toBeDefined(); + expect(textDocument.getText()).toBe('hello'); + expect(nextSelections).toEqual([createSelection(0, 5, 0, 5)]); + }); + + test('deletes the newline when the caret is at the end of a line', () => { + const textDocument = new TextDocument('inmemory://1', 'hello\nworld'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections } = applyDeleteHardLineForwardToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe('helloworld'); + expect(nextSelections).toEqual([createSelection(0, 5, 0, 5)]); + }); + + test('is a no-op at the end of the final line', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections, change } = applyDeleteHardLineForwardToSelections( + textDocument, + selections + ); + + expect(change).toBeUndefined(); + expect(textDocument.getText()).toBe('hello'); + expect(nextSelections).toEqual([createSelection(0, 5, 0, 5)]); + }); + + test('deletes an explicit selection instead of the rest of the line', () => { + const textDocument = new TextDocument('inmemory://1', 'hello world'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + const { nextSelections } = applyDeleteHardLineForwardToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe(' world'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('applies independently across multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'ax\nby\ncz'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyDeleteHardLineForwardToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe('a\nb\nc'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]); + }); +}); + +describe('applyTransposeToSelections', () => { + test('swaps the characters on either side of a collapsed caret', () => { + const textDocument = new TextDocument('inmemory://1', 'abc'); + const selections = [createSelection(0, 1, 0, 1)]; + const { nextSelections, change } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(change).toBeDefined(); + expect(textDocument.getText()).toBe('bac'); + expect(nextSelections).toEqual([createSelection(0, 2, 0, 2)]); + }); + + test('swaps the last two characters when the caret is at end-of-line', () => { + const textDocument = new TextDocument('inmemory://1', 'abc'); + const selections = [createSelection(0, 3, 0, 3)]; + const { nextSelections } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe('acb'); + expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); + }); + + test('swaps across a line boundary when the caret is at start-of-line', () => { + const textDocument = new TextDocument('inmemory://1', 'abc\ndef'); + const selections = [createSelection(1, 0, 1, 0)]; + const { nextSelections } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe('abd\ncef'); + expect(nextSelections).toEqual([createSelection(1, 1, 1, 1)]); + }); + + test('is a no-op when transpose is not possible', () => { + const textDocument = new TextDocument('inmemory://1', 'a'); + const selections = [createSelection(0, 0, 0, 0)]; + const { nextSelections, change } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(change).toBeUndefined(); + expect(textDocument.getText()).toBe('a'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('skips non-collapsed selections', () => { + const textDocument = new TextDocument('inmemory://1', 'abc'); + const selections = [ + createSelection(0, 0, 0, 2, DirectionForward), + createSelection(0, 2, 0, 2), + ]; + const { nextSelections, change } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(change).toBeDefined(); + expect(textDocument.getText()).toBe('acb'); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 2, DirectionForward), + createSelection(0, 3, 0, 3), + ]); + }); + + test('applies independently across multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'ax\nby\ncz'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTransposeToSelections( + textDocument, + selections + ); + + expect(textDocument.getText()).toBe('xa\nyb\nzc'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); +}); + +describe('applyTextReplaceToSelections', () => { + test('replaces each selection with its own pasted text', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + ['a', 'b', 'c'] + ); + + expect(textDocument.getText()).toBe('xa\nyb\nzc'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); +}); + +describe('expandCollapsedSelectionToWord', () => { + // Document content: "hello world!" (14 characters, quotes included) + // Segment positions: hello → [1, 6), world → [7, 12) + const doc = new TextDocument('inmemory://x', '"hello world!"'); + const collapsed = (ch: number) => createSelection(0, ch, 0, ch); + + test('expands when cursor is inside a word', () => { + // "hello world!" + expect(expandCollapsedSelectionToWord(doc, collapsed(3))).toEqual({ + start: { line: 0, character: 1 }, + end: { line: 0, character: 6 }, + direction: DirectionForward, + }); + }); + + test('expands when cursor is at the start of a word ("hello)', () => { + // cursor immediately before 'h' + expect(expandCollapsedSelectionToWord(doc, collapsed(1))).toEqual({ + start: { line: 0, character: 1 }, + end: { line: 0, character: 6 }, + direction: DirectionForward, + }); + }); + + test('expands when cursor is at the end of a word (hello )', () => { + // cursor immediately after 'o' of hello + expect(expandCollapsedSelectionToWord(doc, collapsed(6))).toEqual({ + start: { line: 0, character: 1 }, + end: { line: 0, character: 6 }, + direction: DirectionForward, + }); + }); + + test('expands when cursor is at the start of the second word ( world)', () => { + // cursor immediately before 'w' + expect(expandCollapsedSelectionToWord(doc, collapsed(7))).toEqual({ + start: { line: 0, character: 7 }, + end: { line: 0, character: 12 }, + direction: DirectionForward, + }); + }); + + test('expands when cursor is at the end of the second word (world!)', () => { + // cursor immediately after 'd' of world + expect(expandCollapsedSelectionToWord(doc, collapsed(12))).toEqual({ + start: { line: 0, character: 7 }, + end: { line: 0, character: 12 }, + direction: DirectionForward, + }); + }); + + test('does not expand when cursor is before the opening quote ("hello)', () => { + // cursor before the first ", separated from any word + expect(expandCollapsedSelectionToWord(doc, collapsed(0))).toEqual( + collapsed(0) + ); + }); + + test('does not expand when cursor is after the closing exclamation (world!")', () => { + // cursor after '!', separated from the nearest word by '!' + expect(expandCollapsedSelectionToWord(doc, collapsed(13))).toEqual( + collapsed(13) + ); + }); + + test('does not expand when cursor is after the closing quote ("hello world!")', () => { + // cursor past the last character + expect(expandCollapsedSelectionToWord(doc, collapsed(14))).toEqual( + collapsed(14) + ); + }); +}); + +describe('findNextMatch', () => { + test('returns undefined for empty selections', () => { + const doc = new TextDocument('inmemory://x', 'hello'); + expect(findNexMatch(doc, [])).toBeUndefined(); + }); + + test('ignores non-collapsed selections with different text', () => { + const doc = new TextDocument('inmemory://x', 'aa bb'); + const selections: EditorSelection[] = [ + createSelection(0, 0, 0, 2), + createSelection(0, 3, 0, 5), + ]; + expect(findNexMatch(doc, selections)).toBeUndefined(); + }); + + test('expands a collapsed caret to the surrounding word', () => { + const doc = new TextDocument('inmemory://x', "'foobar'"); + const caret = createSelection(0, 4, 0, 4); + const next = findNexMatch(doc, [caret]); + expect(next).toEqual([ + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 7 }, + direction: DirectionForward, + }, + ]); + }); + + test('adds the next matching range when one occurrence is selected', () => { + const doc = new TextDocument('inmemory://x', 'foo x foo'); + const first = createSelection(0, 0, 0, 3); + const afterFirst = findNexMatch(doc, [first]); + expect(afterFirst).toEqual([ + first, + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 9 }, + direction: DirectionForward, + }, + ]); + expect(findNexMatch(doc, afterFirst!)).toBeUndefined(); + }); + + test('wraps to an earlier occurrence after the last match in the file', () => { + const doc = new TextDocument('inmemory://x', 'foo bar foo'); + const secondFoo = createSelection(0, 8, 0, 11); + const wrapped = findNexMatch(doc, [secondFoo]); + expect(wrapped).toEqual([ + secondFoo, + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + direction: DirectionForward, + }, + ]); + }); + + test('allows multiple selections when every range has the same text', () => { + const doc = new TextDocument('inmemory://x', 'ab ab ab'); + const a = createSelection(0, 0, 0, 2); + const b = createSelection(0, 3, 0, 5); + const two = [a, b]; + const third = findNexMatch(doc, two); + expect(third?.length).toBe(3); + expect(third?.[2]).toEqual({ + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + direction: DirectionForward, + }); + }); +}); diff --git a/packages/diffs/test/editorTextDocument.test.ts b/packages/diffs/test/editorTextDocument.test.ts new file mode 100644 index 000000000..0c4a41e21 --- /dev/null +++ b/packages/diffs/test/editorTextDocument.test.ts @@ -0,0 +1,1058 @@ +import { describe, expect, test } from 'bun:test'; + +import type { EditorSelection } from '../src/editor/selection'; +import { DirectionNone } from '../src/editor/selection'; +import { TextDocument, type TextEdit } from '../src/editor/textDocument'; +import type { DiffLineAnnotation } from '../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(line: number, character: number) { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +describe('TextDocument', () => { + test('lang and lineCount', () => { + const d = doc('a\nb\nc'); + expect(d.languageId).toBe('plain'); + expect(d.lineCount).toBe(3); + }); + + test('empty document keeps one logical line', () => { + const d = doc(''); + expect(d.lineCount).toBe(1); + expect(d.getLineText(0)).toBe(''); + expect(d.getText()).toBe(''); + }); + + test('clearing all content keeps one logical line', () => { + const d = doc('hello\nworld'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 5 }, + }, + newText: '', + }, + ]); + expect(d.getText()).toBe(''); + expect(d.lineCount).toBe(1); + expect(d.getLineText(0)).toBe(''); + expect(change).toEqual({ + startLine: 0, + startCharacter: 0, + endLine: 0, + previousLineCount: 2, + lineCount: 1, + lineDelta: -1, + changedLineRanges: [[0, 0]], + }); + }); + + test('getText without range returns full buffer', () => { + expect(doc('hello').getText()).toBe('hello'); + }); + + test('getText with range', () => { + const d = doc('aa\nbb\ncc'); + expect( + d.getText({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }) + ).toBe('b'); + }); + + test('getLineText', () => { + const d = doc('first\nsecond'); + expect(d.getLineText(0)).toBe('first'); + expect(d.getLineText(1)).toBe('second'); + expect(() => d.getLineText(-1)).toThrow('Line index out of range: -1'); + expect(() => d.getLineText(99)).toThrow('Line index out of range: 99'); + }); + + test('getLineText trims line endings; getText range still includes them', () => { + const d = doc('first\r\nsecond\n'); + expect(d.getLineText(0)).toBe('first'); + expect(d.getLineText(1)).toBe('second'); + expect(d.getLineText(2)).toBe(''); + expect( + d.getText({ + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }) + ).toBe('first\r\n'); + expect( + d.getText({ + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }) + ).toBe('second\n'); + }); + + // test('offsetAt clamps to line and document bounds', () => { + // const d = doc('ab\nc'); + // expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); + // expect(d.offsetAt({ line: 0, character: 99 })).toBe(2); + // expect(d.offsetAt({ line: 1, character: 0 })).toBe(3); + // expect(() => d.offsetAt({ line: 99, character: 0 })).toThrow( + // 'Line index out of range: 99' + // ); + // }); + + test('positionAt is inverse of offsetAt for in-range columns', () => { + const d = doc('ab\nc'); + expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(d.positionAt(3)).toEqual({ line: 1, character: 0 }); + expect(d.positionAt(d.getText().length)).toEqual({ line: 1, character: 1 }); + const { line, character } = d.positionAt(2); + expect(d.offsetAt({ line, character })).toBe(2); + }); + + // test('positionAt and offsetAt clamp line endings', () => { + // const d = doc('a\r\r\nb\r'); + // expect(d.positionAt(2)).toEqual({ line: 0, character: 1 }); + // expect(d.positionAt(3)).toEqual({ line: 0, character: 1 }); + // expect(d.positionAt(4)).toEqual({ line: 1, character: 0 }); + // expect(d.positionAt(6)).toEqual({ line: 1, character: 1 }); + // expect(d.offsetAt({ line: 0, character: 10 })).toBe(1); + // expect(d.offsetAt({ line: 1, character: 10 })).toBe(5); + // }); + + test('positionAt maps initial line offsets from zero', () => { + const d = doc('first\nsecond\nthird'); + expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(d.positionAt(5)).toEqual({ line: 0, character: 5 }); + expect(d.positionAt(6)).toEqual({ line: 1, character: 0 }); + expect(d.offsetAt({ line: 2, character: 0 })).toBe(13); + }); + + test('applyEdits single replacement', () => { + const d = doc('hello world'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + }, + newText: 'you', + }, + ]); + expect(d.getText()).toBe('hello you'); + expect(change).toEqual({ + startLine: 0, + startCharacter: 6, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + changedLineRanges: [[0, 0]], + }); + }); + + test('applyEdits swaps inverted start/end', () => { + const d = doc('abcd'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + ]); + expect(d.getText()).toBe('aXd'); + }); + + test('applyEdits multiple non-overlapping regions', () => { + const d = doc('aa bb cc'); + const edits: TextEdit[] = [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + }, + newText: 'CC', + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + }, + newText: 'AA', + }, + ]; + d.applyEdits(edits); + expect(d.getText()).toBe('AA bb CC'); + }); + + test('applyEdits preserves line breaks around edited line', () => { + const d = doc('a\nb\nc'); + const change = d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }, + newText: 'B', + }, + ]); + expect(d.getText()).toBe('a\nB\nc'); + expect(d.lineCount).toBe(3); + expect(change).toEqual({ + startLine: 1, + startCharacter: 0, + endLine: 1, + previousLineCount: 3, + lineCount: 3, + lineDelta: 0, + changedLineRanges: [[1, 1]], + }); + }); + + test('applyEdits reports inserted lines in returned change', () => { + const d = doc('a'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '\nb', + }, + ]); + expect(d.getText()).toBe('a\nb'); + expect(change).toEqual({ + startLine: 0, + startCharacter: 1, + endLine: 1, + previousLineCount: 1, + lineCount: 2, + lineDelta: 1, + changedLineRanges: [[0, 1]], + }); + }); + + test('applyEdits reports line deletions in returned change', () => { + const d = doc('a\nb\nc'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + expect(d.getText()).toBe('ac'); + expect(change).toEqual({ + startLine: 0, + startCharacter: 1, + endLine: 0, + previousLineCount: 3, + lineCount: 1, + lineDelta: -2, + changedLineRanges: [[0, 0]], + }); + }); + + test('applyEdits preserves CRLF after middle-line edit', () => { + const d = doc('a\r\nb\r\nc'); + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }, + newText: 'B', + }, + ]); + expect(d.getText()).toBe('a\r\nB\r\nc'); + }); + + test('getText(range) spans multiple lines correctly after edits', () => { + const d = doc('foo\nbar\nbaz'); + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + newText: 'BAR', + }, + ]); + expect( + d.getText({ + start: { line: 0, character: 2 }, + end: { line: 2, character: 2 }, + }) + ).toBe('o\nBAR\nba'); + }); + + test('undo restores batch with two disjoint edits', () => { + const d = doc('aa bb cc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + }, + newText: 'CC', + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + }, + newText: 'AA', + }, + ], + true, + [caret(0, 0)] + ); + d.undo(); + expect(d.getText()).toBe('aa bb cc'); + }); + + test('undo multi-line replacement', () => { + const d = doc('line1\nline2\nline3'); + d.applyEdits( + [ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + }, + newText: 'two', + }, + ], + true, + [caret(1, 0)] + ); + expect(d.getText()).toBe('line1\ntwo\nline3'); + d.undo(); + expect(d.getText()).toBe('line1\nline2\nline3'); + }); + + test('undo stack depth for sequential edits', () => { + const d = doc('x'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + true, + [caret(0, 0)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + d.undo(); + expect(d.getText()).toBe('x'); + }); + + test('undo keeps later multiline edit separate from typing group', () => { + const d = doc('x'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + true, + [caret(0, 0)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: '\n', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('ab\nx'); + + d.undo(); + expect(d.getText()).toBe('abx'); + + d.undo(); + expect(d.getText()).toBe('x'); + }); + + test('contiguous backspaces coalesce into one undo step', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + }, + newText: '', + }, + ], + true, + [caret(0, 3)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('a'); + + d.undo(); + expect(d.getText()).toBe('abc'); + }); + + test('replacement edits do not coalesce', () => { + const d = doc('ab'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 2)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('aY'); + + d.undo(); + expect(d.getText()).toBe('aX'); + + d.undo(); + expect(d.getText()).toBe('ab'); + }); + + test('typing after replacing a selection coalesces into one undo step', () => { + const d = doc('hello'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + newText: 'w', + }, + ], + true, + [caret(0, 5)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'orld', + }, + ], + true, + [caret(0, 1)] + ); + + expect(d.getText()).toBe('world'); + + d.undo(); + expect(d.getText()).toBe('hello'); + }); + + test('contiguous forward deletes coalesce into one undo step', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1)] + ); + + expect(d.getText()).toBe('a'); + + d.undo(); + expect(d.getText()).toBe('abc'); + }); + + test('multi-cursor contiguous inserts coalesce into one undo step', () => { + const d = doc('ab\ncd'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + { + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2), caret(1, 2)] + ); + + expect(d.getText()).toBe('aXYb\ncXYd'); + + d.undo(); + expect(d.getText()).toBe('ab\ncd'); + }); + + test('multi-cursor contiguous backspaces coalesce into one undo step', () => { + const d = doc('abc\ndef'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 3 }, + }, + newText: '', + }, + ], + true, + [caret(0, 3), caret(1, 3)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 2), caret(1, 2)] + ); + + expect(d.getText()).toBe('a\nd'); + + d.undo(); + expect(d.getText()).toBe('abc\ndef'); + }); + + test('multi-cursor contiguous forward deletes coalesce into one undo step', () => { + const d = doc('abc\ndef'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + + expect(d.getText()).toBe('a\nd'); + + d.undo(); + expect(d.getText()).toBe('abc\ndef'); + }); + + test('multi-cursor batches with different edit shapes do not coalesce', () => { + const d = doc('ab\ncd'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2)] + ); + + d.undo(); + expect(d.getText()).toBe('aXb\ncXd'); + + d.undo(); + expect(d.getText()).toBe('ab\ncd'); + }); + + test('applyEdits rejects overlapping ranges', () => { + const d = doc('0123456789'); + expect(() => + d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 5 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 7 }, + }, + newText: 'Y', + }, + ]) + ).toThrow('Overlapping text edits are not supported'); + }); + + test('applyEdits empty array does not touch history', () => { + const d = doc('x'); + d.applyEdits([]); + expect(d.canUndo).toBe(false); + }); + + test('applyEdits default does not record undo', () => { + const d = doc('a'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ]); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(false); + expect(d.undo()).toBeUndefined(); + }); + + test('undo and redo', () => { + const d = doc('a'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + + const undoResult = d.undo(); + expect(d.getText()).toBe('a'); + expect(undoResult?.[0]).toEqual({ + startLine: 0, + startCharacter: 1, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + changedLineRanges: [[0, 0]], + }); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + + const redoResult = d.redo(); + expect(d.getText()).toBe('ab'); + expect(redoResult?.[0]).toEqual({ + startLine: 0, + startCharacter: 1, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + changedLineRanges: [[0, 0]], + }); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + }); + + test('undo and redo restore history entry versions', () => { + const d = new TextDocument('inmemory://1', 'a', 'plain', 7); + expect(d.version).toBe(7); + + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + expect(d.version).toBe(8); + + d.undo(); + expect(d.getText()).toBe('a'); + expect(d.version).toBe(7); + + d.redo(); + expect(d.getText()).toBe('ab'); + expect(d.version).toBe(8); + }); + + test('new edit after undo clears redo stack', () => { + const d = doc('a'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + d.undo(); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'c', + }, + ], + true, + [caret(0, 1)] + ); + expect(d.getText()).toBe('ac'); + expect(d.canRedo).toBe(false); + }); + + test('undo on empty stack returns false', () => { + const d = doc('z'); + expect(d.undo()).toBeUndefined(); + }); + + test('redo on empty stack returns false', () => { + const d = doc('z'); + expect(d.redo()).toBeUndefined(); + }); + + test('undo and redo return stored selections', () => { + const d = doc('abc'); + const selectionBefore = caret(0, 1); + const selectionAfter = caret(0, 2); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + true, + [selectionBefore], + [selectionAfter] + ); + + expect(d.undo()?.[1]).toEqual([selectionBefore]); + expect(d.redo()?.[1]).toEqual([selectionAfter]); + }); + + test('undo and redo preserve multiple selections', () => { + const d = doc('a\nb'); + const selectionsBefore = [caret(0, 1), caret(1, 1)]; + const selectionsAfter = [caret(0, 2), caret(1, 2)]; + d.applyEdits( + [ + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: '!', + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + true, + selectionsBefore, + selectionsAfter + ); + + expect(d.undo()?.[1]).toEqual(selectionsBefore); + expect(d.redo()?.[1]).toEqual(selectionsAfter); + }); + + test('undo and redo return stored line annotations', () => { + const d = doc('abc'); + const annotationsBefore: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'bookmark-a' }, + ]; + const annotationsAfter: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'bookmark-b' }, + ]; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + true, + [caret(0, 1)], + [caret(0, 2)], + annotationsBefore, + annotationsAfter + ); + + expect(d.undo()?.[2]).toEqual(annotationsBefore); + expect(d.redo()?.[2]).toEqual(annotationsAfter); + }); + + test('undo omits line annotations tuple entry when none were recorded', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + true, + [caret(0, 1)], + [caret(0, 2)] + ); + + expect(d.undo()?.[2]).toBeUndefined(); + expect(d.redo()?.[2]).toBeUndefined(); + }); + + test('setLastUndoLineAnnotationsAfter updates redo line annotations', () => { + const d = doc('a'); + const annotationsBefore: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'initial' }, + ]; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)], + undefined, + annotationsBefore, + undefined + ); + + const patchedAfter: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'patched-after-edit' }, + ]; + d.setLastUndoLineAnnotationsAfter(patchedAfter); + + d.undo(); + expect(d.redo()?.[2]).toEqual(patchedAfter); + }); +}); diff --git a/packages/diffs/test/editorTokenizer.test.ts b/packages/diffs/test/editorTokenizer.test.ts new file mode 100644 index 000000000..1f70424d4 --- /dev/null +++ b/packages/diffs/test/editorTokenizer.test.ts @@ -0,0 +1,494 @@ +import { describe, expect, test } from 'bun:test'; +import type { IGrammar, StateStack } from 'shiki/textmate'; + +import { + TextDocument, + type TextDocumentChange, +} from '../src/editor/textDocument'; +import { EditorTokenizer } from '../src/editor/tokenzier'; +import type { DiffsHighlighter, HighlightedToken } from '../src/types'; + +describe('EditorTokenizer', () => { + test('limits foreground tokenization to the render range after prepending lines', () => { + const originalAddEventListener = globalThis.addEventListener; + const originalPostMessage = globalThis.postMessage; + const postedMessages: unknown[] = []; + + globalThis.addEventListener = + (() => {}) as typeof globalThis.addEventListener; + globalThis.postMessage = ((message: unknown) => { + postedMessages.push(message); + }) as typeof globalThis.postMessage; + + try { + let tokenizeLineCount = 0; + const grammar = { + tokenizeLine2(lineText: string, ruleStack: StateStack) { + tokenizeLineCount++; + return { + tokens: new Uint32Array([0, 0]), + ruleStack, + stoppedEarly: false, + lineText, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + Array.from({ length: 1_000 }, (_, i) => `line ${i}`).join('\n'), + 'typescript' + ); + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: () => {}, + }); + const renderRange = { + startingLine: 900, + totalLines: 10, + bufferBefore: 0, + bufferAfter: 0, + }; + + tokenizer.tokenize( + { + startLine: 0, + startCharacter: 0, + endLine: 999, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 1, + changedLineRanges: [[0, 999]], + }, + renderRange + ); + tokenizeLineCount = 0; + postedMessages.length = 0; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: + Array.from({ length: 100 }, (_, i) => `new ${i}`).join('\n') + '\n', + }, + ])!; + const dirtyLines = tokenizer.tokenize(change, renderRange); + + expect(tokenizeLineCount).toBe(10); + expect([...dirtyLines.keys()]).toEqual([ + 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, + ]); + expect(postedMessages).toHaveLength(1); + } finally { + globalThis.addEventListener = originalAddEventListener; + globalThis.postMessage = originalPostMessage; + } + }); + + test('flushes offscreen line 0 when select-all delete shrinks the document', () => { + const grammar = { + tokenizeLine2(lineText: string, ruleStack: StateStack) { + return { + tokens: new Uint32Array([0, 0]), + ruleStack, + stoppedEarly: false, + lineText, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + Array.from({ length: 110 }, (_, i) => `line ${i}`).join('\n'), + 'typescript' + ); + const offscreenUpdates: Map>[] = []; + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: (lines) => { + offscreenUpdates.push(lines); + }, + }); + const renderRange = { + startingLine: 100, + totalLines: 10, + bufferBefore: 0, + bufferAfter: 0, + }; + + tokenizer.tokenize( + { + startLine: 0, + startCharacter: 0, + endLine: 109, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 0, + changedLineRanges: [[0, 109]], + }, + renderRange + ); + offscreenUpdates.length = 0; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 109, character: `line 109`.length }, + }, + newText: '', + }, + ])!; + const dirtyLines = tokenizer.tokenize(change, renderRange); + + expect(change.lineDelta).toBeLessThan(0); + expect(dirtyLines.size).toBe(0); + expect(offscreenUpdates.at(-1)?.has(0)).toBe(true); + expect(offscreenUpdates.at(-1)?.get(0)?.[0]?.[2]).toBe(''); + }); + + test('tokenizes inserted lines past the render range in the background', () => { + const originalAddEventListener = globalThis.addEventListener; + const originalRemoveEventListener = globalThis.removeEventListener; + const originalPostMessage = globalThis.postMessage; + let messageListener: ((event: MessageEvent) => void) | undefined; + const postedMessages: unknown[] = []; + + globalThis.addEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject + ) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }) as typeof globalThis.addEventListener; + globalThis.removeEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject + ) => { + if (type === 'message' && listener === messageListener) { + messageListener = undefined; + } + }) as typeof globalThis.removeEventListener; + globalThis.postMessage = ((message: unknown) => { + postedMessages.push(message); + }) as typeof globalThis.postMessage; + + try { + const grammar = { + tokenizeLine2(lineText: string, ruleStack: StateStack) { + return { + tokens: new Uint32Array([0, 0]), + ruleStack, + stoppedEarly: false, + lineText, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n'), + 'typescript' + ); + const deferredUpdates: Map>[] = []; + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: (lines) => { + deferredUpdates.push(lines); + }, + }); + const renderRange = { + startingLine: 0, + totalLines: 10, + bufferBefore: 0, + bufferAfter: 0, + }; + + tokenizer.tokenize( + { + startLine: 0, + startCharacter: 0, + endLine: 19, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 0, + changedLineRanges: [[0, 19]], + }, + renderRange + ); + postedMessages.length = 0; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 8, character: 'line 8'.length }, + end: { line: 8, character: 'line 8'.length }, + }, + newText: '\ninserted 9\ninserted 10\ninserted 11', + }, + ])!; + const dirtyLines = tokenizer.tokenize(change, renderRange); + const activeJobMessage = postedMessages.at(-1); + + expect([...dirtyLines.keys()]).toEqual([8, 9]); + expect(activeJobMessage).toBeDefined(); + + messageListener?.({ data: activeJobMessage } as MessageEvent); + expect(deferredUpdates.at(-1)?.get(10)?.[0]?.[2]).toBe('inserted 10'); + } finally { + globalThis.addEventListener = originalAddEventListener; + globalThis.removeEventListener = originalRemoveEventListener; + globalThis.postMessage = originalPostMessage; + } + }); + + test('settles zero-line edits before the viewport without rebuilding to the viewport', () => { + let tokenizeLineCount = 0; + const grammar = { + tokenizeLine2(lineText: string, ruleStack: StateStack) { + tokenizeLineCount++; + return { + tokens: new Uint32Array([0, 0]), + ruleStack, + stoppedEarly: false, + lineText, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + Array.from({ length: 110 }, (_, i) => `line ${i}`).join('\n'), + 'typescript' + ); + const offscreenUpdates: Map>[] = []; + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: (lines) => { + offscreenUpdates.push(lines); + }, + }); + + tokenizer.tokenize( + { + startLine: 0, + startCharacter: 0, + endLine: 0, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 0, + changedLineRanges: [[0, 0]], + }, + { startingLine: 100, totalLines: 10, bufferBefore: 0, bufferAfter: 0 } + ); + tokenizeLineCount = 0; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 'line 0'.length }, + }, + newText: 'LINE 0', + }, + ])!; + const dirtyLines = tokenizer.tokenize(change, { + startingLine: 100, + totalLines: 10, + bufferBefore: 0, + bufferAfter: 0, + }); + + expect(tokenizeLineCount).toBe(1); + expect(dirtyLines.size).toBe(0); + expect(offscreenUpdates.at(-1)?.get(0)?.[0]?.[2]).toBe('LINE 0'); + }); + + test('ignores queued background messages from stopped jobs', () => { + const originalAddEventListener = globalThis.addEventListener; + const originalRemoveEventListener = globalThis.removeEventListener; + const originalPostMessage = globalThis.postMessage; + let messageListener: ((event: MessageEvent) => void) | undefined; + const postedMessages: unknown[] = []; + + globalThis.addEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject + ) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }) as typeof globalThis.addEventListener; + globalThis.removeEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject + ) => { + if (type === 'message' && listener === messageListener) { + messageListener = undefined; + } + }) as typeof globalThis.removeEventListener; + globalThis.postMessage = ((message: unknown) => { + postedMessages.push(message); + }) as typeof globalThis.postMessage; + + try { + let tokenizeLineCount = 0; + const state = { equals: () => false } as unknown as StateStack; + const grammar = { + tokenizeLine2() { + tokenizeLineCount++; + return { + tokens: new Uint32Array([0, 0]), + ruleStack: state, + stoppedEarly: false, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + ['line 0', 'line 1', 'line 2'].join('\n'), + 'typescript' + ); + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: () => {}, + }); + const change: TextDocumentChange = { + startLine: 0, + startCharacter: 0, + endLine: 0, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 0, + changedLineRanges: [[0, 0]], + }; + const renderRange = { + startingLine: 0, + totalLines: 1, + bufferBefore: 0, + bufferAfter: 0, + }; + + tokenizer.tokenize(change, renderRange); + const stoppedJobMessage = postedMessages.at(-1); + tokenizer.stopBackgroundTokenize(); + tokenizer.tokenize(change, renderRange); + const activeJobMessage = postedMessages.at(-1); + tokenizeLineCount = 0; + + messageListener?.({ data: stoppedJobMessage } as MessageEvent); + expect(tokenizeLineCount).toBe(0); + + messageListener?.({ data: activeJobMessage } as MessageEvent); + expect(tokenizeLineCount).toBeGreaterThan(0); + } finally { + globalThis.addEventListener = originalAddEventListener; + globalThis.removeEventListener = originalRemoveEventListener; + globalThis.postMessage = originalPostMessage; + } + }); + + test('jumps between exact changed ranges for multi-cursor edits', () => { + let tokenizeLineCount = 0; + const grammar = { + tokenizeLine2(lineText: string, ruleStack: StateStack) { + tokenizeLineCount++; + return { + tokens: new Uint32Array([0, 0]), + ruleStack, + stoppedEarly: false, + lineText, + }; + }, + } as unknown as IGrammar; + const textDocument = new TextDocument( + 'test.ts', + Array.from({ length: 800 }, (_, i) => `line ${i}`).join('\n'), + 'typescript' + ); + const tokenizer = new EditorTokenizer({ + highlighter: { + getLanguage: () => grammar, + getLoadedLanguages: () => ['typescript'], + setTheme: () => ({ colorMap: [''] }), + } as unknown as DiffsHighlighter, + textDocument, + theme: { name: 'test-theme', type: 'dark' }, + onDeferTokenize: () => {}, + }); + + tokenizer.tokenize( + { + startLine: 0, + startCharacter: 0, + endLine: 799, + previousLineCount: textDocument.lineCount, + lineCount: textDocument.lineCount, + lineDelta: 1, + changedLineRanges: [[0, 799]], + }, + { startingLine: 0, totalLines: 800, bufferBefore: 0, bufferAfter: 0 } + ); + tokenizeLineCount = 0; + + const change = textDocument.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 'line 0'.length }, + }, + newText: 'LINE 0', + }, + { + range: { + start: { line: 750, character: 0 }, + end: { line: 750, character: 'line 750'.length }, + }, + newText: 'LINE 750', + }, + ])!; + const dirtyLines = tokenizer.tokenize(change, { + startingLine: 0, + totalLines: 800, + bufferBefore: 0, + bufferAfter: 0, + }); + + expect(change.changedLineRanges).toEqual([ + [0, 0], + [750, 750], + ]); + expect(tokenizeLineCount).toBe(2); + expect([...dirtyLines.keys()]).toEqual([0, 750]); + }); +}); diff --git a/packages/diffs/test/iterateOverFile.test.ts b/packages/diffs/test/iterateOverFile.test.ts deleted file mode 100644 index a55f4ddcc..000000000 --- a/packages/diffs/test/iterateOverFile.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - type FileLineCallbackProps, - iterateOverFile, -} from '../src/utils/iterateOverFile'; -import { splitFileContents } from '../src/utils/splitFileContents'; - -describe('iterateOverFile', () => { - test('basic iteration', () => { - const lines = splitFileContents('line1\nline2\nline3\nline4\nline5'); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(5); - - // Verify all props on first line - expect(results[0]).toEqual({ - lineIndex: 0, // 0-based - lineNumber: 1, // 1-based - content: 'line1\n', - isLastLine: false, - }); - - // Verify middle line - expect(results[2]).toEqual({ - lineIndex: 2, - lineNumber: 3, - content: 'line3\n', - isLastLine: false, - }); - - // Verify last line (no trailing newline in source) - expect(results[4]).toEqual({ - lineIndex: 4, - lineNumber: 5, - content: 'line5', - isLastLine: true, - }); - }); - - test('empty file', () => { - const lines = splitFileContents(''); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(0); - }); - - test('single line file', () => { - const lines = splitFileContents('only line'); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(1); - expect(results[0].isLastLine).toBe(true); - expect(results[0].lineIndex).toBe(0); - expect(results[0].lineNumber).toBe(1); - expect(results[0].content).toBe('only line'); - }); - - test('preserves empty lines', () => { - const lines = splitFileContents('line1\n\nline3\n\n\nline6'); - - const results: string[] = []; - iterateOverFile({ - lines, - callback({ content }) { - results.push(content); - }, - }); - - // Newlines are preserved except on last line - expect(results).toEqual(['line1\n', '\n', 'line3\n', '\n', '\n', 'line6']); - }); - - test('windowing', () => { - const lines = splitFileContents( - Array(100) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n') - ); - - // Windowing from start - let results: number[] = []; - iterateOverFile({ - lines, - startingLine: 0, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - - // Windowing from middle - results = []; - iterateOverFile({ - lines, - startingLine: 50, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([50, 51, 52, 53, 54, 55, 56, 57, 58, 59]); - - // Windowing past end - request more lines than available - const shortLines = splitFileContents('line1\nline2\nline3\nline4\nline5'); - results = []; - iterateOverFile({ - lines: shortLines, - startingLine: 3, - totalLines: 100, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([3, 4]); // Only lines 3 and 4 remain - - // Window starting beyond file end - results = []; - iterateOverFile({ - lines: shortLines, - startingLine: 100, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toHaveLength(0); - }); - - test('last new line is not iterated over', () => { - const lines = splitFileContents('line1\nline2\nline3\n\n\n'); - - const results: string[] = []; - iterateOverFile({ - lines, - callback({ content }) { - results.push(content); - }, - }); - - // Split creates: ['line1\n', 'line2\n', 'line3\n', '\n', '\n'] - // Only skips the LAST line if it's a newline, not all trailing newlines - expect(results).toEqual(['line1\n', 'line2\n', 'line3\n', '\n']); - }); - - test('isLastLine with windowing', () => { - const lines = splitFileContents( - Array(10) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n') - ); - - // Window lines 5-7 (not including the actual last line of the file) - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - startingLine: 5, - totalLines: 3, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(3); - // isLastLine should be relative to full file, not the window - expect(results[0].isLastLine).toBe(false); // Line 5 is not last in file - expect(results[1].isLastLine).toBe(false); // Line 6 is not last in file - expect(results[2].isLastLine).toBe(false); // Line 7 is not last in file - - // Window starting at actual last line - results.length = 0; - iterateOverFile({ - lines, - startingLine: 9, // Last line (0-indexed) - totalLines: 10, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(1); - expect(results[0].lineIndex).toBe(9); - expect(results[0].isLastLine).toBe(true); - }); - - test('early termination', () => { - const lines = splitFileContents( - Array(100) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n') - ); - - // Returning true stops iteration - let results: number[] = []; - iterateOverFile({ - lines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - if (lineIndex === 4) { - return true; // Stop - } - return false; - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - - // Returning false continues - const shortLines = splitFileContents('a\nb\nc\nd\ne'); - results = []; - iterateOverFile({ - lines: shortLines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - return false; // Continue - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - - // Returning void continues - results = []; - iterateOverFile({ - lines: shortLines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - // Implicit undefined return - continue - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - }); -});