15.3 KB479 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 {ChunkSelectState, LineRegion, SelectLine} from './stackEdit/chunkSelectState';
9import type {RangeInfo} from './stackEdit/ui/TextEditable';
10
11import {Set as ImSet} from 'immutable';
12import {Checkbox} from 'isl-components/Checkbox';
13import {RadioGroup} from 'isl-components/Radio';
14import {useRef, useState} from 'react';
15import {notEmpty} from 'shared/utils';
16import {t} from './i18n';
17import {TextEditable} from './stackEdit/ui/TextEditable';
18
19import './PartialFileSelection.css';
20
21type Props = {
22 chunkSelection: ChunkSelectState;
23 setChunkSelection: (state: ChunkSelectState) => void;
24};
25
26export type PartialFileEditMode = 'unified' | 'side-by-side' | 'free-edit';
27
28export function PartialFileSelection(props: Props) {
29 const [editMode, setEditMode] = useState<PartialFileEditMode>('unified');
30
31 return (
32 <div>
33 <RadioGroup
34 choices={[
35 {title: t('Unified'), value: 'unified'},
36 {title: t('Side-by-side'), value: 'side-by-side'},
37 {title: t('Freeform edit'), value: 'free-edit'},
38 ]}
39 current={editMode}
40 onChange={setEditMode}
41 />
42 <PartialFileSelectionWithMode {...props} mode={editMode} />
43 </div>
44 );
45}
46
47export function PartialFileSelectionWithMode(props: Props & {mode: PartialFileEditMode}) {
48 if (props.mode === 'unified') {
49 return <PartialFileSelectionWithCheckbox {...props} unified={true} />;
50 } else if (props.mode === 'side-by-side') {
51 return <PartialFileSelectionWithCheckbox {...props} unified={false} />;
52 } else {
53 return <PartialFileSelectionWithFreeEdit {...props} />;
54 }
55}
56
57/** Show chunks with selection checkboxes. Supports unified and side-by-side modes. */
58function PartialFileSelectionWithCheckbox(props: Props & {unified?: boolean}) {
59 const unified = props.unified ?? true;
60
61 // State for dragging on line numbers for range selection.
62 const lastLine = useRef<SelectLine | null>(null);
63
64 // Toggle selection of a line or a region.
65 const toggleLineOrRegion = (line: SelectLine, region: LineRegion | null) => {
66 const selected = !line.selected;
67 const lineSelects: Array<[number, boolean]> = [];
68 if (region) {
69 region.lines.forEach(line => {
70 lineSelects.push([line.rawIndex, selected]);
71 });
72 } else {
73 lineSelects.push([line.rawIndex, selected]);
74 }
75 const newSelection = props.chunkSelection.setSelectedLines(lineSelects);
76 lastLine.current = line;
77 props.setChunkSelection(newSelection);
78 };
79
80 const handlePointerDown = (
81 line: SelectLine,
82 region: LineRegion | null,
83 e: React.PointerEvent,
84 ) => {
85 if (e.isPrimary && line.selected !== null) {
86 toggleLineOrRegion(line, region);
87 }
88 };
89
90 // Toggle selection of a single line.
91 const handlePointerEnter = (line: SelectLine, e: React.PointerEvent<HTMLDivElement>) => {
92 if (e.buttons === 1 && line.selected !== null && lastLine.current?.rawIndex !== line.rawIndex) {
93 const newSelection = props.chunkSelection.setSelectedLines([[line.rawIndex, !line.selected]]);
94 lastLine.current = line;
95 props.setChunkSelection(newSelection);
96 }
97 };
98
99 const lineCheckbox: JSX.Element[] = [];
100 const lineANumber: JSX.Element[] = [];
101 const lineBNumber: JSX.Element[] = [];
102 const lineAContent: JSX.Element[] = []; // side by side left, or unified
103 const lineBContent: JSX.Element[] = unified ? lineAContent : []; // side by side right
104
105 const lineRegions = props.chunkSelection.getLineRegions();
106 lineRegions.forEach((region, regionIndex) => {
107 const key = region.lines[0].rawIndex;
108 if (region.collapsed) {
109 // Skip "~~~~" for the first and last collapsed region.
110 if (regionIndex > 0 && regionIndex + 1 < lineRegions.length) {
111 lineAContent.push(<td key={'line-a' + key} className="line line-context" />);
112 if (!unified) {
113 lineBContent.push(<td key={'line-b' + key} className="line line-context" />);
114 }
115 lineCheckbox.push(<td key="c" />);
116 lineANumber.push(<td key="anum" />);
117 lineBNumber.push(<td key="bnum" />);
118 }
119 return;
120 }
121
122 let hasPushedCheckbox = false;
123 if (!region.same) {
124 const selectableCount = region.lines.reduce(
125 (acc, line) => acc + (line.selected != null ? 1 : 0),
126 0,
127 );
128 if (selectableCount > 0) {
129 const selectedCount = region.lines.reduce((acc, line) => acc + (line.selected ? 1 : 0), 0);
130 const indeterminate = selectedCount > 0 && selectedCount < selectableCount;
131 const checked = selectedCount === selectableCount;
132 lineCheckbox.push(
133 <td className="checkbox-anchor" key={`${key}c`}>
134 <div className="checkbox-container">
135 <Checkbox
136 checked={checked}
137 indeterminate={indeterminate}
138 onChange={() => {
139 toggleLineOrRegion(region.lines[0], region);
140 }}
141 />
142 </div>
143 </td>,
144 );
145 }
146 hasPushedCheckbox = true;
147 }
148
149 let regionALineCount = 0;
150 let regionBLineCount = 0;
151 region.lines.forEach(line => {
152 const lineClasses = ['line'];
153 const isAdd = line.sign.includes('+');
154 if (isAdd) {
155 lineClasses.push('line-add');
156 } else if (line.sign.includes('-')) {
157 lineClasses.push('line-del');
158 }
159
160 const lineNumberClasses = ['line-number'];
161 if (line.selected != null) {
162 lineNumberClasses.push('selectable');
163 }
164 if (line.selected) {
165 lineNumberClasses.push('selected');
166 }
167
168 const hasA = unified || line.aLine != null;
169 const hasB =
170 unified ||
171 line.bLine != null ||
172 isAdd; /* isAdd is for "line.bits == 0b010", added by manual editing */
173 const key = line.rawIndex;
174 const handlerProps = {
175 onPointerDown: handlePointerDown.bind(null, line, null),
176 onPointerEnter: handlePointerEnter.bind(null, line),
177 };
178
179 if (hasA) {
180 lineANumber.push(
181 <td
182 key={'line-a-num-' + key}
183 className={'column-a-number ' + lineNumberClasses.join(' ')}
184 {...handlerProps}>
185 {line.aLine}
186 {'\n'}
187 </td>,
188 );
189 lineAContent.push(
190 <td key={'line-a-' + key} className={lineClasses.join(' ')}>
191 {line.data}
192 </td>,
193 );
194 regionALineCount += 1;
195 }
196 if (hasB) {
197 lineBNumber.push(
198 <td
199 key={'line-b-num-' + key}
200 className={'column-b-number ' + lineNumberClasses.join(' ')}
201 {...handlerProps}>
202 {line.bLine}
203 {'\n'}
204 </td>,
205 );
206 if (!unified) {
207 lineBContent.push(
208 <td key={'line-b-' + key} className={lineClasses.join(' ')}>
209 {line.data}
210 </td>,
211 );
212 regionBLineCount += 1;
213 }
214 }
215 });
216
217 if (!unified) {
218 let columns: JSX.Element[][] = [];
219 let count = 0;
220 if (regionALineCount < regionBLineCount) {
221 columns = [lineANumber, lineAContent];
222 count = regionBLineCount - regionALineCount;
223 } else if (regionALineCount > regionBLineCount) {
224 columns = [lineBNumber, lineBContent];
225 count = regionALineCount - regionBLineCount;
226 }
227 for (let i = 0; i < count; i++) {
228 columns.forEach(column => column.push(<td key={`${key}-pad-${i}`}>{'\n'}</td>));
229 }
230 }
231
232 for (let i = hasPushedCheckbox ? 1 : 0; i < Math.max(regionALineCount, regionBLineCount); i++) {
233 lineCheckbox.push(<td key={`${key}-pad-${i}`}>{'\n'}</td>);
234 }
235 });
236
237 return (
238 <>
239 <table className="partial-file-selection checkboxes">
240 <colgroup>
241 <col width={'3em'} />
242 <col width={'3em'} />
243 <col width={'40px'} />
244 <col width={'100%'} />
245 </colgroup>
246 <tbody>
247 {lineAContent.map((line, i) => {
248 return (
249 <tr key={i} className="column-unified">
250 {lineCheckbox[i]}
251 {lineANumber[i]}
252 {lineBNumber[i]}
253 {line}
254 </tr>
255 );
256 })}
257 </tbody>
258 </table>
259 </>
260 );
261}
262
263/** Show 3 editors side-by-side: `|A|M|B|`. `M` allows editing. No checkboxes. */
264function PartialFileSelectionWithFreeEdit(props: Props) {
265 // States for context line expansion.
266 const [expandedALines, setExpandedALines] = useState<ImSet<number>>(ImSet);
267 const [currentCaretLine, setCurrentSelLine] = useState<number>(-1);
268
269 const lineRegions = props.chunkSelection.getLineRegions({
270 expandedALines,
271 expandedSelLine: currentCaretLine,
272 });
273
274 // Needed by TextEditable. Ranges of text on the right side.
275 const rangeInfos: RangeInfo[] = [];
276 let start = 0;
277
278 // Render the rows.
279 // We draw 3 editors: A (working parent), M (selected), B (working copy).
280 // A and B are read-only. M is editable. The user selects content from
281 // either A or B to change content of M.
282 const lineAContent: JSX.Element[] = [];
283 const lineBContent: JSX.Element[] = [];
284 const lineMContent: JSX.Element[] = [];
285 const lineANumber: JSX.Element[] = [];
286 const lineBNumber: JSX.Element[] = [];
287 const lineMNumber: JSX.Element[] = [];
288
289 const insertContextLines = (lines: Readonly<SelectLine[]>) => {
290 const handleExpand = () => {
291 // Only the "unchanged" lines need expansion.
292 // We use line numbers on the "a" side, which remains "stable" regardless of editing.
293 const newLines = lines.map(l => l.aLine).filter(notEmpty);
294 const newSet = expandedALines.union(newLines);
295 setExpandedALines(newSet);
296 };
297 const key = lines[0].rawIndex;
298 const contextLineContent = (
299 <div
300 key={key}
301 className="line line-context"
302 title={t('Click to expand lines.')}
303 onClick={handleExpand}
304 />
305 );
306 lineAContent.push(contextLineContent);
307 lineBContent.push(contextLineContent);
308 lineMContent.push(contextLineContent);
309 const contextLineNumber = <div key={key} className="line-number line-context" />;
310 lineANumber.push(contextLineNumber);
311 lineBNumber.push(contextLineNumber);
312 lineMNumber.push(contextLineNumber);
313 };
314
315 lineRegions.forEach(region => {
316 if (region.collapsed) {
317 // Draw "~~~" between chunks.
318 insertContextLines(region.lines);
319 }
320
321 const regionClass = region.same ? 'region-same' : 'region-diff';
322
323 let regionALineCount = 0;
324 let regionBLineCount = 0;
325 let regionMLineCount = 0;
326 region.lines.forEach(line => {
327 let dataRangeId = undefined;
328 // Provide `RangeInfo` for editing, if the line exists in the selection version.
329 // This is also needed for "~~~" context lines.
330 if (line.selLine !== null) {
331 const end = start + line.data.length;
332 dataRangeId = rangeInfos.length;
333 rangeInfos.push({start, end});
334 start = end;
335 }
336
337 if (region.collapsed) {
338 return;
339 }
340
341 // Draw the actual line and line numbers.
342 let lineAClass = 'line line-a';
343 let lineBClass = 'line line-b';
344 let lineMClass = 'line line-m';
345
346 // Find the "unique" lines (different with other versions). They will be highlighted.
347 switch (line.bits) {
348 case 0b100:
349 lineAClass += ' line-unique';
350 break;
351 case 0b010:
352 lineMClass += ' line-unique';
353 break;
354 case 0b001:
355 lineBClass += ' line-unique';
356 break;
357 }
358
359 const key = line.rawIndex;
360 if (line.aLine !== null) {
361 lineAContent.push(
362 <div key={key} className={`${lineAClass} ${regionClass}`}>
363 {line.data}
364 </div>,
365 );
366 lineANumber.push(
367 <div key={key} className={`line-number line-a ${regionClass}`}>
368 {line.aLine}
369 </div>,
370 );
371 regionALineCount += 1;
372 }
373 if (line.bLine !== null) {
374 lineBContent.push(
375 <div key={key} className={`${lineBClass} ${regionClass}`}>
376 {line.data}
377 </div>,
378 );
379 lineBNumber.push(
380 <div key={key} className={`line-number line-b ${regionClass}`}>
381 {line.bLine}
382 </div>,
383 );
384 regionBLineCount += 1;
385 }
386 if (line.selLine !== null) {
387 lineMContent.push(
388 <div key={key} className={`${lineMClass} ${regionClass}`} data-range-id={dataRangeId}>
389 {line.data}
390 </div>,
391 );
392 lineMNumber.push(
393 <div key={key} className={`line-number line-m ${regionClass}`}>
394 {line.selLine}
395 </div>,
396 );
397 regionMLineCount += 1;
398 }
399 });
400
401 // Add padding lines to align the "bottom" of the region.
402 const regionPadLineCount = Math.max(regionALineCount, regionBLineCount, regionMLineCount);
403 const key = region.lines[0].rawIndex;
404 (
405 [
406 [lineAContent, lineANumber, regionALineCount],
407 [lineBContent, lineBNumber, regionBLineCount],
408 [lineMContent, lineMNumber, regionMLineCount],
409 ] as [JSX.Element[], JSX.Element[], number][]
410 ).forEach(([lineContent, lineNumber, lineCount]) => {
411 for (let i = 0; i < regionPadLineCount - lineCount; i++) {
412 lineContent.push(
413 <div key={`${key}-pad-${i}`} className="line">
414 {'\n'}
415 </div>,
416 );
417 lineNumber.push(
418 <div key={`${key}-pad-${i}`} className="line-number">
419 {'\n'}
420 </div>,
421 );
422 }
423 });
424 });
425
426 const textValue = props.chunkSelection.getSelectedText();
427 const handleTextChange = (text: string) => {
428 const newChunkSelect = props.chunkSelection.setSelectedText(text);
429 props.setChunkSelection(newChunkSelect);
430 };
431 const handleSelChange = (start: number, end: number) => {
432 // Expand the line of the cursor. But do not expand on range selection (ex. Ctrl+A).
433 if (start === end) {
434 let selLine = countLines(textValue.substring(0, start));
435 if (start == textValue.length && textValue.endsWith('\n')) {
436 selLine -= 1;
437 }
438 setCurrentSelLine(selLine);
439 }
440 };
441
442 return (
443 <div className="partial-file-selection-width-min-content">
444 <div className="partial-file-selection-scroll-y">
445 <div className="partial-file-selection free-form">
446 <pre className="column-a-number readonly">{lineANumber}</pre>
447 <div className="partial-file-selection-scroll-x readonly">
448 <pre className="column-a">{lineAContent}</pre>
449 </div>
450 <pre className="column-m-number">{lineMNumber}</pre>
451 <div className="partial-file-selection-scroll-x">
452 <TextEditable
453 value={textValue}
454 rangeInfos={rangeInfos}
455 onTextChange={handleTextChange}
456 onSelectChange={handleSelChange}>
457 <pre className="column-m">{lineMContent}</pre>
458 </TextEditable>
459 </div>
460 <pre className="column-b-number readonly">{lineBNumber}</pre>
461 <div className="partial-file-selection-scroll-x readonly">
462 <pre className="column-b">{lineBContent}</pre>
463 </div>
464 </div>
465 </div>
466 </div>
467 );
468}
469
470function countLines(text: string): number {
471 let result = 1;
472 for (const ch of text) {
473 if (ch === '\n') {
474 result++;
475 }
476 }
477 return result;
478}
479