addons/isl/src/ComparisonView/SplitDiffView/syntaxHighlighting.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ParsedDiff} from 'shared/patch/types';
b69ab319import type {
b69ab3110 SyntaxWorkerRequest,
b69ab3111 SyntaxWorkerResponse,
b69ab3112 ThemeColor,
b69ab3113 TokenizedDiffHunks,
b69ab3114 TokenizedHunk,
b69ab3115} from './syntaxHighlightingTypes';
b69ab3116
b69ab3117import {useEffect, useState} from 'react';
b69ab3118import {CancellationToken} from 'shared/CancellationToken';
b69ab3119import {updateTextMateGrammarCSS} from 'shared/textmate-lib/textmateStyles';
b69ab3120import {isVscode} from '../../environment';
b69ab3121import {SynchronousWorker, WorkerApi} from './workerApi';
b69ab3122
b69ab3123// Syntax highlighting is done in a WebWorker. This file contains APIs
b69ab3124// to be called from the main thread, which are delegated to the worker.
b69ab3125// In some environments, WebWorker is not available. In that case,
b69ab3126// we fall back to a synchronous worker.
b69ab3127
b69ab3128// Useful for testing the non-WebWorker implementation
b69ab3129const forceDisableWorkers = false;
b69ab3130
b69ab3131let cachedWorkerPromise: Promise<WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>>;
b69ab3132function getWorker(): Promise<WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>> {
b69ab3133 if (cachedWorkerPromise) {
b69ab3134 return cachedWorkerPromise;
b69ab3135 }
b69ab3136 cachedWorkerPromise = (async () => {
b69ab3137 let worker: WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>;
b69ab3138 if (isVscode()) {
b69ab3139 if (process.env.NODE_ENV === 'development') {
b69ab3140 // NOTE: when using vscode in dev mode, because the web worker is not compiled to a single file,
b69ab3141 // the webview can't use it properly.
b69ab3142 // Fall back to a synchronous worker (note that this may have perf issues)
b69ab3143 worker = new WorkerApi(
b69ab3144 new SynchronousWorker(() => import('./syntaxHighlightingWorker')) as unknown as Worker,
b69ab3145 );
b69ab3146 } else {
b69ab3147 // Production vscode build: webworkers in vscode webviews
b69ab3148 // are very particular and can only be loaded via blob: URL.
b69ab3149 // Vite will have built a special worker js asset due to the imports in this file.
b69ab3150 const PATH_TO_WORKER = './worker/syntaxHighlightingWorker.js';
b69ab3151 const blobUrl = await fetch(PATH_TO_WORKER)
b69ab3152 .then(r => r.blob())
b69ab3153 .then(b => URL.createObjectURL(b));
b69ab3154
b69ab3155 worker = new WorkerApi(new Worker(blobUrl));
b69ab3156 }
b69ab3157 } else if (window.Worker && !forceDisableWorkers) {
b69ab3158 // Non-vscode environments: web workers should work normally
b69ab3159 worker = new WorkerApi(
b69ab3160 new Worker(new URL('./syntaxHighlightingWorker', import.meta.url), {type: 'module'}),
b69ab3161 );
b69ab3162 } else {
b69ab3163 worker = new WorkerApi(
b69ab3164 new SynchronousWorker(() => import('./syntaxHighlightingWorker')) as unknown as Worker,
b69ab3165 );
b69ab3166 }
b69ab3167
b69ab3168 // Explicitly set the base URI so the worker can make fetch requests.
b69ab3169 worker.worker.postMessage({type: 'setBaseUri', base: document.baseURI} as SyntaxWorkerRequest);
b69ab3170
b69ab3171 worker.listen('cssColorMap', msg => {
b69ab3172 // During testing-library tear down (ex. syntax highlighting was canceled),
b69ab3173 // `document` may be null. Abort here to avoid errors.
b69ab3174 if (document == null) {
b69ab3175 return undefined;
b69ab3176 }
b69ab3177 updateTextMateGrammarCSS(msg.colorMap);
b69ab3178 });
b69ab3179
b69ab3180 return worker;
b69ab3181 })();
b69ab3182 return cachedWorkerPromise;
b69ab3183}
b69ab3184
b69ab3185/**
b69ab3186 * Given a set of hunks from a diff view,
b69ab3187 * asynchronously provide syntax highlighting by tokenizing.
b69ab3188 *
b69ab3189 * Note that we reconstruct the file contents from the diff,
b69ab3190 * so syntax highlighting can be inaccurate since it's missing full context.
b69ab3191 */
b69ab3192export function useTokenizedHunks(
b69ab3193 path: string,
b69ab3194 hunks: ParsedDiff['hunks'],
b69ab3195 useThemeHook: () => ThemeColor,
b69ab3196): TokenizedDiffHunks | undefined {
b69ab3197 const theme = useThemeHook();
b69ab3198
b69ab3199 const [tokenized, setTokenized] = useState<TokenizedDiffHunks | undefined>(undefined);
b69ab31100
b69ab31101 useEffect(() => {
b69ab31102 const token = newTrackedCancellationToken();
b69ab31103 getWorker().then(worker =>
b69ab31104 worker.request({type: 'tokenizeHunks', theme, path, hunks}, token).then(result => {
b69ab31105 if (!token.isCancelled) {
b69ab31106 setTokenized(result.result);
b69ab31107 }
b69ab31108 }),
b69ab31109 );
b69ab31110 return () => token.cancel();
b69ab31111 }, [theme, path, hunks]);
b69ab31112 return tokenized;
b69ab31113}
b69ab31114
b69ab31115/**
b69ab31116 * Given a chunk of a file as an array of lines, asynchronously provide syntax highlighting by tokenizing.
b69ab31117 */
b69ab31118export function useTokenizedContents(
b69ab31119 path: string,
b69ab31120 content: Array<string> | undefined,
b69ab31121 useThemeHook: () => ThemeColor,
b69ab31122): TokenizedHunk | undefined {
b69ab31123 const theme = useThemeHook();
b69ab31124
b69ab31125 const [tokenized, setTokenized] = useState<TokenizedHunk | undefined>(undefined);
b69ab31126
b69ab31127 useEffect(() => {
b69ab31128 if (content == null) {
b69ab31129 return;
b69ab31130 }
b69ab31131 const token = newTrackedCancellationToken();
b69ab31132 getWorker().then(worker =>
b69ab31133 worker.request({type: 'tokenizeContents', theme, path, content}, token).then(result => {
b69ab31134 if (!token.isCancelled) {
b69ab31135 setTokenized(result.result);
b69ab31136 }
b69ab31137 }),
b69ab31138 );
b69ab31139 return () => token.cancel();
b69ab31140 }, [theme, path, content]);
b69ab31141 return tokenized;
b69ab31142}
b69ab31143
b69ab31144/**
b69ab31145 * Given file content of a change before & after, return syntax highlighted versions of those changes.
b69ab31146 * Also takes a parent HTML Element. Sets up an interaction observer to only try syntax highlighting once
b69ab31147 * the container is visible.
b69ab31148 * Note: if parsing contentBefore/After in the caller, it's easy for these to change each render, causing
b69ab31149 * an infinite loop. Memoize contentBefore/contentAfter from the string content in the caller to avoid this.
b69ab31150 */
b69ab31151export function useTokenizedContentsOnceVisible(
b69ab31152 path: string,
b69ab31153 contentBefore: Array<string> | undefined,
b69ab31154 contentAfter: Array<string> | undefined,
b69ab31155 parentNode: React.MutableRefObject<HTMLElement | null>,
b69ab31156 useThemeHook: () => ThemeColor,
b69ab31157): [TokenizedHunk, TokenizedHunk] | undefined {
b69ab31158 const theme = useThemeHook();
b69ab31159 const [tokenized, setTokenized] = useState<[TokenizedHunk, TokenizedHunk] | undefined>(undefined);
b69ab31160 const [hasBeenVisible, setHasBeenVisible] = useState(false);
b69ab31161
b69ab31162 useEffect(() => {
b69ab31163 if (hasBeenVisible || parentNode.current == null) {
b69ab31164 // no need to start observing again after we've been visible.
b69ab31165 return;
b69ab31166 }
b69ab31167 const observer = new IntersectionObserver((entries, observer) => {
b69ab31168 entries.forEach(entry => {
b69ab31169 if (entry.intersectionRatio > 0) {
b69ab31170 setHasBeenVisible(true);
b69ab31171 // no need to keep observing once we've been visible once and computed the highlights.
b69ab31172 observer.disconnect();
b69ab31173 }
b69ab31174 });
b69ab31175 }, {});
b69ab31176 observer.observe(parentNode.current);
b69ab31177 return () => observer.disconnect();
b69ab31178 }, [parentNode, hasBeenVisible]);
b69ab31179
b69ab31180 useEffect(() => {
b69ab31181 if (!hasBeenVisible || contentBefore == null || contentAfter == null) {
b69ab31182 return;
b69ab31183 }
b69ab31184 const token = newTrackedCancellationToken();
b69ab31185
b69ab31186 Promise.all([
b69ab31187 getWorker().then(worker =>
b69ab31188 worker.request({type: 'tokenizeContents', theme, path, content: contentBefore}, token),
b69ab31189 ),
b69ab31190 getWorker().then(worker =>
b69ab31191 worker.request({type: 'tokenizeContents', theme, path, content: contentAfter}, token),
b69ab31192 ),
b69ab31193 ]).then(([a, b]) => {
b69ab31194 if (a?.result == null || b?.result == null) {
b69ab31195 return;
b69ab31196 }
b69ab31197 if (!token.isCancelled) {
b69ab31198 setTokenized([a.result, b.result]);
b69ab31199 }
b69ab31200 });
b69ab31201 return () => token.cancel();
b69ab31202 }, [hasBeenVisible, theme, path, contentBefore, contentAfter]);
b69ab31203 return tokenized?.[0].length === contentBefore?.length &&
b69ab31204 tokenized?.[1].length === contentAfter?.length
b69ab31205 ? tokenized
b69ab31206 : undefined;
b69ab31207}
b69ab31208
b69ab31209/** Track the `CancellationToken`s so they can be cancelled immediately in tests. */
b69ab31210const cancellationTokens: Set<CancellationToken> = new Set();
b69ab31211
b69ab31212/**
b69ab31213 * Cancel all syntax highlighting tasks immediately. This is useful in tests
b69ab31214 * that do not wait for the highlighting to complete and want to avoid the
b69ab31215 * React "act" warning.
b69ab31216 */
b69ab31217export function cancelAllHighlightingTasks() {
b69ab31218 cancellationTokens.forEach(token => token.cancel());
b69ab31219 cancellationTokens.clear();
b69ab31220}
b69ab31221
b69ab31222function newTrackedCancellationToken(): CancellationToken {
b69ab31223 const token = new CancellationToken();
b69ab31224 cancellationTokens.add(token);
b69ab31225 token.onCancel(() => cancellationTokens.delete(token));
b69ab31226 return token;
b69ab31227}