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

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

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