| 1 | import { createHighlighterCore, type GrammarState, type HighlighterCore } from "@shikijs/core"; |
| 2 | import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; |
| 3 | import bashLanguage from "@shikijs/langs/bash"; |
| 4 | import diffLanguage from "@shikijs/langs/diff"; |
| 5 | import javascriptLanguage from "@shikijs/langs/javascript"; |
| 6 | import jsonLanguage from "@shikijs/langs/json"; |
| 7 | import jsxLanguage from "@shikijs/langs/jsx"; |
| 8 | import tsxLanguage from "@shikijs/langs/tsx"; |
| 9 | import typescriptLanguage from "@shikijs/langs/typescript"; |
| 10 | import yamlLanguage from "@shikijs/langs/yaml"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | |
| 13 | import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes"; |
| 14 | import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; |
| 15 | |
| 16 | export type NativeReviewDiffHighlightScheme = "light" | "dark"; |
| 17 | export type NativeReviewDiffHighlightEngine = "native" | "javascript"; |
| 18 | |
| 19 | export class NativeReviewDiffHighlighterUnavailableError extends Schema.TaggedError<NativeReviewDiffHighlighterUnavailableError>()( |
| 20 | "NativeReviewDiffHighlighterUnavailableError", |
| 21 | {}, |
| 22 | ) { |
| 23 | override get message(): string { |
| 24 | return "The native review diff highlighter is unavailable in this build."; |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | export const isNativeReviewDiffHighlighterUnavailableError = Schema.is( |
| 29 | NativeReviewDiffHighlighterUnavailableError, |
| 30 | ); |
| 31 | |
| 32 | export class NativeReviewDiffHighlighterInitializationError extends Schema.TaggedError<NativeReviewDiffHighlighterInitializationError>()( |
| 33 | "NativeReviewDiffHighlighterInitializationError", |
| 34 | { |
| 35 | requestedEngine: Schema.Literals(["native", "javascript"]), |
| 36 | attemptedEngine: Schema.Literals(["native", "javascript"]), |
| 37 | cause: Schema.Defect(), |
| 38 | }, |
| 39 | ) { |
| 40 | override get message(): string { |
| 41 | return `Failed to initialize the ${this.attemptedEngine} review diff highlighter requested as ${this.requestedEngine}.`; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | export interface NativeReviewDiffHighlighterHandle { |
| 46 | readonly engine: NativeReviewDiffHighlightEngine; |
| 47 | readonly tokenize: ( |
| 48 | code: string, |
| 49 | options: { |
| 50 | readonly lang: NativeReviewDiffLanguage; |
| 51 | readonly theme: string; |
| 52 | readonly signal?: AbortSignal; |
| 53 | }, |
| 54 | ) => Promise<ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>>>; |
| 55 | } |
| 56 | |
| 57 | interface NativeReviewDiffLineRow extends NativeReviewDiffRow { |
| 58 | readonly kind: "line"; |
| 59 | readonly fileId: string; |
| 60 | readonly content: string; |
| 61 | } |
| 62 | |
| 63 | interface IndexedNativeReviewDiffLineRow { |
| 64 | readonly row: NativeReviewDiffLineRow; |
| 65 | readonly rowIndex: number; |
| 66 | } |
| 67 | |
| 68 | export interface HighlightNativeReviewDiffVisibleRowsInput { |
| 69 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; |
| 70 | readonly files: ReadonlyArray<NativeReviewDiffFile>; |
| 71 | readonly scheme: NativeReviewDiffHighlightScheme; |
| 72 | readonly engine?: NativeReviewDiffHighlightEngine; |
| 73 | readonly firstRowIndex: number; |
| 74 | readonly lastRowIndex: number; |
| 75 | readonly overscanRows?: number; |
| 76 | readonly maxRows?: number; |
| 77 | readonly alreadyHighlightedRowIds?: ReadonlySet<string>; |
| 78 | readonly signal?: AbortSignal; |
| 79 | } |
| 80 | |
| 81 | const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; |
| 82 | const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; |
| 83 | const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; |
| 84 | const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS = 8_000; |
| 85 | |
| 86 | const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = { |
| 87 | dark: "t3-pierre-dark", |
| 88 | light: "t3-pierre-light", |
| 89 | } as const; |
| 90 | |
| 91 | const PIERRE_LIGHT_SHIKI_THEME = { |
| 92 | name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.light, |
| 93 | type: "light" as const, |
| 94 | fg: "#070707", |
| 95 | bg: "#ffffff", |
| 96 | settings: [ |
| 97 | { settings: { foreground: "#070707", background: "#ffffff" } }, |
| 98 | { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } }, |
| 99 | { |
| 100 | scope: "keyword, storage, storage.type, keyword.operator.expression", |
| 101 | settings: { foreground: "#FC2B73" }, |
| 102 | }, |
| 103 | { |
| 104 | scope: "entity.name.function, support.function, meta.function-call", |
| 105 | settings: { foreground: "#7B43F8" }, |
| 106 | }, |
| 107 | { |
| 108 | scope: "entity.name.type, support.type, support.class", |
| 109 | settings: { foreground: "#C635E4" }, |
| 110 | }, |
| 111 | { |
| 112 | scope: "string, constant.character, punctuation.definition.string", |
| 113 | settings: { foreground: "#199F43" }, |
| 114 | }, |
| 115 | { |
| 116 | scope: "constant.numeric, constant.language, constant.other", |
| 117 | settings: { foreground: "#1CA1C7" }, |
| 118 | }, |
| 119 | { |
| 120 | scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key", |
| 121 | settings: { foreground: "#D47628" }, |
| 122 | }, |
| 123 | { |
| 124 | scope: "entity.name.tag, support.class.component", |
| 125 | settings: { foreground: "#199F43" }, |
| 126 | }, |
| 127 | { |
| 128 | scope: "punctuation, meta.brace, meta.delimiter", |
| 129 | settings: { foreground: "#79797F" }, |
| 130 | }, |
| 131 | { scope: "invalid", settings: { foreground: "#D52C36" } }, |
| 132 | ], |
| 133 | }; |
| 134 | |
| 135 | const PIERRE_DARK_SHIKI_THEME = { |
| 136 | name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.dark, |
| 137 | type: "dark" as const, |
| 138 | fg: "#adadb1", |
| 139 | bg: "#0a0a0a", |
| 140 | settings: [ |
| 141 | { settings: { foreground: "#adadb1", background: "#0a0a0a" } }, |
| 142 | { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } }, |
| 143 | { |
| 144 | scope: "keyword, storage, storage.type, keyword.operator.expression", |
| 145 | settings: { foreground: "#FF678D" }, |
| 146 | }, |
| 147 | { |
| 148 | scope: "entity.name.function, support.function, meta.function-call", |
| 149 | settings: { foreground: "#9D6AFB" }, |
| 150 | }, |
| 151 | { |
| 152 | scope: "entity.name.type, support.type, support.class", |
| 153 | settings: { foreground: "#D568EA" }, |
| 154 | }, |
| 155 | { |
| 156 | scope: "string, constant.character, punctuation.definition.string", |
| 157 | settings: { foreground: "#5ECC71" }, |
| 158 | }, |
| 159 | { |
| 160 | scope: "constant.numeric, constant.language, constant.other", |
| 161 | settings: { foreground: "#68CDF2" }, |
| 162 | }, |
| 163 | { |
| 164 | scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key", |
| 165 | settings: { foreground: "#FFA359" }, |
| 166 | }, |
| 167 | { |
| 168 | scope: "entity.name.tag, support.class.component", |
| 169 | settings: { foreground: "#5ECC71" }, |
| 170 | }, |
| 171 | { |
| 172 | scope: "punctuation, meta.brace, meta.delimiter", |
| 173 | settings: { foreground: "#79797F" }, |
| 174 | }, |
| 175 | { scope: "invalid", settings: { foreground: "#FF6762" } }, |
| 176 | ], |
| 177 | }; |
| 178 | |
| 179 | const NATIVE_REVIEW_DIFF_SHIKI_THEMES = [ |
| 180 | PIERRE_LIGHT_SHIKI_THEME, |
| 181 | PIERRE_DARK_SHIKI_THEME, |
| 182 | ] satisfies Parameters<typeof createHighlighterCore>[0]["themes"]; |
| 183 | |
| 184 | const NATIVE_REVIEW_DIFF_LANGUAGES = [ |
| 185 | bashLanguage, |
| 186 | diffLanguage, |
| 187 | javascriptLanguage, |
| 188 | jsonLanguage, |
| 189 | jsxLanguage, |
| 190 | tsxLanguage, |
| 191 | typescriptLanguage, |
| 192 | yamlLanguage, |
| 193 | ] satisfies Parameters<typeof createHighlighterCore>[0]["langs"]; |
| 194 | |
| 195 | let nativeHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null; |
| 196 | let javascriptHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null; |
| 197 | |
| 198 | function waitForNextFrame(): Promise<void> { |
| 199 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 200 | } |
| 201 | |
| 202 | function normalizeTokens( |
| 203 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, |
| 204 | ): ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>> { |
| 205 | return tokenLines.map((line) => |
| 206 | line.map((token) => ({ |
| 207 | content: token.content, |
| 208 | color: token.color ?? null, |
| 209 | fontStyle: token.fontStyle ?? null, |
| 210 | })), |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | function createHighlighterHandle( |
| 215 | highlighter: HighlighterCore, |
| 216 | engine: NativeReviewDiffHighlightEngine, |
| 217 | ): NativeReviewDiffHighlighterHandle { |
| 218 | return { |
| 219 | engine, |
| 220 | async tokenize(code, { lang, theme, signal }) { |
| 221 | const lines = code.split("\n"); |
| 222 | const highlighted: Array<ReadonlyArray<NativeReviewDiffToken>> = []; |
| 223 | let grammarState: GrammarState | undefined; |
| 224 | let start = 0; |
| 225 | |
| 226 | while (start < lines.length) { |
| 227 | if (signal?.aborted) return []; |
| 228 | |
| 229 | // Skipping this line leaves its ending grammar state unknown. Resume |
| 230 | // from a fresh state rather than leaving the rest of the segment plain: |
| 231 | // highlighted rows are cached for the sheet's lifetime, so a plain tail |
| 232 | // would stick, and which rows it covered would depend on where the |
| 233 | // first visible window happened to start. |
| 234 | if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { |
| 235 | highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); |
| 236 | grammarState = undefined; |
| 237 | start += 1; |
| 238 | continue; |
| 239 | } |
| 240 | |
| 241 | let end = start; |
| 242 | let characters = 0; |
| 243 | while (end < lines.length) { |
| 244 | const length = lines[end]!.length; |
| 245 | const nextCharacters = characters + length + (end > start ? 1 : 0); |
| 246 | if ( |
| 247 | length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH || |
| 248 | nextCharacters > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS |
| 249 | ) { |
| 250 | break; |
| 251 | } |
| 252 | characters = nextCharacters; |
| 253 | end += 1; |
| 254 | } |
| 255 | |
| 256 | const tokens = highlighter.codeToTokensBase(lines.slice(start, end).join("\n"), { |
| 257 | lang, |
| 258 | theme, |
| 259 | grammarState, |
| 260 | }); |
| 261 | grammarState = highlighter.getLastGrammarState(tokens); |
| 262 | highlighted.push(...normalizeTokens(tokens)); |
| 263 | start = end; |
| 264 | if (start < lines.length) await waitForNextFrame(); |
| 265 | } |
| 266 | |
| 267 | return signal?.aborted ? [] : highlighted; |
| 268 | }, |
| 269 | }; |
| 270 | } |
| 271 | |
| 272 | async function createNativeReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> { |
| 273 | const nativeEngineModule = await import("react-native-shiki-engine"); |
| 274 | if (!nativeEngineModule.isNativeEngineAvailable()) { |
| 275 | throw new NativeReviewDiffHighlighterUnavailableError(); |
| 276 | } |
| 277 | |
| 278 | const highlighter = await createHighlighterCore({ |
| 279 | langs: NATIVE_REVIEW_DIFF_LANGUAGES, |
| 280 | themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES, |
| 281 | engine: nativeEngineModule.createNativeEngine(), |
| 282 | }); |
| 283 | |
| 284 | return createHighlighterHandle(highlighter, "native"); |
| 285 | } |
| 286 | |
| 287 | async function createJavascriptReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> { |
| 288 | const highlighter: HighlighterCore = await createHighlighterCore({ |
| 289 | langs: NATIVE_REVIEW_DIFF_LANGUAGES, |
| 290 | themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES, |
| 291 | engine: createJavaScriptRegexEngine(), |
| 292 | }); |
| 293 | |
| 294 | return createHighlighterHandle(highlighter, "javascript"); |
| 295 | } |
| 296 | |
| 297 | export async function getNativeReviewDiffHighlighter( |
| 298 | engine: NativeReviewDiffHighlightEngine = "native", |
| 299 | ): Promise<NativeReviewDiffHighlighterHandle> { |
| 300 | if (engine === "javascript") { |
| 301 | try { |
| 302 | javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); |
| 303 | return await javascriptHighlighterPromise; |
| 304 | } catch (cause) { |
| 305 | javascriptHighlighterPromise = null; |
| 306 | throw new NativeReviewDiffHighlighterInitializationError({ |
| 307 | requestedEngine: engine, |
| 308 | attemptedEngine: "javascript", |
| 309 | cause, |
| 310 | }); |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | nativeHighlighterPromise ??= createNativeReviewDiffHighlighter() |
| 315 | .catch(async (cause: unknown) => { |
| 316 | const nativeError = isNativeReviewDiffHighlighterUnavailableError(cause) |
| 317 | ? cause |
| 318 | : new NativeReviewDiffHighlighterInitializationError({ |
| 319 | requestedEngine: engine, |
| 320 | attemptedEngine: "native", |
| 321 | cause, |
| 322 | }); |
| 323 | console.warn("[debug-native-diff] native highlighter unavailable", { |
| 324 | error: nativeError, |
| 325 | }); |
| 326 | try { |
| 327 | javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); |
| 328 | return await javascriptHighlighterPromise; |
| 329 | } catch (fallbackCause) { |
| 330 | javascriptHighlighterPromise = null; |
| 331 | throw new NativeReviewDiffHighlighterInitializationError({ |
| 332 | requestedEngine: engine, |
| 333 | attemptedEngine: "javascript", |
| 334 | cause: new AggregateError( |
| 335 | [nativeError, fallbackCause], |
| 336 | "Native and JavaScript review diff highlighter initialization failed.", |
| 337 | { cause: nativeError }, |
| 338 | ), |
| 339 | }); |
| 340 | } |
| 341 | }) |
| 342 | .catch((error) => { |
| 343 | nativeHighlighterPromise = null; |
| 344 | throw error; |
| 345 | }); |
| 346 | return await nativeHighlighterPromise; |
| 347 | } |
| 348 | |
| 349 | function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { |
| 350 | return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; |
| 351 | } |
| 352 | |
| 353 | function hasConsecutiveLineNumbers( |
| 354 | previous: number | null | undefined, |
| 355 | next: number | null | undefined, |
| 356 | ): boolean { |
| 357 | return typeof previous === "number" && typeof next === "number" && next === previous + 1; |
| 358 | } |
| 359 | |
| 360 | function hasOnlyCommentRowsBetween( |
| 361 | rows: ReadonlyArray<NativeReviewDiffRow>, |
| 362 | previousRowIndex: number, |
| 363 | nextRowIndex: number, |
| 364 | ): boolean { |
| 365 | for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { |
| 366 | if (rows[rowIndex]?.kind !== "comment") { |
| 367 | return false; |
| 368 | } |
| 369 | } |
| 370 | return true; |
| 371 | } |
| 372 | |
| 373 | function canShareGrammarContext( |
| 374 | previous: IndexedNativeReviewDiffLineRow, |
| 375 | next: IndexedNativeReviewDiffLineRow, |
| 376 | rows: ReadonlyArray<NativeReviewDiffRow>, |
| 377 | ): boolean { |
| 378 | if ( |
| 379 | next.row.fileId !== previous.row.fileId || |
| 380 | !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) |
| 381 | ) { |
| 382 | return false; |
| 383 | } |
| 384 | |
| 385 | if (previous.row.change === "delete" || next.row.change === "delete") { |
| 386 | return ( |
| 387 | previous.row.change !== "add" && |
| 388 | next.row.change !== "add" && |
| 389 | hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) |
| 390 | ); |
| 391 | } |
| 392 | |
| 393 | if (previous.row.change === "add" || next.row.change === "add") { |
| 394 | return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); |
| 395 | } |
| 396 | |
| 397 | return ( |
| 398 | previous.row.change === "context" && |
| 399 | next.row.change === "context" && |
| 400 | hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && |
| 401 | hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) |
| 402 | ); |
| 403 | } |
| 404 | |
| 405 | function createFileMap(files: ReadonlyArray<NativeReviewDiffFile>) { |
| 406 | return new Map(files.map((file) => [file.id, file])); |
| 407 | } |
| 408 | |
| 409 | function clampRowIndex(index: number, rows: ReadonlyArray<NativeReviewDiffRow>) { |
| 410 | if (rows.length === 0) { |
| 411 | return 0; |
| 412 | } |
| 413 | |
| 414 | return Math.min(rows.length - 1, Math.max(0, Math.floor(index))); |
| 415 | } |
| 416 | |
| 417 | function makePlainTokenFallback( |
| 418 | row: NativeReviewDiffLineRow, |
| 419 | ): ReadonlyArray<NativeReviewDiffToken> { |
| 420 | return [{ content: row.content || " ", color: null, fontStyle: null }]; |
| 421 | } |
| 422 | |
| 423 | export async function highlightNativeReviewDiffVisibleRows( |
| 424 | input: HighlightNativeReviewDiffVisibleRowsInput, |
| 425 | ): Promise<{ |
| 426 | readonly engine: NativeReviewDiffHighlightEngine; |
| 427 | readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>; |
| 428 | readonly rowCount: number; |
| 429 | readonly durationMs: number; |
| 430 | }> { |
| 431 | const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); |
| 432 | if (input.signal?.aborted) { |
| 433 | return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 }; |
| 434 | } |
| 435 | |
| 436 | const startedAt = performance.now(); |
| 437 | const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; |
| 438 | const fileMap = createFileMap(input.files); |
| 439 | const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS; |
| 440 | const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; |
| 441 | const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); |
| 442 | const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); |
| 443 | const selectedRows: IndexedNativeReviewDiffLineRow[] = []; |
| 444 | |
| 445 | for ( |
| 446 | let rowIndex = startIndex; |
| 447 | rowIndex <= endIndex && selectedRows.length < maxRows; |
| 448 | rowIndex += 1 |
| 449 | ) { |
| 450 | const row = input.rows[rowIndex]; |
| 451 | if ( |
| 452 | row && |
| 453 | isHighlightableLineRow(row) && |
| 454 | !input.alreadyHighlightedRowIds?.has(row.id) && |
| 455 | fileMap.has(row.fileId) |
| 456 | ) { |
| 457 | selectedRows.push({ row, rowIndex }); |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {}; |
| 462 | let segmentRows: IndexedNativeReviewDiffLineRow[] = []; |
| 463 | let segmentFile: NativeReviewDiffFile | undefined; |
| 464 | let charactersSinceYield = 0; |
| 465 | |
| 466 | const flushSegment = async () => { |
| 467 | if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) { |
| 468 | segmentRows = []; |
| 469 | segmentFile = undefined; |
| 470 | return; |
| 471 | } |
| 472 | |
| 473 | const code = segmentRows.map(({ row }) => row.content).join("\n"); |
| 474 | if ( |
| 475 | charactersSinceYield > 0 && |
| 476 | charactersSinceYield + code.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS |
| 477 | ) { |
| 478 | await waitForNextFrame(); |
| 479 | charactersSinceYield = 0; |
| 480 | if (input.signal?.aborted) return; |
| 481 | } |
| 482 | const tokenLines = await highlighter.tokenize(code, { |
| 483 | lang: segmentFile.language, |
| 484 | theme, |
| 485 | signal: input.signal, |
| 486 | }); |
| 487 | charactersSinceYield += code.length; |
| 488 | segmentRows.forEach(({ row }, rowIndex) => { |
| 489 | tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); |
| 490 | }); |
| 491 | segmentRows = []; |
| 492 | segmentFile = undefined; |
| 493 | }; |
| 494 | |
| 495 | for (const selectedRow of selectedRows) { |
| 496 | if (input.signal?.aborted) break; |
| 497 | const { row } = selectedRow; |
| 498 | const file = fileMap.get(row.fileId); |
| 499 | if (!file) { |
| 500 | continue; |
| 501 | } |
| 502 | |
| 503 | const previousRow = segmentRows.at(-1); |
| 504 | if ( |
| 505 | segmentFile && |
| 506 | (segmentFile.id !== file.id || |
| 507 | (previousRow !== undefined && |
| 508 | !canShareGrammarContext(previousRow, selectedRow, input.rows))) |
| 509 | ) { |
| 510 | await flushSegment(); |
| 511 | } |
| 512 | |
| 513 | segmentFile = file; |
| 514 | segmentRows.push(selectedRow); |
| 515 | } |
| 516 | await flushSegment(); |
| 517 | |
| 518 | if (input.signal?.aborted) { |
| 519 | return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 }; |
| 520 | } |
| 521 | |
| 522 | return { |
| 523 | engine: highlighter.engine, |
| 524 | tokensByRowId, |
| 525 | rowCount: selectedRows.length, |
| 526 | durationMs: Math.round(performance.now() - startedAt), |
| 527 | }; |
| 528 | } |