12.9 KB399 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 {TokenizedHunk} from '../../ComparisonView/SplitDiffView/syntaxHighlightingTypes';
9import type {FlattenLine} from '../../linelog';
10import type {FileRev, FileStackState} from '../fileStackState';
11import type {RangeInfo} from './TextEditable';
12
13import {Set as ImSet, Range} from 'immutable';
14import {applyTokenizationToLine} from 'shared/createTokenizedIntralineDiff';
15import {type Block, collapseContextBlocks, type LineIdx} from 'shared/diff';
16import {t} from '../../i18n';
17import {prev} from '../revMath';
18import {bumpStackEditMetric} from './stackEditState';
19
20export type ComputedFileStackLines = {
21 leftGutter: JSX.Element[];
22 leftButtons: JSX.Element[];
23 mainContent: JSX.Element[];
24 rightGutter: JSX.Element[];
25 rightButtons: JSX.Element[];
26 lineKind: Array<string>;
27};
28
29export type Mode = 'unified-diff' | 'side-by-side-diff' | 'unified-stack';
30
31/**
32 * Given
33 * Compute content lines
34 */
35export function computeLinesForFileStackEditor(
36 stack: FileStackState,
37 setStack: (stack: FileStackState) => unknown,
38 rev: FileRev,
39 mode: Mode,
40 aLines: Array<string>,
41 bLines: Array<string>,
42 highlightedALines: TokenizedHunk | undefined,
43 highlightedBLines: TokenizedHunk | undefined,
44 abBlocks: Array<Block>,
45 cbBlocks: Array<Block>,
46 blocks: Array<Block>,
47 expandedLines: ImSet<number>,
48 setExpandedLines: (v: ImSet<number>) => unknown,
49 selectedLineIds: ImSet<string>,
50 rangeInfos: Array<RangeInfo>,
51 readOnly: boolean,
52 textEdit: boolean,
53 aTextIsOverridden?: boolean,
54): ComputedFileStackLines {
55 const leftGutter: JSX.Element[] = [];
56 const leftButtons: JSX.Element[] = [];
57 const mainContent: JSX.Element[] = [];
58 const rightGutter: JSX.Element[] = [];
59 const rightButtons: JSX.Element[] = [];
60 const lineKind: Array<string> = [];
61
62 // Can move left? If `leftIsOverridden` is set (because copyFrom),
63 // disabling moving left by setting leftMost to true.
64 const leftMost = rev <= 1 || aTextIsOverridden === true;
65 const rightMost = rev + 1 >= stack.revLength;
66
67 // Utility to get the "different" block containing the given b-side line number.
68 // Used by side-by-side diff to highlight left and right gutters.
69 const buildGetDifferentBlockFunction = (blocks: Array<Block>) => {
70 let blockIdx = 0;
71 return (bIdx: LineIdx): Block | null => {
72 while (blockIdx < blocks.length && bIdx >= blocks[blockIdx][1][3]) {
73 blockIdx++;
74 }
75 return blockIdx < blocks.length && blocks[blockIdx][0] === '!' ? blocks[blockIdx] : null;
76 };
77 };
78 const getLeftDifferentBlock = buildGetDifferentBlockFunction(abBlocks);
79 const getRightDifferentBlock = buildGetDifferentBlockFunction(cbBlocks);
80 const blockToClass = (block: Block | null, add = true): ' add' | ' del' | ' change' | '' =>
81 block == null ? '' : block[1][0] === block[1][1] ? (add ? ' add' : ' del') : ' change';
82
83 // Collapse unchanged context blocks, preserving the context lines.
84 const collapsedBlocks = collapseContextBlocks(blocks, (_aLine, bLine) =>
85 expandedLines.has(bLine),
86 );
87
88 const handleContextExpand = (b1: LineIdx, b2: LineIdx) => {
89 const newSet = expandedLines.union(Range(b1, b2));
90 setExpandedLines(newSet);
91 };
92
93 const showLineButtons = !textEdit && !readOnly && mode === 'unified-diff';
94 const pushLineButtons = (sign: '=' | '!' | '~', aIdx?: LineIdx, bIdx?: LineIdx) => {
95 if (!showLineButtons) {
96 return;
97 }
98
99 let leftButton: JSX.Element | string = ' ';
100 let rightButton: JSX.Element | string = ' ';
101
102 // Move one or more lines. If the current line is part of the selection,
103 // Move all lines in the selection.
104 const moveLines = (revOffset: number) => {
105 // Figure out which lines to move on both sides.
106 let aIdxToMove: ImSet<LineIdx> = ImSet();
107 let bIdxToMove: ImSet<LineIdx> = ImSet();
108 if (
109 (aIdx != null && selectedLineIds.has(`a${aIdx}`)) ||
110 (bIdx != null && selectedLineIds.has(`b${bIdx}`))
111 ) {
112 // Move selected multiple lines.
113 aIdxToMove = aIdxToMove.withMutations(mut => {
114 let set = mut;
115 selectedLineIds.forEach(id => {
116 if (id.startsWith('a')) {
117 set = set.add(parseInt(id.slice(1)));
118 }
119 });
120 return set;
121 });
122 bIdxToMove = bIdxToMove.withMutations(mut => {
123 let set = mut;
124 selectedLineIds.forEach(id => {
125 if (id.startsWith('b')) {
126 set = set.add(parseInt(id.slice(1)));
127 }
128 });
129 return set;
130 });
131 } else {
132 // Move a single line.
133 if (aIdx != null) {
134 aIdxToMove = aIdxToMove.add(aIdx);
135 }
136 if (bIdx != null) {
137 bIdxToMove = bIdxToMove.add(bIdx);
138 }
139 }
140
141 // Actually move the lines.
142 const aRev = prev(rev);
143 const bRev = rev;
144 let currentAIdx = 0;
145 let currentBIdx = 0;
146 let mutStack = stack;
147
148 // When `aTextIsOverridden` is set (usually because "copyFrom"), the `aLines`
149 // does not match `stack.getRev(aRev)`. The lines in `aIdxToMove` might not
150 // exist in `stack`. To move `aIdxToMove` properly, patch `stack` to use the
151 // `aLines` content temporarily.
152 //
153 // Practically, this is needed for moving deleted lines right for renamed
154 // files. "moving left" is disabled for renamed files so it is ignored now.
155 const needPatchARev = aTextIsOverridden && revOffset > 0 && aIdxToMove.size > 0;
156 if (needPatchARev) {
157 mutStack = mutStack.editText(aRev, aLines.join(''), false);
158 }
159
160 mutStack = mutStack.mapAllLines(line => {
161 let newRevs = line.revs;
162 let extraLine: undefined | FlattenLine = undefined;
163 if (line.revs.has(aRev)) {
164 // This is a deletion.
165 if (aIdxToMove.has(currentAIdx)) {
166 if (revOffset > 0) {
167 // Move deletion right - add it in bRev.
168 newRevs = newRevs.add(bRev);
169 } else {
170 // Move deletion left - drop it from aRev.
171 newRevs = newRevs.remove(aRev);
172 }
173 }
174 currentAIdx += 1;
175 }
176 if (line.revs.has(bRev)) {
177 // This is an insertion.
178 if (bIdxToMove.has(currentBIdx)) {
179 if (revOffset > 0) {
180 // Move insertion right - drop it in bRev.
181 newRevs = newRevs.remove(bRev);
182 } else {
183 // Move insertion left - add it to aRev.
184 // If it already exists in aRev (due to diff shifting), duplicate the line.
185 if (newRevs.has(aRev)) {
186 extraLine = line.set('revs', ImSet([aRev]));
187 } else {
188 newRevs = newRevs.add(aRev);
189 }
190 }
191 }
192 currentBIdx += 1;
193 }
194 const newLine = newRevs === line.revs ? line : line.set('revs', newRevs);
195 return extraLine != null ? [extraLine, newLine] : [newLine];
196 });
197
198 if (needPatchARev) {
199 mutStack = mutStack.editText(aRev, stack.getRev(aRev), false);
200 }
201
202 setStack(mutStack);
203
204 bumpStackEditMetric('splitMoveLine');
205
206 // deselect
207 window.getSelection()?.removeAllRanges();
208 };
209
210 const selected =
211 aIdx != null
212 ? selectedLineIds.has(`a${aIdx}`)
213 : bIdx != null
214 ? selectedLineIds.has(`b${bIdx}`)
215 : false;
216
217 if (!leftMost && sign === '!') {
218 const title = selected
219 ? t('Move selected line changes left')
220 : t('Move this line change left');
221 leftButton = (
222 <span className="button" role="button" title={title} onClick={() => moveLines(-1)}>
223 ⬅
224 </span>
225 );
226 }
227 if (!rightMost && sign === '!') {
228 const title = selected
229 ? t('Move selected line changes right')
230 : t('Move this line change right');
231 rightButton = (
232 <span className="button" role="button" title={title} onClick={() => moveLines(+1)}>
233 ⮕
234 </span>
235 );
236 }
237
238 const className = selected ? 'selected' : '';
239
240 leftButtons.push(
241 <div key={leftButtons.length} className={`${className} left`}>
242 {leftButton}
243 </div>,
244 );
245 rightButtons.push(
246 <div key={rightButtons.length} className={`${className} right`}>
247 {rightButton}
248 </div>,
249 );
250 };
251
252 let start = 0;
253 const nextRangeId = (len: number): number => {
254 const id = rangeInfos.length;
255 const end = start + len;
256 rangeInfos.push({start, end});
257 start = end;
258 return id;
259 };
260 const bLineSpan = (bLine: string): JSX.Element => {
261 if (!textEdit) {
262 return <span>{bLine}</span>;
263 }
264 const id = nextRangeId(bLine.length);
265 return <span data-range-id={id}>{bLine}</span>;
266 };
267
268 collapsedBlocks.forEach(([sign, [a1, a2, b1, b2]]) => {
269 if (sign === '~') {
270 // Context line.
271 leftGutter.push(<div key={a1} className="lineno" />);
272 rightGutter.push(<div key={b1} className="lineno" />);
273 mainContent.push(
274 <div key={b1} className="context-button" onClick={() => handleContextExpand(b1, b2)}>
275 {' '}
276 </div>,
277 );
278 lineKind.push('context');
279 pushLineButtons(sign, a1, b1);
280 if (textEdit) {
281 // Still need to update rangeInfos.
282 let len = 0;
283 for (let bi = b1; bi < b2; ++bi) {
284 len += bLines[bi].length;
285 }
286 nextRangeId(len);
287 }
288 } else if (sign === '=') {
289 // Unchanged.
290 for (let ai = a1; ai < a2; ++ai) {
291 const bi = ai + b1 - a1;
292 const leftIdx = mode === 'unified-diff' ? ai : bi;
293 leftGutter.push(
294 <div className="lineno" key={ai} data-span-id={`${rev}-${leftIdx}l`}>
295 {leftIdx + 1}
296 </div>,
297 );
298 rightGutter.push(
299 <div className="lineno" key={bi} data-span-id={`${rev}-${bi}r`}>
300 {bi + 1}
301 </div>,
302 );
303 mainContent.push(
304 <div key={bi} className="unchanged line">
305 {highlightedBLines == null
306 ? bLineSpan(bLines[bi])
307 : applyTokenizationToLine(bLines[bi], highlightedBLines[bi])}
308 </div>,
309 );
310 lineKind.push('context');
311 pushLineButtons(sign, ai, bi);
312 }
313 } else if (sign === '!') {
314 // Changed.
315 if (mode === 'unified-diff') {
316 // Deleted lines only show up in unified diff.
317 for (let ai = a1; ai < a2; ++ai) {
318 leftGutter.push(
319 <div className="lineno" key={ai}>
320 {ai + 1}
321 </div>,
322 );
323 rightGutter.push(<div className="lineno" key={`a${ai}`} />);
324 const selId = `a${ai}`;
325 let className = 'del line';
326 if (selectedLineIds.has(selId)) {
327 className += ' selected';
328 }
329
330 pushLineButtons(sign, ai, undefined);
331 mainContent.push(
332 <div key={-ai} className={className} data-sel-id={selId}>
333 {highlightedALines == null
334 ? aLines[ai]
335 : applyTokenizationToLine(aLines[ai], highlightedALines[ai])}
336 </div>,
337 );
338 lineKind.push(className);
339 }
340 }
341 for (let bi = b1; bi < b2; ++bi) {
342 // Inserted lines show up in unified and side-by-side diffs.
343 let leftClassName = 'lineno';
344 if (mode === 'side-by-side-diff') {
345 leftClassName += blockToClass(getLeftDifferentBlock(bi), true);
346 }
347 leftGutter.push(
348 <div className={leftClassName} key={`b${bi}`} data-span-id={`${rev}-${bi}l`}>
349 {mode === 'unified-diff' ? null : bi + 1}
350 </div>,
351 );
352 let rightClassName = 'lineno';
353 if (mode === 'side-by-side-diff') {
354 rightClassName += blockToClass(getRightDifferentBlock(bi), false);
355 }
356 rightGutter.push(
357 <div className={rightClassName} key={bi} data-span-id={`${rev}-${bi}r`}>
358 {bi + 1}
359 </div>,
360 );
361 const selId = `b${bi}`;
362 let lineClassName = 'line';
363 if (mode === 'unified-diff') {
364 lineClassName += ' add';
365 } else if (mode === 'side-by-side-diff') {
366 const lineNoClassNames = leftClassName + rightClassName;
367 for (const name of [' change', ' add', ' del']) {
368 if (lineNoClassNames.includes(name)) {
369 lineClassName += name;
370 break;
371 }
372 }
373 }
374 if (selectedLineIds.has(selId)) {
375 lineClassName += ' selected';
376 }
377 pushLineButtons(sign, undefined, bi);
378 mainContent.push(
379 <div key={bi} className={lineClassName} data-sel-id={selId}>
380 {highlightedBLines == null
381 ? bLineSpan(bLines[bi])
382 : applyTokenizationToLine(bLines[bi], highlightedBLines[bi])}
383 </div>,
384 );
385 lineKind.push(lineClassName);
386 }
387 }
388 });
389
390 return {
391 leftGutter,
392 leftButtons,
393 mainContent,
394 rightGutter,
395 rightButtons,
396 lineKind,
397 };
398}
399