| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | resolveReviewHighlighterEnginePreference, |
| 18 | 18 | type ReviewHighlighterEngine, |
| 19 | 19 | } from "./reviewHighlighterEngine"; |
| 20 | | import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; |
| 20 | import type { ReviewRenderableLineRow } from "./reviewModel"; |
| 21 | 21 | import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; |
| 22 | 22 | |
| 23 | 23 | export type ReviewDiffTheme = "light" | "dark"; |
| ... | ... | @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { |
| 43 | 43 | readonly diffHighlight?: boolean; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | | export interface ReviewHighlightedFile { |
| 47 | | readonly additionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>; |
| 48 | | readonly deletionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>; |
| 49 | | } |
| 50 | | |
| 51 | | export interface ReviewHighlightFileProgress { |
| 52 | | readonly highlightedFile: ReviewHighlightedFile; |
| 53 | | readonly complete: boolean; |
| 54 | | readonly highlightedLineCount: number; |
| 55 | | } |
| 56 | | |
| 57 | 46 | const SHIKI_THEME_NAME_BY_SCHEME = { |
| 58 | 47 | light: "github-light-default", |
| 59 | 48 | dark: "github-dark-default", |
| ... | ... | @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = |
| 64 | 53 | const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( |
| 65 | 54 | REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, |
| 66 | 55 | ); |
| 67 | | const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( |
| 68 | | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, |
| 69 | | false, |
| 70 | | ); |
| 71 | | const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; |
| 72 | 56 | const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; |
| 73 | 57 | const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; |
| 74 | 58 | const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; |
| 75 | | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); |
| 76 | | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); |
| 77 | 59 | const REVIEW_INITIAL_LANGUAGE_MODULES = [ |
| 78 | 60 | bashLanguage, |
| 79 | 61 | javascriptLanguage, |
| ... | ... | @@ -204,22 +186,6 @@ type LoadedLanguageModule = { |
| 204 | 186 | default: Parameters<HighlighterCore["loadLanguage"]>[0]; |
| 205 | 187 | }; |
| 206 | 188 | |
| 207 | | function resolveReviewHighlighterBooleanFlag( |
| 208 | | value: string | undefined, |
| 209 | | defaultValue: boolean, |
| 210 | | ): boolean { |
| 211 | | switch (value) { |
| 212 | | case "1": |
| 213 | | case "true": |
| 214 | | return true; |
| 215 | | case "0": |
| 216 | | case "false": |
| 217 | | return false; |
| 218 | | default: |
| 219 | | return defaultValue; |
| 220 | | } |
| 221 | | } |
| 222 | | |
| 223 | 189 | function isReviewHighlighterDebugLoggingEnabled(): boolean { |
| 224 | 190 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; |
| 225 | 191 | } |
| ... | ... | @@ -267,7 +233,6 @@ async function getHighlighter(): Promise<HighlighterCore> { |
| 267 | 233 | logReviewHighlighterDiagnostic("initializing", { |
| 268 | 234 | configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, |
| 269 | 235 | preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, |
| 270 | | resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, |
| 271 | 236 | }); |
| 272 | 237 | |
| 273 | 238 | const themes = [githubLightDefault, githubDarkDefault]; |
| ... | ... | @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( |
| 488 | 453 | return candidate; |
| 489 | 454 | } |
| 490 | 455 | |
| 491 | | async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { |
| 492 | | return ( |
| 493 | | resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? |
| 494 | | (await resolveLanguageFromPath(file.path, file.languageHint)) |
| 495 | | ); |
| 496 | | } |
| 497 | | |
| 498 | 456 | function normalizeHighlightedLines( |
| 499 | 457 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, |
| 500 | 458 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { |
| ... | ... | @@ -507,84 +465,6 @@ function normalizeHighlightedLines( |
| 507 | 465 | ); |
| 508 | 466 | } |
| 509 | 467 | |
| 510 | | function makePlainHighlightedLines( |
| 511 | | lines: ReadonlyArray<string>, |
| 512 | | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { |
| 513 | | return lines.map((line) => [ |
| 514 | | { |
| 515 | | content: stripTrailingNewline(line), |
| 516 | | color: null, |
| 517 | | fontStyle: null, |
| 518 | | }, |
| 519 | | ]); |
| 520 | | } |
| 521 | | |
| 522 | | function applyWordAltDiffHighlightsToFile( |
| 523 | | file: ReviewRenderableFile, |
| 524 | | highlighted: ReviewHighlightedFile, |
| 525 | | ): ReviewHighlightedFile { |
| 526 | | const nextAdditionLines = [...highlighted.additionLines]; |
| 527 | | const nextDeletionLines = [...highlighted.deletionLines]; |
| 528 | | const processedPairs = new Set<string>(); |
| 529 | | let changed = false; |
| 530 | | |
| 531 | | file.rows.forEach((row) => { |
| 532 | | if (row.kind !== "line" || row.change === "context" || !row.comparison) { |
| 533 | | return; |
| 534 | | } |
| 535 | | |
| 536 | | const deletionTokenIndex = |
| 537 | | row.change === "delete" |
| 538 | | ? row.deletionTokenIndex |
| 539 | | : row.comparison.change === "delete" |
| 540 | | ? row.comparison.tokenIndex |
| 541 | | : null; |
| 542 | | const additionTokenIndex = |
| 543 | | row.change === "add" |
| 544 | | ? row.additionTokenIndex |
| 545 | | : row.comparison.change === "add" |
| 546 | | ? row.comparison.tokenIndex |
| 547 | | : null; |
| 548 | | |
| 549 | | if (deletionTokenIndex === null || additionTokenIndex === null) { |
| 550 | | return; |
| 551 | | } |
| 552 | | |
| 553 | | const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; |
| 554 | | if (processedPairs.has(pairKey)) { |
| 555 | | return; |
| 556 | | } |
| 557 | | processedPairs.add(pairKey); |
| 558 | | |
| 559 | | const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); |
| 560 | | const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); |
| 561 | | const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); |
| 562 | | |
| 563 | | if (ranges.deletion.length > 0) { |
| 564 | | nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( |
| 565 | | nextDeletionLines[deletionTokenIndex] ?? [], |
| 566 | | ranges.deletion, |
| 567 | | ); |
| 568 | | changed = true; |
| 569 | | } |
| 570 | | |
| 571 | | if (ranges.addition.length > 0) { |
| 572 | | nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( |
| 573 | | nextAdditionLines[additionTokenIndex] ?? [], |
| 574 | | ranges.addition, |
| 575 | | ); |
| 576 | | changed = true; |
| 577 | | } |
| 578 | | }); |
| 579 | | |
| 580 | | return changed |
| 581 | | ? { |
| 582 | | additionLines: nextAdditionLines, |
| 583 | | deletionLines: nextDeletionLines, |
| 584 | | } |
| 585 | | : highlighted; |
| 586 | | } |
| 587 | | |
| 588 | 468 | function applyWordAltDiffHighlightsToSelectedLines(input: { |
| 589 | 469 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; |
| 590 | 470 | readonly tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>>; |
| ... | ... | @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { |
| 733 | 613 | return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); |
| 734 | 614 | } |
| 735 | 615 | |
| 736 | | async function highlightPatchLinesInChunks(input: { |
| 737 | | readonly lines: ReadonlyArray<string>; |
| 738 | | readonly language: string; |
| 739 | | readonly theme: string; |
| 740 | | readonly onChunk: ( |
| 741 | | startIndex: number, |
| 742 | | tokens: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>, |
| 743 | | ) => void; |
| 744 | | }): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> { |
| 745 | | if (input.lines.length === 0) { |
| 746 | | return []; |
| 747 | | } |
| 748 | | |
| 749 | | const highlighter = await getHighlighter(); |
| 750 | | const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; |
| 751 | | |
| 752 | | for ( |
| 753 | | let startIndex = 0; |
| 754 | | startIndex < input.lines.length; |
| 755 | | startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE |
| 756 | | ) { |
| 757 | | const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); |
| 758 | | const chunkTokens: Array<ReadonlyArray<ReviewHighlightedToken>> = []; |
| 759 | | const tokenizableLines: string[] = []; |
| 760 | | const tokenizableIndexes: number[] = []; |
| 761 | | |
| 762 | | lineChunk.forEach((line, index) => { |
| 763 | | const strippedLine = stripTrailingNewline(line); |
| 764 | | if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { |
| 765 | | chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; |
| 766 | | return; |
| 767 | | } |
| 768 | | |
| 769 | | tokenizableIndexes.push(index); |
| 770 | | tokenizableLines.push(strippedLine); |
| 771 | | }); |
| 772 | | |
| 773 | | if (tokenizableLines.length > 0) { |
| 774 | | const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { |
| 775 | | lang: input.language, |
| 776 | | theme: input.theme, |
| 777 | | }); |
| 778 | | const normalizedTokenLines = normalizeHighlightedLines(tokenLines); |
| 779 | | |
| 780 | | tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { |
| 781 | | chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; |
| 782 | | }); |
| 783 | | } |
| 784 | | |
| 785 | | const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); |
| 786 | | highlightedLines.push(...completedChunk); |
| 787 | | input.onChunk(startIndex, completedChunk); |
| 788 | | |
| 789 | | if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { |
| 790 | | await waitForNextFrame(); |
| 791 | | } |
| 792 | | } |
| 793 | | |
| 794 | | return highlightedLines; |
| 795 | | } |
| 796 | | |
| 797 | | function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { |
| 798 | | return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; |
| 799 | | } |
| 800 | | |
| 801 | | function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { |
| 802 | | if (resolvedHighlightCache.has(cacheKey)) { |
| 803 | | resolvedHighlightCache.delete(cacheKey); |
| 804 | | } |
| 805 | | |
| 806 | | resolvedHighlightCache.set(cacheKey, highlighted); |
| 807 | | |
| 808 | | while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { |
| 809 | | const oldestKey = resolvedHighlightCache.keys().next().value; |
| 810 | | if (oldestKey === undefined) { |
| 811 | | break; |
| 812 | | } |
| 813 | | resolvedHighlightCache.delete(oldestKey); |
| 814 | | } |
| 815 | | } |
| 816 | | |
| 817 | | export async function highlightReviewFile( |
| 818 | | file: ReviewRenderableFile, |
| 819 | | theme: ReviewDiffTheme, |
| 820 | | ): Promise<ReviewHighlightedFile> { |
| 821 | | const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; |
| 822 | | const cacheKey = getHighlightCacheKey(file, theme); |
| 823 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 824 | | const resolved = resolvedHighlightCache.get(cacheKey); |
| 825 | | if (resolved) { |
| 826 | | logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { |
| 827 | | fileId: file.id, |
| 828 | | filePath: file.path, |
| 829 | | theme, |
| 830 | | }); |
| 831 | | return resolved; |
| 832 | | } |
| 833 | | const cached = highlightCache.get(cacheKey); |
| 834 | | if (cached) { |
| 835 | | logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { |
| 836 | | fileId: file.id, |
| 837 | | filePath: file.path, |
| 838 | | theme, |
| 839 | | }); |
| 840 | | return cached; |
| 841 | | } |
| 842 | | } |
| 843 | | |
| 844 | | const promise = (async () => { |
| 845 | | const startedAt = Date.now(); |
| 846 | | logReviewHighlighterDiagnostic("file highlight start", { |
| 847 | | fileId: file.id, |
| 848 | | filePath: file.path, |
| 849 | | theme, |
| 850 | | additionLineCount: file.additionLines.length, |
| 851 | | deletionLineCount: file.deletionLines.length, |
| 852 | | rowCount: file.rows.length, |
| 853 | | }); |
| 854 | | const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); |
| 855 | | const language = loadedLanguage ?? (await resolveLanguage(file)); |
| 856 | | if (language === "text") { |
| 857 | | const highlighted = applyWordAltDiffHighlightsToFile(file, { |
| 858 | | additionLines: makePlainHighlightedLines(file.additionLines), |
| 859 | | deletionLines: makePlainHighlightedLines(file.deletionLines), |
| 860 | | }); |
| 861 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 862 | | storeResolvedHighlightedFile(cacheKey, highlighted); |
| 863 | | } |
| 864 | | logReviewHighlighterDiagnostic("file highlight complete", { |
| 865 | | fileId: file.id, |
| 866 | | filePath: file.path, |
| 867 | | theme, |
| 868 | | language, |
| 869 | | highlightedAdditionLineCount: highlighted.additionLines.length, |
| 870 | | highlightedDeletionLineCount: highlighted.deletionLines.length, |
| 871 | | durationMs: Date.now() - startedAt, |
| 872 | | }); |
| 873 | | return highlighted; |
| 874 | | } |
| 875 | | |
| 876 | | const additionLines = await highlightLines( |
| 877 | | joinPatchLines(file.additionLines), |
| 878 | | language, |
| 879 | | shikiTheme, |
| 880 | | ); |
| 881 | | await waitForNextFrame(); |
| 882 | | const deletionLines = await highlightLines( |
| 883 | | joinPatchLines(file.deletionLines), |
| 884 | | language, |
| 885 | | shikiTheme, |
| 886 | | ); |
| 887 | | await waitForNextFrame(); |
| 888 | | |
| 889 | | const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); |
| 890 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 891 | | storeResolvedHighlightedFile(cacheKey, highlighted); |
| 892 | | } |
| 893 | | logReviewHighlighterDiagnostic("file highlight complete", { |
| 894 | | fileId: file.id, |
| 895 | | filePath: file.path, |
| 896 | | theme, |
| 897 | | language, |
| 898 | | highlightedAdditionLineCount: highlighted.additionLines.length, |
| 899 | | highlightedDeletionLineCount: highlighted.deletionLines.length, |
| 900 | | durationMs: Date.now() - startedAt, |
| 901 | | }); |
| 902 | | return highlighted; |
| 903 | | })(); |
| 904 | | |
| 905 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 906 | | highlightCache.set(cacheKey, promise); |
| 907 | | } |
| 908 | | return promise.finally(() => { |
| 909 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 910 | | highlightCache.delete(cacheKey); |
| 911 | | } |
| 912 | | }); |
| 913 | | } |
| 914 | | |
| 915 | | export async function streamHighlightReviewFile( |
| 916 | | file: ReviewRenderableFile, |
| 917 | | theme: ReviewDiffTheme, |
| 918 | | onProgress: (progress: ReviewHighlightFileProgress) => void, |
| 919 | | ): Promise<ReviewHighlightedFile> { |
| 920 | | const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; |
| 921 | | const cacheKey = getHighlightCacheKey(file, theme); |
| 922 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 923 | | const resolved = resolvedHighlightCache.get(cacheKey); |
| 924 | | if (resolved) { |
| 925 | | onProgress({ |
| 926 | | highlightedFile: resolved, |
| 927 | | complete: true, |
| 928 | | highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, |
| 929 | | }); |
| 930 | | return resolved; |
| 931 | | } |
| 932 | | } |
| 933 | | |
| 934 | | const startedAt = Date.now(); |
| 935 | | logReviewHighlighterDiagnostic("file stream highlight start", { |
| 936 | | fileId: file.id, |
| 937 | | filePath: file.path, |
| 938 | | theme, |
| 939 | | additionLineCount: file.additionLines.length, |
| 940 | | deletionLineCount: file.deletionLines.length, |
| 941 | | rowCount: file.rows.length, |
| 942 | | }); |
| 943 | | |
| 944 | | const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); |
| 945 | | const language = loadedLanguage ?? (await resolveLanguage(file)); |
| 946 | | if (language === "text") { |
| 947 | | const highlighted = applyWordAltDiffHighlightsToFile(file, { |
| 948 | | additionLines: makePlainHighlightedLines(file.additionLines), |
| 949 | | deletionLines: makePlainHighlightedLines(file.deletionLines), |
| 950 | | }); |
| 951 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 952 | | storeResolvedHighlightedFile(cacheKey, highlighted); |
| 953 | | } |
| 954 | | onProgress({ |
| 955 | | highlightedFile: highlighted, |
| 956 | | complete: true, |
| 957 | | highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, |
| 958 | | }); |
| 959 | | logReviewHighlighterDiagnostic("file stream highlight complete", { |
| 960 | | fileId: file.id, |
| 961 | | filePath: file.path, |
| 962 | | theme, |
| 963 | | language, |
| 964 | | highlightedAdditionLineCount: highlighted.additionLines.length, |
| 965 | | highlightedDeletionLineCount: highlighted.deletionLines.length, |
| 966 | | highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, |
| 967 | | durationMs: Date.now() - startedAt, |
| 968 | | }); |
| 969 | | return highlighted; |
| 970 | | } |
| 971 | | |
| 972 | | const additionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; |
| 973 | | const deletionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; |
| 974 | | let highlightedLineCount = 0; |
| 975 | | |
| 976 | | await highlightPatchLinesInChunks({ |
| 977 | | lines: file.additionLines, |
| 978 | | language, |
| 979 | | theme: shikiTheme, |
| 980 | | onChunk: (startIndex, tokens) => { |
| 981 | | tokens.forEach((lineTokens, index) => { |
| 982 | | additionLines[startIndex + index] = lineTokens; |
| 983 | | }); |
| 984 | | highlightedLineCount += tokens.length; |
| 985 | | }, |
| 986 | | }); |
| 987 | | await waitForNextFrame(); |
| 988 | | await highlightPatchLinesInChunks({ |
| 989 | | lines: file.deletionLines, |
| 990 | | language, |
| 991 | | theme: shikiTheme, |
| 992 | | onChunk: (startIndex, tokens) => { |
| 993 | | tokens.forEach((lineTokens, index) => { |
| 994 | | deletionLines[startIndex + index] = lineTokens; |
| 995 | | }); |
| 996 | | highlightedLineCount += tokens.length; |
| 997 | | }, |
| 998 | | }); |
| 999 | | |
| 1000 | | const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); |
| 1001 | | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 1002 | | storeResolvedHighlightedFile(cacheKey, highlighted); |
| 1003 | | } |
| 1004 | | onProgress({ |
| 1005 | | highlightedFile: highlighted, |
| 1006 | | complete: true, |
| 1007 | | highlightedLineCount, |
| 1008 | | }); |
| 1009 | | logReviewHighlighterDiagnostic("file stream highlight complete", { |
| 1010 | | fileId: file.id, |
| 1011 | | filePath: file.path, |
| 1012 | | theme, |
| 1013 | | language, |
| 1014 | | highlightedAdditionLineCount: highlighted.additionLines.length, |
| 1015 | | highlightedDeletionLineCount: highlighted.deletionLines.length, |
| 1016 | | highlightedLineCount, |
| 1017 | | durationMs: Date.now() - startedAt, |
| 1018 | | }); |
| 1019 | | return highlighted; |
| 1020 | | } |
| 1021 | | |
| 1022 | 616 | export async function highlightReviewSelectedLines(input: { |
| 1023 | 617 | readonly filePath: string; |
| 1024 | 618 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; |