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