17.9 KB593 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 {ReactNode} from 'react';
9import type {Hunk, ParsedDiff} from 'shared/patch/types';
10import type {Result} from '../../types';
11import type {TokenizedDiffHunk, TokenizedHunk} from './syntaxHighlightingTypes';
12import type {Context, OneIndexedLineNumber} from './types';
13
14import {diffChars} from 'diff';
15import {ErrorNotice} from 'isl-components/ErrorNotice';
16import {Icon} from 'isl-components/Icon';
17import React, {useCallback, useEffect, useState} from 'react';
18import {comparisonStringKey} from 'shared/Comparison';
19import organizeLinesIntoGroups from 'shared/SplitDiffView/organizeLinesIntoGroups';
20import {
21 applyTokenizationToLine,
22 createTokenizedIntralineDiff,
23} from 'shared/createTokenizedIntralineDiff';
24import SplitDiffRow, {BlankLineNumber} from './SplitDiffRow';
25import {useTableColumnSelection} from './copyFromSelectedColumn';
26import {useTokenizedContents, useTokenizedHunks} from './syntaxHighlighting';
27
28const MAX_INPUT_LENGTH_FOR_INTRALINE_DIFF = 300;
29
30export type SplitDiffTableProps = {
31 ctx: Context;
32 path: string;
33 patch: ParsedDiff;
34};
35
36export const SplitDiffTable = React.memo(
37 ({ctx, path, patch}: SplitDiffTableProps): React.ReactElement => {
38 const [deletedFileExpanded, setDeletedFileExpanded] = useState<boolean>(false);
39 const [expandedSeparators, setExpandedSeparators] = useState<Readonly<Set<string>>>(
40 () => new Set(),
41 );
42 const onExpand = useCallback(
43 (key: string) => {
44 const amendedSet = new Set(expandedSeparators);
45 amendedSet.add(key);
46 setExpandedSeparators(amendedSet);
47 },
48 [expandedSeparators, setExpandedSeparators],
49 );
50
51 const t = ctx.t ?? (s => s);
52 const tokenization = useTokenizedHunks(patch.newFileName ?? '', patch.hunks, ctx.useThemeHook);
53
54 const {className: tableSelectionClassName, ...tableSelectionProps} = useTableColumnSelection();
55
56 const isDeleted = patch.newFileName === '/dev/null';
57 const isAdded = patch.type === 'Added';
58
59 const unified = ctx.display === 'unified';
60 const displayLineNumbers = ctx.displayLineNumbers ?? true;
61
62 const {hunks} = patch;
63 const lastHunkIndex = hunks.length - 1;
64 const rows: React.ReactElement[] = [];
65 if (!isDeleted || deletedFileExpanded) {
66 hunks.forEach((hunk, index) => {
67 // Show a separator before the first hunk if the file starts with a
68 // section of unmodified lines that is hidden by default.
69 if (index === 0 && (hunk.oldStart !== 1 || hunk.newStart !== 1)) {
70 // TODO: test empty file that went from 644 to 755?
71 const key = 's0';
72 if (expandedSeparators.has(key)) {
73 rows.push(
74 <ExpandingSeparator
75 key={key}
76 ctx={ctx}
77 path={path}
78 start={1}
79 numLines={hunk.oldStart - 1}
80 beforeLineStart={1}
81 afterLineStart={1}
82 />,
83 );
84 } else if (ctx.fetchAdditionalLines != null) {
85 const numLines = Math.max(hunk.oldStart, hunk.newStart) - 1;
86 rows.push(
87 <HunkSeparator key={key} numLines={numLines} onExpand={() => onExpand(key)} t={t} />,
88 );
89 }
90 }
91
92 addRowsForHunk(
93 unified,
94 hunk,
95 path,
96 rows,
97 tokenization?.[index],
98 ctx.openFileToLine,
99 displayLineNumbers,
100 );
101
102 const isLast = index === lastHunkIndex;
103 const nextHunk = hunks[index + 1];
104 const key = `s${hunk.oldStart}`;
105 const canExpand = !isLast || !(isAdded || isDeleted || isHunkProbablyAtEndOfFile(hunk)); // added and deleted files are already expanded
106 if (canExpand) {
107 if (expandedSeparators.has(key)) {
108 const start = hunk.oldStart + hunk.oldLines;
109 const MAX_LINES_FETCH = 10000; // We don't know the total number of lines, so for the last hunk we just request a lot of lines.
110 const numLines = isLast ? MAX_LINES_FETCH : nextHunk.oldStart - start;
111 rows.push(
112 <ExpandingSeparator
113 key={key}
114 ctx={ctx}
115 start={start}
116 numLines={numLines}
117 path={path}
118 beforeLineStart={hunk.oldStart + hunk.oldLines}
119 afterLineStart={hunk.newStart + hunk.newLines}
120 />,
121 );
122 } else if (ctx.fetchAdditionalLines != null) {
123 const numLines = isLast ? null : nextHunk.oldStart - hunk.oldLines - hunk.oldStart;
124 rows.push(
125 <HunkSeparator key={key} numLines={numLines} onExpand={() => onExpand(key)} t={t} />,
126 );
127 }
128 }
129 });
130 } else {
131 rows.push(
132 <SeparatorRow>
133 <InlineRowButton
134 key={'show-deleted'}
135 label={t('Show deleted file')}
136 onClick={() => setDeletedFileExpanded(true)}
137 />
138 </SeparatorRow>,
139 );
140 }
141
142 if (unified) {
143 return (
144 <table
145 className={
146 'split-diff-view-hunk-table display-unified ' + (tableSelectionClassName ?? '')
147 }
148 {...tableSelectionProps}>
149 <colgroup>
150 {displayLineNumbers && <col width={50} />}
151 {displayLineNumbers && <col width={50} />}
152 <col width={'100%'} />
153 </colgroup>
154 <tbody>{rows}</tbody>
155 </table>
156 );
157 }
158 return (
159 <table
160 className={'split-diff-view-hunk-table display-split ' + (tableSelectionClassName ?? '')}
161 {...tableSelectionProps}>
162 <colgroup>
163 {displayLineNumbers && <col width={50} />}
164 <col width={'50%'} />
165 {displayLineNumbers && <col width={50} />}
166 <col width={'50%'} />
167 </colgroup>
168 <tbody>{rows}</tbody>
169 </table>
170 );
171 },
172);
173
174/**
175 * If the last hunk of a file doesn't have as many context lines as it should,
176 * it's because it's at the end of the file. This is a clue we can skip showing
177 * the expander.
178 * This util should only be called on the last hunk in the file.
179 */
180function isHunkProbablyAtEndOfFile(hunk: Hunk): boolean {
181 // we could conceivably check if the initial context length matches the end length, but that's not true in short files.
182 const CONTEXT_LENGTH = 4;
183 return !hunk.lines.slice(-CONTEXT_LENGTH).every(line => line.startsWith(' '));
184}
185
186/**
187 * Adds new rows to the supplied `rows` array.
188 */
189function addRowsForHunk(
190 unified: boolean,
191 hunk: Hunk,
192 path: string,
193 rows: React.ReactElement[],
194 tokenization: TokenizedDiffHunk | undefined,
195 openFileToLine?: (line: OneIndexedLineNumber) => unknown,
196 displayLineNumbers: boolean = true,
197): void {
198 const {oldStart, newStart, lines} = hunk;
199 const groups = organizeLinesIntoGroups(lines);
200 let beforeLineNumber = oldStart;
201 let afterLineNumber = newStart;
202
203 let beforeTokenizedIndex = 0;
204 let afterTokenizedIndex = 0;
205
206 groups.forEach(group => {
207 const {common, removed, added} = group;
208 addUnmodifiedRows(
209 unified,
210 common,
211 path,
212 'common',
213 beforeLineNumber,
214 afterLineNumber,
215 rows,
216 tokenization?.[0].slice(beforeTokenizedIndex),
217 tokenization?.[1].slice(afterTokenizedIndex),
218 openFileToLine,
219 displayLineNumbers,
220 );
221 beforeLineNumber += common.length;
222 afterLineNumber += common.length;
223 beforeTokenizedIndex += common.length;
224 afterTokenizedIndex += common.length;
225
226 // split content, or before lines when unified
227 const linesA = [];
228 // after lines when unified, or empty when using "split"
229 const linesB = [];
230
231 const maxIndex = Math.max(removed.length, added.length);
232 for (let index = 0; index < maxIndex; ++index) {
233 const removedLine = removed[index];
234 const addedLine = added[index];
235 if (removedLine != null && addedLine != null) {
236 let beforeAndAfter;
237
238 if (tokenization != null) {
239 beforeAndAfter = createTokenizedIntralineDiff(
240 removedLine,
241 tokenization[0][beforeTokenizedIndex],
242 addedLine,
243 tokenization[1][afterTokenizedIndex],
244 );
245 } else {
246 beforeAndAfter = createIntralineDiff(removedLine, addedLine);
247 }
248
249 const [before, after] = beforeAndAfter;
250 const [beforeLine, beforeChange, afterLine, afterChange] = SplitDiffRow({
251 beforeLineNumber,
252 before,
253 afterLineNumber,
254 after,
255 rowType: 'modify',
256 path,
257 unified,
258 openFileToLine,
259 });
260
261 if (unified) {
262 linesA.push(
263 <tr key={`${beforeLineNumber}/${afterLineNumber}:b`}>
264 {displayLineNumbers && beforeLine}
265 {displayLineNumbers && <BlankLineNumber before />}
266 {beforeChange}
267 </tr>,
268 );
269 linesB.push(
270 <tr key={`${beforeLineNumber}/${afterLineNumber}:a`}>
271 {displayLineNumbers && <BlankLineNumber after />}
272 {displayLineNumbers && afterLine}
273 {afterChange}
274 </tr>,
275 );
276 } else {
277 linesA.push(
278 <tr key={`${beforeLineNumber}/${afterLineNumber}`}>
279 {displayLineNumbers && beforeLine}
280 {beforeChange}
281 {displayLineNumbers && afterLine}
282 {afterChange}
283 </tr>,
284 );
285 }
286 ++beforeLineNumber;
287 ++afterLineNumber;
288 ++beforeTokenizedIndex;
289 ++afterTokenizedIndex;
290 } else if (removedLine != null) {
291 const [beforeLine, beforeChange, afterLine, afterChange] = SplitDiffRow({
292 beforeLineNumber,
293 before:
294 tokenization?.[0] == null
295 ? removedLine
296 : applyTokenizationToLine(removedLine, tokenization[0][beforeTokenizedIndex]),
297 afterLineNumber: null,
298 after: null,
299 rowType: 'remove',
300 path,
301 unified,
302 openFileToLine,
303 });
304
305 if (unified) {
306 linesA.push(
307 <tr key={`${beforeLineNumber}/`}>
308 {displayLineNumbers && beforeLine}
309 {displayLineNumbers && <BlankLineNumber before />}
310 {beforeChange}
311 </tr>,
312 );
313 } else {
314 linesA.push(
315 <tr key={`${beforeLineNumber}/`}>
316 {displayLineNumbers && beforeLine}
317 {beforeChange}
318 {displayLineNumbers && afterLine}
319 {afterChange}
320 </tr>,
321 );
322 }
323 ++beforeLineNumber;
324 ++beforeTokenizedIndex;
325 } else {
326 const [beforeLine, beforeChange, afterLine, afterChange] = SplitDiffRow({
327 beforeLineNumber: null,
328 before: null,
329 afterLineNumber,
330 after:
331 tokenization?.[1] == null
332 ? addedLine
333 : applyTokenizationToLine(addedLine, tokenization[1][afterTokenizedIndex]),
334 rowType: 'add',
335 path,
336 unified,
337 openFileToLine,
338 });
339
340 if (unified) {
341 linesB.push(
342 <tr key={`/${afterLineNumber}`}>
343 {displayLineNumbers && <BlankLineNumber after />}
344 {displayLineNumbers && afterLine}
345 {afterChange}
346 </tr>,
347 );
348 } else {
349 linesA.push(
350 <tr key={`/${afterLineNumber}`}>
351 {displayLineNumbers && beforeLine}
352 {beforeChange}
353 {displayLineNumbers && afterLine}
354 {afterChange}
355 </tr>,
356 );
357 }
358 ++afterLineNumber;
359 ++afterTokenizedIndex;
360 }
361 }
362
363 rows.push(...linesA, ...linesB);
364 });
365}
366
367function InlineRowButton({onClick, label}: {onClick: () => unknown; label: ReactNode}) {
368 return (
369 // TODO: tabindex or make this a button for accessibility
370 <div className="split-diff-view-inline-row-button" onClick={onClick}>
371 <Icon icon="unfold" />
372 <span className="inline-row-button-label">{label}</span>
373 <Icon icon="unfold" />
374 </div>
375 );
376}
377
378/**
379 * Adds new rows to the supplied `rows` array.
380 */
381function addUnmodifiedRows(
382 unified: boolean,
383 lines: string[],
384 path: string,
385 rowType: 'common' | 'expanded',
386 initialBeforeLineNumber: number,
387 initialAfterLineNumber: number,
388 rows: React.ReactElement[],
389 tokenizationBefore?: TokenizedHunk | undefined,
390 tokenizationAfter?: TokenizedHunk | undefined,
391 openFileToLine?: (line: OneIndexedLineNumber) => unknown,
392 displayLineNumbers: boolean = true,
393): void {
394 let beforeLineNumber = initialBeforeLineNumber;
395 let afterLineNumber = initialAfterLineNumber;
396 lines.forEach((lineContent, i) => {
397 const [beforeLine, beforeChange, afterLine, afterChange] = SplitDiffRow({
398 beforeLineNumber,
399 before:
400 tokenizationBefore?.[i] == null
401 ? lineContent
402 : applyTokenizationToLine(lineContent, tokenizationBefore[i]),
403 afterLineNumber,
404 after:
405 tokenizationAfter?.[i] == null
406 ? lineContent
407 : applyTokenizationToLine(lineContent, tokenizationAfter[i]),
408 rowType,
409 path,
410 unified,
411 openFileToLine,
412 });
413 if (unified) {
414 rows.push(
415 <tr key={`${beforeLineNumber}/${afterLineNumber}`}>
416 {displayLineNumbers && beforeLine}
417 {displayLineNumbers && afterLine}
418 {beforeChange}
419 </tr>,
420 );
421 } else {
422 rows.push(
423 <tr key={`${beforeLineNumber}/${afterLineNumber}`}>
424 {displayLineNumbers && beforeLine}
425 {beforeChange}
426 {displayLineNumbers && afterLine}
427 {afterChange}
428 </tr>,
429 );
430 }
431 ++beforeLineNumber;
432 ++afterLineNumber;
433 });
434}
435
436function createIntralineDiff(
437 before: string,
438 after: string,
439): [React.ReactFragment, React.ReactFragment] {
440 // For lines longer than this, diffChars() can get very expensive to compute
441 // and is likely of little value to the user.
442 if (before.length + after.length > MAX_INPUT_LENGTH_FOR_INTRALINE_DIFF) {
443 return [before, after];
444 }
445
446 const changes = diffChars(before, after);
447 const beforeElements: React.ReactNode[] = [];
448 const afterElements: React.ReactNode[] = [];
449 changes.forEach((change, index) => {
450 const {added, removed, value} = change;
451 if (added) {
452 afterElements.push(
453 <span key={index} className="patch-add-word">
454 {value}
455 </span>,
456 );
457 } else if (removed) {
458 beforeElements.push(
459 <span key={index} className="patch-remove-word">
460 {value}
461 </span>,
462 );
463 } else {
464 beforeElements.push(value);
465 afterElements.push(value);
466 }
467 });
468
469 return [beforeElements, afterElements];
470}
471
472/**
473 * Visual element to delimit the discontinuity in a SplitDiffView.
474 */
475function HunkSeparator({
476 numLines,
477 onExpand,
478 t,
479}: {
480 numLines: number | null;
481 onExpand: () => unknown;
482 t: (s: string) => string;
483}): React.ReactElement | null {
484 if (numLines === 0) {
485 return null;
486 }
487 // TODO: Ensure numLines is never below a certain threshold: it takes up more
488 // space to display the separator than it does to display the text (though
489 // admittedly fetching the collapsed text is an async operation).
490 const label =
491 numLines == null
492 ? // to expand the remaining lines at the end of the file, we don't know the size ahead of time,
493 // just omit the amount to be expanded
494 t('Expand lines')
495 : numLines === 1
496 ? t('Expand 1 line')
497 : t(`Expand ${numLines} lines`);
498 return (
499 <SeparatorRow>
500 <InlineRowButton label={label} onClick={onExpand} />
501 </SeparatorRow>
502 );
503}
504
505/**
506 * This replaces a <HunkSeparator> when the user clicks on it to expand the
507 * hidden file contents.
508 * By rendering this, additional lines are automatically fetched.
509 */
510function ExpandingSeparator({
511 ctx,
512 path,
513 start,
514 numLines,
515 beforeLineStart,
516 afterLineStart,
517}: {
518 ctx: Context;
519 path: string;
520 numLines: number;
521 start: number;
522 beforeLineStart: number;
523 afterLineStart: number;
524}): React.ReactElement {
525 const result = useFetchLines(ctx, numLines, start);
526 const t = ctx.t ?? (s => s);
527
528 const tokenization = useTokenizedContents(path, result?.value, ctx.useThemeHook);
529 if (result == null) {
530 return (
531 <SeparatorRow>
532 <div className="split-diff-view-loading-row">
533 <Icon icon="loading" />
534 <span>{t('Loading...')}</span>
535 </div>
536 </SeparatorRow>
537 );
538 }
539 if (result.error) {
540 return (
541 <SeparatorRow>
542 <div className="split-diff-view-error-row">
543 <ErrorNotice error={result.error} title={t('Unable to fetch additional lines')} />
544 </div>
545 </SeparatorRow>
546 );
547 }
548
549 const rows: React.ReactElement[] = [];
550 addUnmodifiedRows(
551 ctx.display === 'unified',
552 result.value,
553 path,
554 'expanded',
555 beforeLineStart,
556 afterLineStart,
557 rows,
558 tokenization,
559 tokenization,
560 ctx.openFileToLine,
561 ctx.displayLineNumbers,
562 );
563 return <>{rows}</>;
564}
565
566function SeparatorRow({children}: {children: React.ReactNode}): React.ReactElement {
567 return (
568 <tr className="separator-row">
569 <td colSpan={4} className="separator">
570 {children}
571 </td>
572 </tr>
573 );
574}
575
576/** Fetches context lines */
577function useFetchLines(ctx: Context, numLines: number, start: number) {
578 const [fetchedLines, setFetchedLines] = useState<Result<Array<string>> | undefined>(undefined);
579
580 // Use-case controlled key that allows invalidating the fetched lines.
581 const invalidationKey = ctx.useComparisonInvalidationKeyHook?.();
582
583 const comparisonKey = comparisonStringKey(ctx.id.comparison);
584 useEffect(() => {
585 ctx.fetchAdditionalLines?.(ctx.id, start, numLines).then(result => {
586 setFetchedLines(result);
587 });
588 // eslint-disable-next-line react-hooks/exhaustive-deps
589 }, [invalidationKey, ctx.id.path, comparisonKey, numLines, start]);
590
591 return fetchedLines;
592}
593