UNPKG

@pierre/diffs

Version:

616 lines (615 loc) 24.4 kB
import { DEFAULT_RENDER_RANGE, DEFAULT_THEMES } from "../constants.js"; import { areLanguagesAttached } from "../highlighter/languages/areLanguagesAttached.js"; import { getThemes } from "../utils/getThemes.js"; import { areThemesAttached } from "../highlighter/themes/areThemesAttached.js"; import { getHighlighterIfLoaded, getSharedHighlighter } from "../highlighter/shared_highlighter.js"; import { areFileTargetsEqual } from "../utils/areFileTargetsEqual.js"; import { getFileAnnotations, shouldRenderFileAnnotations } from "../utils/includesFileAnnotations.js"; import { createGutterGap, createGutterItem, createGutterWrapper, createHastElement } from "../utils/hast_utils.js"; import { hasResolvedThemes } from "../highlighter/themes/hasResolvedThemes.js"; import { applyLineTextWithNewline } from "../utils/applyLineTextWithNewline.js"; import { areFileRenderOptionsEqual } from "../utils/areFileRenderOptionsEqual.js"; import { areRenderRangesEqual } from "../utils/areRenderRangesEqual.js"; import { linesFromFileContents } from "../utils/computeFileOffsets.js"; import { createAnnotationElement } from "../utils/createAnnotationElement.js"; import { createContentColumn } from "../utils/createContentColumn.js"; import { createFileHeaderElement } from "../utils/createFileHeaderElement.js"; import { createPreElement } from "../utils/createPreElement.js"; import { getFiletypeFromFileName } from "../utils/getFiletypeFromFileName.js"; import { getHighlighterOptions } from "../utils/getHighlighterOptions.js"; import { getLineAnnotationName } from "../utils/getLineAnnotationName.js"; import { isDefaultRenderRange } from "../utils/isDefaultRenderRange.js"; import { isFilePlainText } from "../utils/isFilePlainText.js"; import { renderFileWithHighlighter } from "../utils/renderFileWithHighlighter.js"; import { toHtml } from "hast-util-to-html"; //#region src/renderers/FileRenderer.ts function isLineCacheForFile(lineCache, file) { return file.cacheKey == null ? lineCache.file === file && lineCache.sourceContents === file.contents : lineCache.cacheKey === file.cacheKey; } let instanceId = -1; var FileRenderer = class { options; annotationSlotName; onRenderUpdate; workerManager; __id = `file-renderer:${++instanceId}`; highlighter; file; renderCache; pendingHighlightResult; computedLang = "text"; lineAnnotations = {}; lineCache; pendingStructuralRows; textDocumentCache = /* @__PURE__ */ new WeakMap(); editSessionActive = false; get fileCache() { return this.renderCache?.file; } constructor(options = { theme: DEFAULT_THEMES }, annotationSlotName = getLineAnnotationName, onRenderUpdate, workerManager) { this.options = options; this.annotationSlotName = annotationSlotName; this.onRenderUpdate = onRenderUpdate; this.workerManager = workerManager; if (workerManager?.isWorkingPool() !== true) this.highlighter = areThemesAttached(options.theme ?? DEFAULT_THEMES) ? getHighlighterIfLoaded() : void 0; } setOptions(options) { this.options = options; } mergeOptions(options) { this.options = { ...this.options, ...options }; } setLineAnnotations(lineAnnotations) { this.lineAnnotations = {}; for (const annotation of lineAnnotations) { const arr = this.lineAnnotations[annotation.lineNumber] ?? []; this.lineAnnotations[annotation.lineNumber] = arr; arr.push(annotation); } } cleanUp() { this.recycle(); this.workerManager = void 0; this.onRenderUpdate = void 0; } /** * Enter edit-session mode: rendering happens locally with the token * transformer forced on, and worker-pool requests/results are suspended * for this renderer. Called on initial editor association and whenever its * rendering resumes after recycle. */ beginEditSession(file, externalFile) { const { editSessionActive: wasAlreadyActive, renderCache } = this; this.editSessionActive = true; if (!wasAlreadyActive) this.pendingHighlightResult = void 0; this.file = file; if (renderCache == null) return; if (wasAlreadyActive && renderCache.file === file) return; const { options } = this.getRenderOptions(file); const cacheBelongsToSession = renderCache.file === file; const cacheBelongsToExternal = externalFile != null && areFileTargetsEqual(renderCache.file, externalFile); const { result } = renderCache; if (!renderCache.highlighted || result == null || !areFileRenderOptionsEqual(renderCache.options, options) || !cacheBelongsToSession && !cacheBelongsToExternal) { this.clearRenderCache(); this.lineCache = void 0; this.textDocumentCache = /* @__PURE__ */ new WeakMap(); return; } if (cacheBelongsToSession) return; this.renderCache = { ...renderCache, file, result: { ...result, code: [...result.code] } }; const { lineCache } = this; if (lineCache != null && externalFile != null && isLineCacheForFile(lineCache, externalFile)) this.lineCache = { cacheKey: void 0, file, sourceContents: file.contents, lines: lineCache.lines }; else this.lineCache = void 0; } /** * Leave edit-session mode. Rendering returns to the pool when one works. * When `settledFile` has the content the cache already shows, the cache * adopts it as its identity so the next render treats it as current * instead of a new file. */ endEditSession(settledFile) { this.editSessionActive = false; this.pendingHighlightResult = void 0; const { renderCache } = this; if (settledFile == null || renderCache == null || renderCache.file === settledFile || !areFileTargetsEqual(renderCache.file, settledFile)) return; renderCache.file = settledFile; } /** * Ensures that the DOM is compatible with editor render updates */ editorRenderReady() { return this.renderCache?.options.useTokenTransformer === true && this.renderCache.highlighted && this.renderCache.result != null; } recycle() { this.clearRenderCache(); this.highlighter = void 0; this.workerManager?.cleanUpTasks(this); this.lineCache = void 0; this.file = void 0; this.endEditSession(); this.textDocumentCache = /* @__PURE__ */ new WeakMap(); } clearRenderCache() { this.pendingStructuralRows = void 0; this.renderCache = void 0; this.pendingHighlightResult = void 0; } hydrate(file) { this.file = file; const { options } = this.getRenderOptions(file); const massiveFile = isFileMassive(this.getOrCreateLineCache(file).length, this.getTokenizeMaxLength()); let cache = this.workerManager?.getFileResultCache(file); if (cache != null && !areFileRenderOptionsEqual(options, cache.options)) cache = void 0; this.renderCache ??= { file, hydrated: true, options, highlighted: !massiveFile && !isFilePlainText(file), result: massiveFile ? void 0 : cache?.result, renderRange: void 0 }; if (!this.editSessionActive && this.workerManager?.isWorkingPool() === true) { if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); } else if (this.highlighter == null) { this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); this.initializeHighlighter(); } } getLocalHighlightTheme() { return this.workerManager?.getFileRenderOptions().theme ?? this.options.theme ?? DEFAULT_THEMES; } getEffectiveCodeOptions() { const poolOptions = this.workerManager?.isWorkingPool() === true ? this.workerManager.getFileRenderOptions() : void 0; return { theme: this.getLocalHighlightTheme(), tokenizeMaxLineLength: poolOptions?.tokenizeMaxLineLength ?? this.options.tokenizeMaxLineLength }; } getRenderOptions(file) { const options = (() => { if (this.workerManager?.isWorkingPool() === true) { const poolOptions = this.workerManager.getFileRenderOptions(); if (this.editSessionActive && poolOptions.useTokenTransformer !== true) return { ...poolOptions, useTokenTransformer: true }; return poolOptions; } const { tokenizeMaxLineLength = 1e3 } = this.options; return { theme: this.getLocalHighlightTheme(), useTokenTransformer: this.editSessionActive || this.options.useTokenTransformer === true, tokenizeMaxLineLength }; })(); const { renderCache } = this; if (renderCache?.result == null) return { options, forceHighlight: true }; if (!areFileTargetsEqual(file, renderCache.file) || !areFileRenderOptionsEqual(options, renderCache.options)) return { options, forceHighlight: true }; return { options, forceHighlight: false }; } /** * Returns the file that the next synchronous render can commit without * changing the current render cache. Virtualized layouts use this to stay * aligned with the DOM while a replacement highlight is still pending. */ getFileForNextRender(file) { const { options } = this.getRenderOptions(file); if (this.getReadyRenderResult(file, options) != null) return file; const { renderCache } = this; if (renderCache == null) return file; if (areFileTargetsEqual(renderCache.file, file)) return renderCache.file; const lines = linesFromFileContents(file.contents); const forcePlainText = file.contents.length === 0 || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); return this.canRenderFile(file, options, forcePlainText) ? file : renderCache.file; } canRenderFile(file, options, forcePlainText) { const { renderCache } = this; if (renderCache == null || areFileTargetsEqual(renderCache.file, file)) return true; if (forcePlainText) return renderCache.result == null && renderCache.hydrated !== true || this.workerManager?.isWorkingPool() === true || this.highlighter != null && areThemesAttached(options.theme); if (renderCache.result == null && renderCache.hydrated !== true) return true; if (!this.editSessionActive && this.workerManager?.isWorkingPool() === true) return !renderCache.highlighted; return this.highlighter != null && areThemesAttached(options.theme); } getOrCreateLineCache(file) { let { lineCache } = this; if (lineCache == null || !isLineCacheForFile(lineCache, file)) lineCache = { cacheKey: file.cacheKey, file, sourceContents: file.contents, lines: linesFromFileContents(file.contents) }; this.lineCache = lineCache; return lineCache.lines; } getLineCount(file) { const lines = this.getOrCreateLineCache(file); return this.textDocumentCache.get(file)?.lineCount ?? lines.length; } updateRenderCache(dirtyLines, themeType, lineCountChangeInFlight = false) { this.pendingStructuralRows = void 0; const { renderCache } = this; if (renderCache == null) return; const { file, result } = renderCache; if (result == null) return; const pendingStructuralRows = lineCountChangeInFlight ? /* @__PURE__ */ new Map() : void 0; this.pendingStructuralRows = pendingStructuralRows; const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; for (const [line, tokens] of dirtyLines) { if (pendingStructuralRows == null && lineCache != null && line < lineCache.lines.length) { const lineText = tokens.map((token) => token[2]).join(""); lineCache.lines[line] = applyLineTextWithNewline(lineCache.lines[line] ?? "", lineText); } const row = { type: "element", tagName: "div", properties: { "data-line": line + 1, "data-line-type": "context", "data-line-index": line }, children: tokens.map(([char, fg, text]) => { if (char === 0 && fg === "") { if (text === "") return { type: "element", tagName: "br", properties: {}, children: [] }; return { type: "text", value: text }; } return { type: "element", tagName: "span", properties: { "data-char": char, style: `color:${fg};` }, children: [{ type: "text", value: text }] }; }) }; if (pendingStructuralRows != null) pendingStructuralRows.set(line, row); else result.code[line] = row; } result.baseThemeType = themeType; renderCache.isDirty = true; if (pendingStructuralRows == null && lineCache != null) { file.contents = lineCache.lines.join(""); lineCache.sourceContents = file.contents; } } applyDocumentChange(textDocument) { const { pendingStructuralRows, renderCache } = this; this.pendingStructuralRows = void 0; if (renderCache == null) return; const { file, result } = renderCache; if (result == null) return; const previousLines = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache.lines : linesFromFileContents(file.contents); const nextLines = linesFromFileContents(textDocument.getText()); if (previousLines.length !== nextLines.length) { const maxShared = Math.min(previousLines.length, nextLines.length); let prefix = 0; while (prefix < maxShared && previousLines[prefix] === nextLines[prefix]) prefix++; let suffix = 0; while (suffix < maxShared - prefix && previousLines[previousLines.length - 1 - suffix] === nextLines[nextLines.length - 1 - suffix]) suffix++; const previousCode = result.code; result.code = new Array(nextLines.length); for (let i = 0; i < prefix; i++) result.code[i] = previousCode[i]; for (let i = 0; i < suffix; i++) result.code[nextLines.length - 1 - i] = previousCode[previousLines.length - 1 - i]; if (pendingStructuralRows !== void 0) { for (const [line, row] of pendingStructuralRows) if (line < nextLines.length) result.code[line] = row; } for (let i = prefix; i < nextLines.length - suffix; i++) result.code[i] ??= { type: "element", tagName: "div", properties: { "data-line": i + 1, "data-line-type": "context", "data-line-index": i }, children: [{ type: "element", tagName: "span", properties: { "data-char": 0 }, children: [{ type: "text", value: textDocument.getLineText(i) }] }] }; for (let i = 0; i < result.code.length; i++) { const line = result.code[i]; if (line?.type === "element") { line.properties["data-line"] = i + 1; line.properties["data-line-index"] = i; } } renderCache.isDirty = true; } this.lineCache = { cacheKey: file.cacheKey, file, sourceContents: file.contents, lines: nextLines }; this.textDocumentCache.set(file, textDocument); file.contents = textDocument.getText(); } renderFile(file = this.file, renderRange = DEFAULT_RENDER_RANGE) { this.file = file; if (file == null) { this.pendingHighlightResult = void 0; return; } let { options, forceHighlight } = this.getRenderOptions(file); const readyResult = this.getReadyRenderResult(file, options); this.pendingHighlightResult = void 0; if (readyResult != null) { this.renderCache = { ...readyResult, file, renderRange: void 0 }; forceHighlight = false; } this.renderCache ??= { file, highlighted: false, options, result: void 0, renderRange: void 0 }; const lines = this.getOrCreateLineCache(file); const hasContent = file.contents.length > 0; const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); const canRenderFile = this.canRenderFile(file, options, forcePlainText); const newContent = !areFileTargetsEqual(file, this.renderCache.file); const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); if (!this.editSessionActive && this.workerManager?.isWorkingPool() === true) { const preserveHydratedContent = this.renderCache.result == null && this.renderCache.highlighted && !forcePlainText && !newContent && isDefaultRenderRange(renderRange); if (canRenderFile && !preserveHydratedContent && (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange))) { this.renderCache.file = file; this.renderCache.options = options; this.renderCache.highlighted = false; if (this.renderCache.result == null || newContent || newRenderRange || forceHighlight) this.renderCache.result = this.workerManager.getPlainFileAST(file, renderRange.startingLine, renderRange.totalLines, lines); this.renderCache.renderRange = renderRange; } if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); } else { this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); this.highlighter ??= getHighlighterIfLoaded(); const hasThemes = this.highlighter != null && areThemesAttached(options.theme); const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); const canHighlight = !forcePlainText && hasLangs; if (canRenderFile && this.highlighter != null && hasThemes && (forceHighlight || forcePlainText || !this.renderCache.highlighted && canHighlight || this.renderCache.result == null)) { const { result, options } = this.renderFileWithHighlighter(file, this.highlighter, forcePlainText || !hasLangs); this.renderCache = { file, options, highlighted: canHighlight, result, renderRange: void 0 }; } if (!hasThemes || !forcePlainText && !hasLangs) this.asyncHighlight(file).then(({ result, options }) => { this.applyHighlightResult(file, result, options, !forcePlainText); }); } return this.renderCache.result != null ? this.processFileResult(this.renderCache.file, renderRange, this.renderCache.result) : void 0; } async asyncRender(file, renderRange = DEFAULT_RENDER_RANGE) { this.file = file; const { result } = await this.asyncHighlight(file); return this.processFileResult(file, renderRange, result); } async asyncHighlight(file) { const forcePlainText = isFileMassive(this.getOrCreateLineCache(file).length, this.getTokenizeMaxLength()); this.computedLang = forcePlainText ? "text" : file.lang ?? getFiletypeFromFileName(file.name); const hasThemes = this.highlighter != null && hasResolvedThemes(getThemes(this.getLocalHighlightTheme())); const hasLangs = forcePlainText || this.highlighter != null && areLanguagesAttached(this.computedLang); if (this.highlighter == null || !hasThemes || !hasLangs) this.highlighter = await this.initializeHighlighter(); return this.renderFileWithHighlighter(file, this.highlighter, forcePlainText); } renderFileWithHighlighter(file, highlighter, forcePlainText = false) { const { options } = this.getRenderOptions(file); return { result: renderFileWithHighlighter(file, highlighter, options, { forcePlainText }), options }; } processFileResult(file, renderRange, { code, themeStyles, baseThemeType }) { const totalLines = this.getLineCount(file); const { disableFileHeader = false } = this.options; const contentArray = []; const gutter = createGutterWrapper(); const endLine = Math.min(renderRange.startingLine + renderRange.totalLines, totalLines); let rowCount = 0; const fileLevelAnnotations = shouldRenderFileAnnotations(renderRange) ? getFileAnnotations(this.lineAnnotations) : void 0; if (fileLevelAnnotations != null) { gutter.children.push(createGutterGap("context", "annotation", 1)); contentArray.push(createAnnotationElement({ type: "annotation", hunkIndex: -1, lineIndex: -1, annotations: fileLevelAnnotations.map((annotation) => this.annotationSlotName(annotation)) })); rowCount++; } for (let lineIndex = renderRange.startingLine; lineIndex < endLine; lineIndex++) { const lineNumber = lineIndex + 1; 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); } gutter.children.push(createGutterItem("context", lineNumber, `${lineIndex}`)); contentArray.push(line); rowCount++; 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) => this.annotationSlotName(annotation)) })); rowCount++; } } gutter.properties.style = `grid-row: span ${rowCount}`; return { file, gutterAST: gutter.children ?? [], contentAST: contentArray, preAST: this.createPreElement(totalLines), headerAST: !disableFileHeader ? this.renderHeader(file) : void 0, totalLines, rowCount, themeStyles, baseThemeType, bufferBefore: renderRange.bufferBefore, bufferAfter: renderRange.bufferAfter, css: "" }; } renderHeader(file) { const { headerRenderMode = "default", stickyHeader = false } = this.options; return createFileHeaderElement({ fileOrDiff: file, mode: headerRenderMode, stickyHeader }); } renderFullHTML(result) { return toHtml(this.renderFullAST(result)); } renderFullAST(result, children = []) { children.push(createHastElement({ tagName: "code", children: this.renderCodeAST(result), properties: { "data-code": "" } })); return { ...result.preAST, children }; } renderCodeAST(result) { const gutter = createGutterWrapper(); gutter.children = result.gutterAST; gutter.properties.style = `grid-row: span ${result.rowCount}`; return [gutter, createContentColumn(result.contentAST, result.rowCount)]; } renderPartialHTML(children, includeCodeNode = false) { if (!includeCodeNode) return toHtml(children); return toHtml(createHastElement({ tagName: "code", children, properties: { "data-code": "" } })); } async initializeHighlighter() { this.highlighter = await getSharedHighlighter(getHighlighterOptions(this.computedLang, { theme: this.getLocalHighlightTheme(), preferredHighlighter: this.workerManager?.getPreferredHighlighter() ?? this.options.preferredHighlighter })); return this.highlighter; } onHighlightSuccess(file, result, options, highlighted = true) { if (this.editSessionActive) return; this.applyHighlightResult(file, result, options, highlighted); } applyHighlightResult(file, result, options, highlighted = true) { const { file: currentFile, renderCache } = this; if (currentFile == null || renderCache == null || !areFileTargetsEqual(file, currentFile) || !areFileRenderOptionsEqual(options, this.getRenderOptions(currentFile).options)) return; if (!(renderCache.result == null || !renderCache.highlighted || !areFileRenderOptionsEqual(renderCache.options, options) || !areFileTargetsEqual(renderCache.file, currentFile))) return; this.pendingHighlightResult = { file: currentFile, options, highlighted, result }; this.onRenderUpdate?.(); } getMatchingWorkerResultCache(file, options) { if (this.editSessionActive) return; const cache = this.workerManager?.getFileResultCache(file); if (cache == null || !areFileRenderOptionsEqual(options, cache.options)) return; return cache; } getReadyRenderResult(file, options) { const { pendingHighlightResult } = this; if (pendingHighlightResult != null && areFileTargetsEqual(pendingHighlightResult.file, file) && areFileRenderOptionsEqual(pendingHighlightResult.options, options)) return pendingHighlightResult; const workerCache = this.getMatchingWorkerResultCache(file, options); if (workerCache == null || this.hasHighlightedRenderCache(file, options)) return; return { file, highlighted: true, ...workerCache }; } hasHighlightedRenderCache(file, options) { const { renderCache } = this; return renderCache?.result != null && renderCache.highlighted && areFileTargetsEqual(file, renderCache.file) && areFileRenderOptionsEqual(options, renderCache.options); } onHighlightError(error) { console.error(error); } getTokenizeMaxLength() { return this.options.tokenizeMaxLength ?? 1e5; } createPreElement(totalLines) { const { disableLineNumbers = false, overflow = "scroll" } = this.options; return createPreElement({ type: "file", diffIndicators: "none", disableBackground: true, disableLineNumbers, overflow, split: false, totalLines }); } }; function isFileMassive(lineCount, tokenizeMaxLength) { return lineCount > tokenizeMaxLength; } //#endregion export { FileRenderer }; //# sourceMappingURL=FileRenderer.js.map