8.0 KB228 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ParsedDiff} from 'shared/patch/types';
9import type {
10 SyntaxWorkerRequest,
11 SyntaxWorkerResponse,
12 ThemeColor,
13 TokenizedDiffHunks,
14 TokenizedHunk,
15} from './syntaxHighlightingTypes';
16
17import {useEffect, useState} from 'react';
18import {CancellationToken} from 'shared/CancellationToken';
19import {updateTextMateGrammarCSS} from 'shared/textmate-lib/textmateStyles';
20import {isVscode} from '../../environment';
21import {SynchronousWorker, WorkerApi} from './workerApi';
22
23// Syntax highlighting is done in a WebWorker. This file contains APIs
24// to be called from the main thread, which are delegated to the worker.
25// In some environments, WebWorker is not available. In that case,
26// we fall back to a synchronous worker.
27
28// Useful for testing the non-WebWorker implementation
29const forceDisableWorkers = false;
30
31let cachedWorkerPromise: Promise<WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>>;
32function getWorker(): Promise<WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>> {
33 if (cachedWorkerPromise) {
34 return cachedWorkerPromise;
35 }
36 cachedWorkerPromise = (async () => {
37 let worker: WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>;
38 if (isVscode()) {
39 if (process.env.NODE_ENV === 'development') {
40 // NOTE: when using vscode in dev mode, because the web worker is not compiled to a single file,
41 // the webview can't use it properly.
42 // Fall back to a synchronous worker (note that this may have perf issues)
43 worker = new WorkerApi(
44 new SynchronousWorker(() => import('./syntaxHighlightingWorker')) as unknown as Worker,
45 );
46 } else {
47 // Production vscode build: webworkers in vscode webviews
48 // are very particular and can only be loaded via blob: URL.
49 // Vite will have built a special worker js asset due to the imports in this file.
50 const PATH_TO_WORKER = './worker/syntaxHighlightingWorker.js';
51 const blobUrl = await fetch(PATH_TO_WORKER)
52 .then(r => r.blob())
53 .then(b => URL.createObjectURL(b));
54
55 worker = new WorkerApi(new Worker(blobUrl));
56 }
57 } else if (window.Worker && !forceDisableWorkers) {
58 // Non-vscode environments: web workers should work normally
59 worker = new WorkerApi(
60 new Worker(new URL('./syntaxHighlightingWorker', import.meta.url), {type: 'module'}),
61 );
62 } else {
63 worker = new WorkerApi(
64 new SynchronousWorker(() => import('./syntaxHighlightingWorker')) as unknown as Worker,
65 );
66 }
67
68 // Explicitly set the base URI so the worker can make fetch requests.
69 worker.worker.postMessage({type: 'setBaseUri', base: document.baseURI} as SyntaxWorkerRequest);
70
71 worker.listen('cssColorMap', msg => {
72 // During testing-library tear down (ex. syntax highlighting was canceled),
73 // `document` may be null. Abort here to avoid errors.
74 if (document == null) {
75 return undefined;
76 }
77 updateTextMateGrammarCSS(msg.colorMap);
78 });
79
80 return worker;
81 })();
82 return cachedWorkerPromise;
83}
84
85/**
86 * Given a set of hunks from a diff view,
87 * asynchronously provide syntax highlighting by tokenizing.
88 *
89 * Note that we reconstruct the file contents from the diff,
90 * so syntax highlighting can be inaccurate since it's missing full context.
91 */
92export function useTokenizedHunks(
93 path: string,
94 hunks: ParsedDiff['hunks'],
95 useThemeHook: () => ThemeColor,
96): TokenizedDiffHunks | undefined {
97 const theme = useThemeHook();
98
99 const [tokenized, setTokenized] = useState<TokenizedDiffHunks | undefined>(undefined);
100
101 useEffect(() => {
102 const token = newTrackedCancellationToken();
103 getWorker().then(worker =>
104 worker.request({type: 'tokenizeHunks', theme, path, hunks}, token).then(result => {
105 if (!token.isCancelled) {
106 setTokenized(result.result);
107 }
108 }),
109 );
110 return () => token.cancel();
111 }, [theme, path, hunks]);
112 return tokenized;
113}
114
115/**
116 * Given a chunk of a file as an array of lines, asynchronously provide syntax highlighting by tokenizing.
117 */
118export function useTokenizedContents(
119 path: string,
120 content: Array<string> | undefined,
121 useThemeHook: () => ThemeColor,
122): TokenizedHunk | undefined {
123 const theme = useThemeHook();
124
125 const [tokenized, setTokenized] = useState<TokenizedHunk | undefined>(undefined);
126
127 useEffect(() => {
128 if (content == null) {
129 return;
130 }
131 const token = newTrackedCancellationToken();
132 getWorker().then(worker =>
133 worker.request({type: 'tokenizeContents', theme, path, content}, token).then(result => {
134 if (!token.isCancelled) {
135 setTokenized(result.result);
136 }
137 }),
138 );
139 return () => token.cancel();
140 }, [theme, path, content]);
141 return tokenized;
142}
143
144/**
145 * Given file content of a change before & after, return syntax highlighted versions of those changes.
146 * Also takes a parent HTML Element. Sets up an interaction observer to only try syntax highlighting once
147 * the container is visible.
148 * Note: if parsing contentBefore/After in the caller, it's easy for these to change each render, causing
149 * an infinite loop. Memoize contentBefore/contentAfter from the string content in the caller to avoid this.
150 */
151export function useTokenizedContentsOnceVisible(
152 path: string,
153 contentBefore: Array<string> | undefined,
154 contentAfter: Array<string> | undefined,
155 parentNode: React.MutableRefObject<HTMLElement | null>,
156 useThemeHook: () => ThemeColor,
157): [TokenizedHunk, TokenizedHunk] | undefined {
158 const theme = useThemeHook();
159 const [tokenized, setTokenized] = useState<[TokenizedHunk, TokenizedHunk] | undefined>(undefined);
160 const [hasBeenVisible, setHasBeenVisible] = useState(false);
161
162 useEffect(() => {
163 if (hasBeenVisible || parentNode.current == null) {
164 // no need to start observing again after we've been visible.
165 return;
166 }
167 const observer = new IntersectionObserver((entries, observer) => {
168 entries.forEach(entry => {
169 if (entry.intersectionRatio > 0) {
170 setHasBeenVisible(true);
171 // no need to keep observing once we've been visible once and computed the highlights.
172 observer.disconnect();
173 }
174 });
175 }, {});
176 observer.observe(parentNode.current);
177 return () => observer.disconnect();
178 }, [parentNode, hasBeenVisible]);
179
180 useEffect(() => {
181 if (!hasBeenVisible || contentBefore == null || contentAfter == null) {
182 return;
183 }
184 const token = newTrackedCancellationToken();
185
186 Promise.all([
187 getWorker().then(worker =>
188 worker.request({type: 'tokenizeContents', theme, path, content: contentBefore}, token),
189 ),
190 getWorker().then(worker =>
191 worker.request({type: 'tokenizeContents', theme, path, content: contentAfter}, token),
192 ),
193 ]).then(([a, b]) => {
194 if (a?.result == null || b?.result == null) {
195 return;
196 }
197 if (!token.isCancelled) {
198 setTokenized([a.result, b.result]);
199 }
200 });
201 return () => token.cancel();
202 }, [hasBeenVisible, theme, path, contentBefore, contentAfter]);
203 return tokenized?.[0].length === contentBefore?.length &&
204 tokenized?.[1].length === contentAfter?.length
205 ? tokenized
206 : undefined;
207}
208
209/** Track the `CancellationToken`s so they can be cancelled immediately in tests. */
210const cancellationTokens: Set<CancellationToken> = new Set();
211
212/**
213 * Cancel all syntax highlighting tasks immediately. This is useful in tests
214 * that do not wait for the highlighting to complete and want to avoid the
215 * React "act" warning.
216 */
217export function cancelAllHighlightingTasks() {
218 cancellationTokens.forEach(token => token.cancel());
219 cancellationTokens.clear();
220}
221
222function newTrackedCancellationToken(): CancellationToken {
223 const token = new CancellationToken();
224 cancellationTokens.add(token);
225 token.onCancel(() => cancellationTokens.delete(token));
226 return token;
227}
228