| 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 | |
| 8 | import type {ParsedDiff} from 'shared/patch/types'; |
| 9 | import type { |
| 10 | SyntaxWorkerRequest, |
| 11 | SyntaxWorkerResponse, |
| 12 | ThemeColor, |
| 13 | TokenizedDiffHunks, |
| 14 | TokenizedHunk, |
| 15 | } from './syntaxHighlightingTypes'; |
| 16 | |
| 17 | import {useEffect, useState} from 'react'; |
| 18 | import {CancellationToken} from 'shared/CancellationToken'; |
| 19 | import {updateTextMateGrammarCSS} from 'shared/textmate-lib/textmateStyles'; |
| 20 | import {isVscode} from '../../environment'; |
| 21 | import {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 |
| 29 | const forceDisableWorkers = false; |
| 30 | |
| 31 | let cachedWorkerPromise: Promise<WorkerApi<SyntaxWorkerRequest, SyntaxWorkerResponse>>; |
| 32 | function 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 | */ |
| 92 | export 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 | */ |
| 118 | export 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 | */ |
| 151 | export 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. */ |
| 210 | const 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 | */ |
| 217 | export function cancelAllHighlightingTasks() { |
| 218 | cancellationTokens.forEach(token => token.cancel()); |
| 219 | cancellationTokens.clear(); |
| 220 | } |
| 221 | |
| 222 | function newTrackedCancellationToken(): CancellationToken { |
| 223 | const token = new CancellationToken(); |
| 224 | cancellationTokens.add(token); |
| 225 | token.onCancel(() => cancellationTokens.delete(token)); |
| 226 | return token; |
| 227 | } |
| 228 | |