addons/isl/src/PartialFileSelection.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ChunkSelectState, LineRegion, SelectLine} from './stackEdit/chunkSelectState';
b69ab319import type {RangeInfo} from './stackEdit/ui/TextEditable';
b69ab3110
b69ab3111import {Set as ImSet} from 'immutable';
b69ab3112import {Checkbox} from 'isl-components/Checkbox';
b69ab3113import {RadioGroup} from 'isl-components/Radio';
b69ab3114import {useRef, useState} from 'react';
b69ab3115import {notEmpty} from 'shared/utils';
b69ab3116import {t} from './i18n';
b69ab3117import {TextEditable} from './stackEdit/ui/TextEditable';
b69ab3118
b69ab3119import './PartialFileSelection.css';
b69ab3120
b69ab3121type Props = {
b69ab3122 chunkSelection: ChunkSelectState;
b69ab3123 setChunkSelection: (state: ChunkSelectState) => void;
b69ab3124};
b69ab3125
b69ab3126export type PartialFileEditMode = 'unified' | 'side-by-side' | 'free-edit';
b69ab3127
b69ab3128export function PartialFileSelection(props: Props) {
b69ab3129 const [editMode, setEditMode] = useState<PartialFileEditMode>('unified');
b69ab3130
b69ab3131 return (
b69ab3132 <div>
b69ab3133 <RadioGroup
b69ab3134 choices={[
b69ab3135 {title: t('Unified'), value: 'unified'},
b69ab3136 {title: t('Side-by-side'), value: 'side-by-side'},
b69ab3137 {title: t('Freeform edit'), value: 'free-edit'},
b69ab3138 ]}
b69ab3139 current={editMode}
b69ab3140 onChange={setEditMode}
b69ab3141 />
b69ab3142 <PartialFileSelectionWithMode {...props} mode={editMode} />
b69ab3143 </div>
b69ab3144 );
b69ab3145}
b69ab3146
b69ab3147export function PartialFileSelectionWithMode(props: Props & {mode: PartialFileEditMode}) {
b69ab3148 if (props.mode === 'unified') {
b69ab3149 return <PartialFileSelectionWithCheckbox {...props} unified={true} />;
b69ab3150 } else if (props.mode === 'side-by-side') {
b69ab3151 return <PartialFileSelectionWithCheckbox {...props} unified={false} />;
b69ab3152 } else {
b69ab3153 return <PartialFileSelectionWithFreeEdit {...props} />;
b69ab3154 }
b69ab3155}
b69ab3156
b69ab3157/** Show chunks with selection checkboxes. Supports unified and side-by-side modes. */
b69ab3158function PartialFileSelectionWithCheckbox(props: Props & {unified?: boolean}) {
b69ab3159 const unified = props.unified ?? true;
b69ab3160
b69ab3161 // State for dragging on line numbers for range selection.
b69ab3162 const lastLine = useRef<SelectLine | null>(null);
b69ab3163
b69ab3164 // Toggle selection of a line or a region.
b69ab3165 const toggleLineOrRegion = (line: SelectLine, region: LineRegion | null) => {
b69ab3166 const selected = !line.selected;
b69ab3167 const lineSelects: Array<[number, boolean]> = [];
b69ab3168 if (region) {
b69ab3169 region.lines.forEach(line => {
b69ab3170 lineSelects.push([line.rawIndex, selected]);
b69ab3171 });
b69ab3172 } else {
b69ab3173 lineSelects.push([line.rawIndex, selected]);
b69ab3174 }
b69ab3175 const newSelection = props.chunkSelection.setSelectedLines(lineSelects);
b69ab3176 lastLine.current = line;
b69ab3177 props.setChunkSelection(newSelection);
b69ab3178 };
b69ab3179
b69ab3180 const handlePointerDown = (
b69ab3181 line: SelectLine,
b69ab3182 region: LineRegion | null,
b69ab3183 e: React.PointerEvent,
b69ab3184 ) => {
b69ab3185 if (e.isPrimary && line.selected !== null) {
b69ab3186 toggleLineOrRegion(line, region);
b69ab3187 }
b69ab3188 };
b69ab3189
b69ab3190 // Toggle selection of a single line.
b69ab3191 const handlePointerEnter = (line: SelectLine, e: React.PointerEvent<HTMLDivElement>) => {
b69ab3192 if (e.buttons === 1 && line.selected !== null && lastLine.current?.rawIndex !== line.rawIndex) {
b69ab3193 const newSelection = props.chunkSelection.setSelectedLines([[line.rawIndex, !line.selected]]);
b69ab3194 lastLine.current = line;
b69ab3195 props.setChunkSelection(newSelection);
b69ab3196 }
b69ab3197 };
b69ab3198
b69ab3199 const lineCheckbox: JSX.Element[] = [];
b69ab31100 const lineANumber: JSX.Element[] = [];
b69ab31101 const lineBNumber: JSX.Element[] = [];
b69ab31102 const lineAContent: JSX.Element[] = []; // side by side left, or unified
b69ab31103 const lineBContent: JSX.Element[] = unified ? lineAContent : []; // side by side right
b69ab31104
b69ab31105 const lineRegions = props.chunkSelection.getLineRegions();
b69ab31106 lineRegions.forEach((region, regionIndex) => {
b69ab31107 const key = region.lines[0].rawIndex;
b69ab31108 if (region.collapsed) {
b69ab31109 // Skip "~~~~" for the first and last collapsed region.
b69ab31110 if (regionIndex > 0 && regionIndex + 1 < lineRegions.length) {
b69ab31111 lineAContent.push(<td key={'line-a' + key} className="line line-context" />);
b69ab31112 if (!unified) {
b69ab31113 lineBContent.push(<td key={'line-b' + key} className="line line-context" />);
b69ab31114 }
b69ab31115 lineCheckbox.push(<td key="c" />);
b69ab31116 lineANumber.push(<td key="anum" />);
b69ab31117 lineBNumber.push(<td key="bnum" />);
b69ab31118 }
b69ab31119 return;
b69ab31120 }
b69ab31121
b69ab31122 let hasPushedCheckbox = false;
b69ab31123 if (!region.same) {
b69ab31124 const selectableCount = region.lines.reduce(
b69ab31125 (acc, line) => acc + (line.selected != null ? 1 : 0),
b69ab31126 0,
b69ab31127 );
b69ab31128 if (selectableCount > 0) {
b69ab31129 const selectedCount = region.lines.reduce((acc, line) => acc + (line.selected ? 1 : 0), 0);
b69ab31130 const indeterminate = selectedCount > 0 && selectedCount < selectableCount;
b69ab31131 const checked = selectedCount === selectableCount;
b69ab31132 lineCheckbox.push(
b69ab31133 <td className="checkbox-anchor" key={`${key}c`}>
b69ab31134 <div className="checkbox-container">
b69ab31135 <Checkbox
b69ab31136 checked={checked}
b69ab31137 indeterminate={indeterminate}
b69ab31138 onChange={() => {
b69ab31139 toggleLineOrRegion(region.lines[0], region);
b69ab31140 }}
b69ab31141 />
b69ab31142 </div>
b69ab31143 </td>,
b69ab31144 );
b69ab31145 }
b69ab31146 hasPushedCheckbox = true;
b69ab31147 }
b69ab31148
b69ab31149 let regionALineCount = 0;
b69ab31150 let regionBLineCount = 0;
b69ab31151 region.lines.forEach(line => {
b69ab31152 const lineClasses = ['line'];
b69ab31153 const isAdd = line.sign.includes('+');
b69ab31154 if (isAdd) {
b69ab31155 lineClasses.push('line-add');
b69ab31156 } else if (line.sign.includes('-')) {
b69ab31157 lineClasses.push('line-del');
b69ab31158 }
b69ab31159
b69ab31160 const lineNumberClasses = ['line-number'];
b69ab31161 if (line.selected != null) {
b69ab31162 lineNumberClasses.push('selectable');
b69ab31163 }
b69ab31164 if (line.selected) {
b69ab31165 lineNumberClasses.push('selected');
b69ab31166 }
b69ab31167
b69ab31168 const hasA = unified || line.aLine != null;
b69ab31169 const hasB =
b69ab31170 unified ||
b69ab31171 line.bLine != null ||
b69ab31172 isAdd; /* isAdd is for "line.bits == 0b010", added by manual editing */
b69ab31173 const key = line.rawIndex;
b69ab31174 const handlerProps = {
b69ab31175 onPointerDown: handlePointerDown.bind(null, line, null),
b69ab31176 onPointerEnter: handlePointerEnter.bind(null, line),
b69ab31177 };
b69ab31178
b69ab31179 if (hasA) {
b69ab31180 lineANumber.push(
b69ab31181 <td
b69ab31182 key={'line-a-num-' + key}
b69ab31183 className={'column-a-number ' + lineNumberClasses.join(' ')}
b69ab31184 {...handlerProps}>
b69ab31185 {line.aLine}
b69ab31186 {'\n'}
b69ab31187 </td>,
b69ab31188 );
b69ab31189 lineAContent.push(
b69ab31190 <td key={'line-a-' + key} className={lineClasses.join(' ')}>
b69ab31191 {line.data}
b69ab31192 </td>,
b69ab31193 );
b69ab31194 regionALineCount += 1;
b69ab31195 }
b69ab31196 if (hasB) {
b69ab31197 lineBNumber.push(
b69ab31198 <td
b69ab31199 key={'line-b-num-' + key}
b69ab31200 className={'column-b-number ' + lineNumberClasses.join(' ')}
b69ab31201 {...handlerProps}>
b69ab31202 {line.bLine}
b69ab31203 {'\n'}
b69ab31204 </td>,
b69ab31205 );
b69ab31206 if (!unified) {
b69ab31207 lineBContent.push(
b69ab31208 <td key={'line-b-' + key} className={lineClasses.join(' ')}>
b69ab31209 {line.data}
b69ab31210 </td>,
b69ab31211 );
b69ab31212 regionBLineCount += 1;
b69ab31213 }
b69ab31214 }
b69ab31215 });
b69ab31216
b69ab31217 if (!unified) {
b69ab31218 let columns: JSX.Element[][] = [];
b69ab31219 let count = 0;
b69ab31220 if (regionALineCount < regionBLineCount) {
b69ab31221 columns = [lineANumber, lineAContent];
b69ab31222 count = regionBLineCount - regionALineCount;
b69ab31223 } else if (regionALineCount > regionBLineCount) {
b69ab31224 columns = [lineBNumber, lineBContent];
b69ab31225 count = regionALineCount - regionBLineCount;
b69ab31226 }
b69ab31227 for (let i = 0; i < count; i++) {
b69ab31228 columns.forEach(column => column.push(<td key={`${key}-pad-${i}`}>{'\n'}</td>));
b69ab31229 }
b69ab31230 }
b69ab31231
b69ab31232 for (let i = hasPushedCheckbox ? 1 : 0; i < Math.max(regionALineCount, regionBLineCount); i++) {
b69ab31233 lineCheckbox.push(<td key={`${key}-pad-${i}`}>{'\n'}</td>);
b69ab31234 }
b69ab31235 });
b69ab31236
b69ab31237 return (
b69ab31238 <>
b69ab31239 <table className="partial-file-selection checkboxes">
b69ab31240 <colgroup>
b69ab31241 <col width={'3em'} />
b69ab31242 <col width={'3em'} />
b69ab31243 <col width={'40px'} />
b69ab31244 <col width={'100%'} />
b69ab31245 </colgroup>
b69ab31246 <tbody>
b69ab31247 {lineAContent.map((line, i) => {
b69ab31248 return (
b69ab31249 <tr key={i} className="column-unified">
b69ab31250 {lineCheckbox[i]}
b69ab31251 {lineANumber[i]}
b69ab31252 {lineBNumber[i]}
b69ab31253 {line}
b69ab31254 </tr>
b69ab31255 );
b69ab31256 })}
b69ab31257 </tbody>
b69ab31258 </table>
b69ab31259 </>
b69ab31260 );
b69ab31261}
b69ab31262
b69ab31263/** Show 3 editors side-by-side: `|A|M|B|`. `M` allows editing. No checkboxes. */
b69ab31264function PartialFileSelectionWithFreeEdit(props: Props) {
b69ab31265 // States for context line expansion.
b69ab31266 const [expandedALines, setExpandedALines] = useState<ImSet<number>>(ImSet);
b69ab31267 const [currentCaretLine, setCurrentSelLine] = useState<number>(-1);
b69ab31268
b69ab31269 const lineRegions = props.chunkSelection.getLineRegions({
b69ab31270 expandedALines,
b69ab31271 expandedSelLine: currentCaretLine,
b69ab31272 });
b69ab31273
b69ab31274 // Needed by TextEditable. Ranges of text on the right side.
b69ab31275 const rangeInfos: RangeInfo[] = [];
b69ab31276 let start = 0;
b69ab31277
b69ab31278 // Render the rows.
b69ab31279 // We draw 3 editors: A (working parent), M (selected), B (working copy).
b69ab31280 // A and B are read-only. M is editable. The user selects content from
b69ab31281 // either A or B to change content of M.
b69ab31282 const lineAContent: JSX.Element[] = [];
b69ab31283 const lineBContent: JSX.Element[] = [];
b69ab31284 const lineMContent: JSX.Element[] = [];
b69ab31285 const lineANumber: JSX.Element[] = [];
b69ab31286 const lineBNumber: JSX.Element[] = [];
b69ab31287 const lineMNumber: JSX.Element[] = [];
b69ab31288
b69ab31289 const insertContextLines = (lines: Readonly<SelectLine[]>) => {
b69ab31290 const handleExpand = () => {
b69ab31291 // Only the "unchanged" lines need expansion.
b69ab31292 // We use line numbers on the "a" side, which remains "stable" regardless of editing.
b69ab31293 const newLines = lines.map(l => l.aLine).filter(notEmpty);
b69ab31294 const newSet = expandedALines.union(newLines);
b69ab31295 setExpandedALines(newSet);
b69ab31296 };
b69ab31297 const key = lines[0].rawIndex;
b69ab31298 const contextLineContent = (
b69ab31299 <div
b69ab31300 key={key}
b69ab31301 className="line line-context"
b69ab31302 title={t('Click to expand lines.')}
b69ab31303 onClick={handleExpand}
b69ab31304 />
b69ab31305 );
b69ab31306 lineAContent.push(contextLineContent);
b69ab31307 lineBContent.push(contextLineContent);
b69ab31308 lineMContent.push(contextLineContent);
b69ab31309 const contextLineNumber = <div key={key} className="line-number line-context" />;
b69ab31310 lineANumber.push(contextLineNumber);
b69ab31311 lineBNumber.push(contextLineNumber);
b69ab31312 lineMNumber.push(contextLineNumber);
b69ab31313 };
b69ab31314
b69ab31315 lineRegions.forEach(region => {
b69ab31316 if (region.collapsed) {
b69ab31317 // Draw "~~~" between chunks.
b69ab31318 insertContextLines(region.lines);
b69ab31319 }
b69ab31320
b69ab31321 const regionClass = region.same ? 'region-same' : 'region-diff';
b69ab31322
b69ab31323 let regionALineCount = 0;
b69ab31324 let regionBLineCount = 0;
b69ab31325 let regionMLineCount = 0;
b69ab31326 region.lines.forEach(line => {
b69ab31327 let dataRangeId = undefined;
b69ab31328 // Provide `RangeInfo` for editing, if the line exists in the selection version.
b69ab31329 // This is also needed for "~~~" context lines.
b69ab31330 if (line.selLine !== null) {
b69ab31331 const end = start + line.data.length;
b69ab31332 dataRangeId = rangeInfos.length;
b69ab31333 rangeInfos.push({start, end});
b69ab31334 start = end;
b69ab31335 }
b69ab31336
b69ab31337 if (region.collapsed) {
b69ab31338 return;
b69ab31339 }
b69ab31340
b69ab31341 // Draw the actual line and line numbers.
b69ab31342 let lineAClass = 'line line-a';
b69ab31343 let lineBClass = 'line line-b';
b69ab31344 let lineMClass = 'line line-m';
b69ab31345
b69ab31346 // Find the "unique" lines (different with other versions). They will be highlighted.
b69ab31347 switch (line.bits) {
b69ab31348 case 0b100:
b69ab31349 lineAClass += ' line-unique';
b69ab31350 break;
b69ab31351 case 0b010:
b69ab31352 lineMClass += ' line-unique';
b69ab31353 break;
b69ab31354 case 0b001:
b69ab31355 lineBClass += ' line-unique';
b69ab31356 break;
b69ab31357 }
b69ab31358
b69ab31359 const key = line.rawIndex;
b69ab31360 if (line.aLine !== null) {
b69ab31361 lineAContent.push(
b69ab31362 <div key={key} className={`${lineAClass} ${regionClass}`}>
b69ab31363 {line.data}
b69ab31364 </div>,
b69ab31365 );
b69ab31366 lineANumber.push(
b69ab31367 <div key={key} className={`line-number line-a ${regionClass}`}>
b69ab31368 {line.aLine}
b69ab31369 </div>,
b69ab31370 );
b69ab31371 regionALineCount += 1;
b69ab31372 }
b69ab31373 if (line.bLine !== null) {
b69ab31374 lineBContent.push(
b69ab31375 <div key={key} className={`${lineBClass} ${regionClass}`}>
b69ab31376 {line.data}
b69ab31377 </div>,
b69ab31378 );
b69ab31379 lineBNumber.push(
b69ab31380 <div key={key} className={`line-number line-b ${regionClass}`}>
b69ab31381 {line.bLine}
b69ab31382 </div>,
b69ab31383 );
b69ab31384 regionBLineCount += 1;
b69ab31385 }
b69ab31386 if (line.selLine !== null) {
b69ab31387 lineMContent.push(
b69ab31388 <div key={key} className={`${lineMClass} ${regionClass}`} data-range-id={dataRangeId}>
b69ab31389 {line.data}
b69ab31390 </div>,
b69ab31391 );
b69ab31392 lineMNumber.push(
b69ab31393 <div key={key} className={`line-number line-m ${regionClass}`}>
b69ab31394 {line.selLine}
b69ab31395 </div>,
b69ab31396 );
b69ab31397 regionMLineCount += 1;
b69ab31398 }
b69ab31399 });
b69ab31400
b69ab31401 // Add padding lines to align the "bottom" of the region.
b69ab31402 const regionPadLineCount = Math.max(regionALineCount, regionBLineCount, regionMLineCount);
b69ab31403 const key = region.lines[0].rawIndex;
b69ab31404 (
b69ab31405 [
b69ab31406 [lineAContent, lineANumber, regionALineCount],
b69ab31407 [lineBContent, lineBNumber, regionBLineCount],
b69ab31408 [lineMContent, lineMNumber, regionMLineCount],
b69ab31409 ] as [JSX.Element[], JSX.Element[], number][]
b69ab31410 ).forEach(([lineContent, lineNumber, lineCount]) => {
b69ab31411 for (let i = 0; i < regionPadLineCount - lineCount; i++) {
b69ab31412 lineContent.push(
b69ab31413 <div key={`${key}-pad-${i}`} className="line">
b69ab31414 {'\n'}
b69ab31415 </div>,
b69ab31416 );
b69ab31417 lineNumber.push(
b69ab31418 <div key={`${key}-pad-${i}`} className="line-number">
b69ab31419 {'\n'}
b69ab31420 </div>,
b69ab31421 );
b69ab31422 }
b69ab31423 });
b69ab31424 });
b69ab31425
b69ab31426 const textValue = props.chunkSelection.getSelectedText();
b69ab31427 const handleTextChange = (text: string) => {
b69ab31428 const newChunkSelect = props.chunkSelection.setSelectedText(text);
b69ab31429 props.setChunkSelection(newChunkSelect);
b69ab31430 };
b69ab31431 const handleSelChange = (start: number, end: number) => {
b69ab31432 // Expand the line of the cursor. But do not expand on range selection (ex. Ctrl+A).
b69ab31433 if (start === end) {
b69ab31434 let selLine = countLines(textValue.substring(0, start));
b69ab31435 if (start == textValue.length && textValue.endsWith('\n')) {
b69ab31436 selLine -= 1;
b69ab31437 }
b69ab31438 setCurrentSelLine(selLine);
b69ab31439 }
b69ab31440 };
b69ab31441
b69ab31442 return (
b69ab31443 <div className="partial-file-selection-width-min-content">
b69ab31444 <div className="partial-file-selection-scroll-y">
b69ab31445 <div className="partial-file-selection free-form">
b69ab31446 <pre className="column-a-number readonly">{lineANumber}</pre>
b69ab31447 <div className="partial-file-selection-scroll-x readonly">
b69ab31448 <pre className="column-a">{lineAContent}</pre>
b69ab31449 </div>
b69ab31450 <pre className="column-m-number">{lineMNumber}</pre>
b69ab31451 <div className="partial-file-selection-scroll-x">
b69ab31452 <TextEditable
b69ab31453 value={textValue}
b69ab31454 rangeInfos={rangeInfos}
b69ab31455 onTextChange={handleTextChange}
b69ab31456 onSelectChange={handleSelChange}>
b69ab31457 <pre className="column-m">{lineMContent}</pre>
b69ab31458 </TextEditable>
b69ab31459 </div>
b69ab31460 <pre className="column-b-number readonly">{lineBNumber}</pre>
b69ab31461 <div className="partial-file-selection-scroll-x readonly">
b69ab31462 <pre className="column-b">{lineBContent}</pre>
b69ab31463 </div>
b69ab31464 </div>
b69ab31465 </div>
b69ab31466 </div>
b69ab31467 );
b69ab31468}
b69ab31469
b69ab31470function countLines(text: string): number {
b69ab31471 let result = 1;
b69ab31472 for (const ch of text) {
b69ab31473 if (ch === '\n') {
b69ab31474 result++;
b69ab31475 }
b69ab31476 }
b69ab31477 return result;
b69ab31478}