addons/isl/src/stackEdit/ui/FileStackEditorLines.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 {TokenizedHunk} from '../../ComparisonView/SplitDiffView/syntaxHighlightingTypes';
b69ab319import type {FlattenLine} from '../../linelog';
b69ab3110import type {FileRev, FileStackState} from '../fileStackState';
b69ab3111import type {RangeInfo} from './TextEditable';
b69ab3112
b69ab3113import {Set as ImSet, Range} from 'immutable';
b69ab3114import {applyTokenizationToLine} from 'shared/createTokenizedIntralineDiff';
b69ab3115import {type Block, collapseContextBlocks, type LineIdx} from 'shared/diff';
b69ab3116import {t} from '../../i18n';
b69ab3117import {prev} from '../revMath';
b69ab3118import {bumpStackEditMetric} from './stackEditState';
b69ab3119
b69ab3120export type ComputedFileStackLines = {
b69ab3121 leftGutter: JSX.Element[];
b69ab3122 leftButtons: JSX.Element[];
b69ab3123 mainContent: JSX.Element[];
b69ab3124 rightGutter: JSX.Element[];
b69ab3125 rightButtons: JSX.Element[];
b69ab3126 lineKind: Array<string>;
b69ab3127};
b69ab3128
b69ab3129export type Mode = 'unified-diff' | 'side-by-side-diff' | 'unified-stack';
b69ab3130
b69ab3131/**
b69ab3132 * Given
b69ab3133 * Compute content lines
b69ab3134 */
b69ab3135export function computeLinesForFileStackEditor(
b69ab3136 stack: FileStackState,
b69ab3137 setStack: (stack: FileStackState) => unknown,
b69ab3138 rev: FileRev,
b69ab3139 mode: Mode,
b69ab3140 aLines: Array<string>,
b69ab3141 bLines: Array<string>,
b69ab3142 highlightedALines: TokenizedHunk | undefined,
b69ab3143 highlightedBLines: TokenizedHunk | undefined,
b69ab3144 abBlocks: Array<Block>,
b69ab3145 cbBlocks: Array<Block>,
b69ab3146 blocks: Array<Block>,
b69ab3147 expandedLines: ImSet<number>,
b69ab3148 setExpandedLines: (v: ImSet<number>) => unknown,
b69ab3149 selectedLineIds: ImSet<string>,
b69ab3150 rangeInfos: Array<RangeInfo>,
b69ab3151 readOnly: boolean,
b69ab3152 textEdit: boolean,
b69ab3153 aTextIsOverridden?: boolean,
b69ab3154): ComputedFileStackLines {
b69ab3155 const leftGutter: JSX.Element[] = [];
b69ab3156 const leftButtons: JSX.Element[] = [];
b69ab3157 const mainContent: JSX.Element[] = [];
b69ab3158 const rightGutter: JSX.Element[] = [];
b69ab3159 const rightButtons: JSX.Element[] = [];
b69ab3160 const lineKind: Array<string> = [];
b69ab3161
b69ab3162 // Can move left? If `leftIsOverridden` is set (because copyFrom),
b69ab3163 // disabling moving left by setting leftMost to true.
b69ab3164 const leftMost = rev <= 1 || aTextIsOverridden === true;
b69ab3165 const rightMost = rev + 1 >= stack.revLength;
b69ab3166
b69ab3167 // Utility to get the "different" block containing the given b-side line number.
b69ab3168 // Used by side-by-side diff to highlight left and right gutters.
b69ab3169 const buildGetDifferentBlockFunction = (blocks: Array<Block>) => {
b69ab3170 let blockIdx = 0;
b69ab3171 return (bIdx: LineIdx): Block | null => {
b69ab3172 while (blockIdx < blocks.length && bIdx >= blocks[blockIdx][1][3]) {
b69ab3173 blockIdx++;
b69ab3174 }
b69ab3175 return blockIdx < blocks.length && blocks[blockIdx][0] === '!' ? blocks[blockIdx] : null;
b69ab3176 };
b69ab3177 };
b69ab3178 const getLeftDifferentBlock = buildGetDifferentBlockFunction(abBlocks);
b69ab3179 const getRightDifferentBlock = buildGetDifferentBlockFunction(cbBlocks);
b69ab3180 const blockToClass = (block: Block | null, add = true): ' add' | ' del' | ' change' | '' =>
b69ab3181 block == null ? '' : block[1][0] === block[1][1] ? (add ? ' add' : ' del') : ' change';
b69ab3182
b69ab3183 // Collapse unchanged context blocks, preserving the context lines.
b69ab3184 const collapsedBlocks = collapseContextBlocks(blocks, (_aLine, bLine) =>
b69ab3185 expandedLines.has(bLine),
b69ab3186 );
b69ab3187
b69ab3188 const handleContextExpand = (b1: LineIdx, b2: LineIdx) => {
b69ab3189 const newSet = expandedLines.union(Range(b1, b2));
b69ab3190 setExpandedLines(newSet);
b69ab3191 };
b69ab3192
b69ab3193 const showLineButtons = !textEdit && !readOnly && mode === 'unified-diff';
b69ab3194 const pushLineButtons = (sign: '=' | '!' | '~', aIdx?: LineIdx, bIdx?: LineIdx) => {
b69ab3195 if (!showLineButtons) {
b69ab3196 return;
b69ab3197 }
b69ab3198
b69ab3199 let leftButton: JSX.Element | string = ' ';
b69ab31100 let rightButton: JSX.Element | string = ' ';
b69ab31101
b69ab31102 // Move one or more lines. If the current line is part of the selection,
b69ab31103 // Move all lines in the selection.
b69ab31104 const moveLines = (revOffset: number) => {
b69ab31105 // Figure out which lines to move on both sides.
b69ab31106 let aIdxToMove: ImSet<LineIdx> = ImSet();
b69ab31107 let bIdxToMove: ImSet<LineIdx> = ImSet();
b69ab31108 if (
b69ab31109 (aIdx != null && selectedLineIds.has(`a${aIdx}`)) ||
b69ab31110 (bIdx != null && selectedLineIds.has(`b${bIdx}`))
b69ab31111 ) {
b69ab31112 // Move selected multiple lines.
b69ab31113 aIdxToMove = aIdxToMove.withMutations(mut => {
b69ab31114 let set = mut;
b69ab31115 selectedLineIds.forEach(id => {
b69ab31116 if (id.startsWith('a')) {
b69ab31117 set = set.add(parseInt(id.slice(1)));
b69ab31118 }
b69ab31119 });
b69ab31120 return set;
b69ab31121 });
b69ab31122 bIdxToMove = bIdxToMove.withMutations(mut => {
b69ab31123 let set = mut;
b69ab31124 selectedLineIds.forEach(id => {
b69ab31125 if (id.startsWith('b')) {
b69ab31126 set = set.add(parseInt(id.slice(1)));
b69ab31127 }
b69ab31128 });
b69ab31129 return set;
b69ab31130 });
b69ab31131 } else {
b69ab31132 // Move a single line.
b69ab31133 if (aIdx != null) {
b69ab31134 aIdxToMove = aIdxToMove.add(aIdx);
b69ab31135 }
b69ab31136 if (bIdx != null) {
b69ab31137 bIdxToMove = bIdxToMove.add(bIdx);
b69ab31138 }
b69ab31139 }
b69ab31140
b69ab31141 // Actually move the lines.
b69ab31142 const aRev = prev(rev);
b69ab31143 const bRev = rev;
b69ab31144 let currentAIdx = 0;
b69ab31145 let currentBIdx = 0;
b69ab31146 let mutStack = stack;
b69ab31147
b69ab31148 // When `aTextIsOverridden` is set (usually because "copyFrom"), the `aLines`
b69ab31149 // does not match `stack.getRev(aRev)`. The lines in `aIdxToMove` might not
b69ab31150 // exist in `stack`. To move `aIdxToMove` properly, patch `stack` to use the
b69ab31151 // `aLines` content temporarily.
b69ab31152 //
b69ab31153 // Practically, this is needed for moving deleted lines right for renamed
b69ab31154 // files. "moving left" is disabled for renamed files so it is ignored now.
b69ab31155 const needPatchARev = aTextIsOverridden && revOffset > 0 && aIdxToMove.size > 0;
b69ab31156 if (needPatchARev) {
b69ab31157 mutStack = mutStack.editText(aRev, aLines.join(''), false);
b69ab31158 }
b69ab31159
b69ab31160 mutStack = mutStack.mapAllLines(line => {
b69ab31161 let newRevs = line.revs;
b69ab31162 let extraLine: undefined | FlattenLine = undefined;
b69ab31163 if (line.revs.has(aRev)) {
b69ab31164 // This is a deletion.
b69ab31165 if (aIdxToMove.has(currentAIdx)) {
b69ab31166 if (revOffset > 0) {
b69ab31167 // Move deletion right - add it in bRev.
b69ab31168 newRevs = newRevs.add(bRev);
b69ab31169 } else {
b69ab31170 // Move deletion left - drop it from aRev.
b69ab31171 newRevs = newRevs.remove(aRev);
b69ab31172 }
b69ab31173 }
b69ab31174 currentAIdx += 1;
b69ab31175 }
b69ab31176 if (line.revs.has(bRev)) {
b69ab31177 // This is an insertion.
b69ab31178 if (bIdxToMove.has(currentBIdx)) {
b69ab31179 if (revOffset > 0) {
b69ab31180 // Move insertion right - drop it in bRev.
b69ab31181 newRevs = newRevs.remove(bRev);
b69ab31182 } else {
b69ab31183 // Move insertion left - add it to aRev.
b69ab31184 // If it already exists in aRev (due to diff shifting), duplicate the line.
b69ab31185 if (newRevs.has(aRev)) {
b69ab31186 extraLine = line.set('revs', ImSet([aRev]));
b69ab31187 } else {
b69ab31188 newRevs = newRevs.add(aRev);
b69ab31189 }
b69ab31190 }
b69ab31191 }
b69ab31192 currentBIdx += 1;
b69ab31193 }
b69ab31194 const newLine = newRevs === line.revs ? line : line.set('revs', newRevs);
b69ab31195 return extraLine != null ? [extraLine, newLine] : [newLine];
b69ab31196 });
b69ab31197
b69ab31198 if (needPatchARev) {
b69ab31199 mutStack = mutStack.editText(aRev, stack.getRev(aRev), false);
b69ab31200 }
b69ab31201
b69ab31202 setStack(mutStack);
b69ab31203
b69ab31204 bumpStackEditMetric('splitMoveLine');
b69ab31205
b69ab31206 // deselect
b69ab31207 window.getSelection()?.removeAllRanges();
b69ab31208 };
b69ab31209
b69ab31210 const selected =
b69ab31211 aIdx != null
b69ab31212 ? selectedLineIds.has(`a${aIdx}`)
b69ab31213 : bIdx != null
b69ab31214 ? selectedLineIds.has(`b${bIdx}`)
b69ab31215 : false;
b69ab31216
b69ab31217 if (!leftMost && sign === '!') {
b69ab31218 const title = selected
b69ab31219 ? t('Move selected line changes left')
b69ab31220 : t('Move this line change left');
b69ab31221 leftButton = (
b69ab31222 <span className="button" role="button" title={title} onClick={() => moveLines(-1)}>
b69ab31223 ⬅
b69ab31224 </span>
b69ab31225 );
b69ab31226 }
b69ab31227 if (!rightMost && sign === '!') {
b69ab31228 const title = selected
b69ab31229 ? t('Move selected line changes right')
b69ab31230 : t('Move this line change right');
b69ab31231 rightButton = (
b69ab31232 <span className="button" role="button" title={title} onClick={() => moveLines(+1)}>
b69ab31233 ⮕
b69ab31234 </span>
b69ab31235 );
b69ab31236 }
b69ab31237
b69ab31238 const className = selected ? 'selected' : '';
b69ab31239
b69ab31240 leftButtons.push(
b69ab31241 <div key={leftButtons.length} className={`${className} left`}>
b69ab31242 {leftButton}
b69ab31243 </div>,
b69ab31244 );
b69ab31245 rightButtons.push(
b69ab31246 <div key={rightButtons.length} className={`${className} right`}>
b69ab31247 {rightButton}
b69ab31248 </div>,
b69ab31249 );
b69ab31250 };
b69ab31251
b69ab31252 let start = 0;
b69ab31253 const nextRangeId = (len: number): number => {
b69ab31254 const id = rangeInfos.length;
b69ab31255 const end = start + len;
b69ab31256 rangeInfos.push({start, end});
b69ab31257 start = end;
b69ab31258 return id;
b69ab31259 };
b69ab31260 const bLineSpan = (bLine: string): JSX.Element => {
b69ab31261 if (!textEdit) {
b69ab31262 return <span>{bLine}</span>;
b69ab31263 }
b69ab31264 const id = nextRangeId(bLine.length);
b69ab31265 return <span data-range-id={id}>{bLine}</span>;
b69ab31266 };
b69ab31267
b69ab31268 collapsedBlocks.forEach(([sign, [a1, a2, b1, b2]]) => {
b69ab31269 if (sign === '~') {
b69ab31270 // Context line.
b69ab31271 leftGutter.push(<div key={a1} className="lineno" />);
b69ab31272 rightGutter.push(<div key={b1} className="lineno" />);
b69ab31273 mainContent.push(
b69ab31274 <div key={b1} className="context-button" onClick={() => handleContextExpand(b1, b2)}>
b69ab31275 {' '}
b69ab31276 </div>,
b69ab31277 );
b69ab31278 lineKind.push('context');
b69ab31279 pushLineButtons(sign, a1, b1);
b69ab31280 if (textEdit) {
b69ab31281 // Still need to update rangeInfos.
b69ab31282 let len = 0;
b69ab31283 for (let bi = b1; bi < b2; ++bi) {
b69ab31284 len += bLines[bi].length;
b69ab31285 }
b69ab31286 nextRangeId(len);
b69ab31287 }
b69ab31288 } else if (sign === '=') {
b69ab31289 // Unchanged.
b69ab31290 for (let ai = a1; ai < a2; ++ai) {
b69ab31291 const bi = ai + b1 - a1;
b69ab31292 const leftIdx = mode === 'unified-diff' ? ai : bi;
b69ab31293 leftGutter.push(
b69ab31294 <div className="lineno" key={ai} data-span-id={`${rev}-${leftIdx}l`}>
b69ab31295 {leftIdx + 1}
b69ab31296 </div>,
b69ab31297 );
b69ab31298 rightGutter.push(
b69ab31299 <div className="lineno" key={bi} data-span-id={`${rev}-${bi}r`}>
b69ab31300 {bi + 1}
b69ab31301 </div>,
b69ab31302 );
b69ab31303 mainContent.push(
b69ab31304 <div key={bi} className="unchanged line">
b69ab31305 {highlightedBLines == null
b69ab31306 ? bLineSpan(bLines[bi])
b69ab31307 : applyTokenizationToLine(bLines[bi], highlightedBLines[bi])}
b69ab31308 </div>,
b69ab31309 );
b69ab31310 lineKind.push('context');
b69ab31311 pushLineButtons(sign, ai, bi);
b69ab31312 }
b69ab31313 } else if (sign === '!') {
b69ab31314 // Changed.
b69ab31315 if (mode === 'unified-diff') {
b69ab31316 // Deleted lines only show up in unified diff.
b69ab31317 for (let ai = a1; ai < a2; ++ai) {
b69ab31318 leftGutter.push(
b69ab31319 <div className="lineno" key={ai}>
b69ab31320 {ai + 1}
b69ab31321 </div>,
b69ab31322 );
b69ab31323 rightGutter.push(<div className="lineno" key={`a${ai}`} />);
b69ab31324 const selId = `a${ai}`;
b69ab31325 let className = 'del line';
b69ab31326 if (selectedLineIds.has(selId)) {
b69ab31327 className += ' selected';
b69ab31328 }
b69ab31329
b69ab31330 pushLineButtons(sign, ai, undefined);
b69ab31331 mainContent.push(
b69ab31332 <div key={-ai} className={className} data-sel-id={selId}>
b69ab31333 {highlightedALines == null
b69ab31334 ? aLines[ai]
b69ab31335 : applyTokenizationToLine(aLines[ai], highlightedALines[ai])}
b69ab31336 </div>,
b69ab31337 );
b69ab31338 lineKind.push(className);
b69ab31339 }
b69ab31340 }
b69ab31341 for (let bi = b1; bi < b2; ++bi) {
b69ab31342 // Inserted lines show up in unified and side-by-side diffs.
b69ab31343 let leftClassName = 'lineno';
b69ab31344 if (mode === 'side-by-side-diff') {
b69ab31345 leftClassName += blockToClass(getLeftDifferentBlock(bi), true);
b69ab31346 }
b69ab31347 leftGutter.push(
b69ab31348 <div className={leftClassName} key={`b${bi}`} data-span-id={`${rev}-${bi}l`}>
b69ab31349 {mode === 'unified-diff' ? null : bi + 1}
b69ab31350 </div>,
b69ab31351 );
b69ab31352 let rightClassName = 'lineno';
b69ab31353 if (mode === 'side-by-side-diff') {
b69ab31354 rightClassName += blockToClass(getRightDifferentBlock(bi), false);
b69ab31355 }
b69ab31356 rightGutter.push(
b69ab31357 <div className={rightClassName} key={bi} data-span-id={`${rev}-${bi}r`}>
b69ab31358 {bi + 1}
b69ab31359 </div>,
b69ab31360 );
b69ab31361 const selId = `b${bi}`;
b69ab31362 let lineClassName = 'line';
b69ab31363 if (mode === 'unified-diff') {
b69ab31364 lineClassName += ' add';
b69ab31365 } else if (mode === 'side-by-side-diff') {
b69ab31366 const lineNoClassNames = leftClassName + rightClassName;
b69ab31367 for (const name of [' change', ' add', ' del']) {
b69ab31368 if (lineNoClassNames.includes(name)) {
b69ab31369 lineClassName += name;
b69ab31370 break;
b69ab31371 }
b69ab31372 }
b69ab31373 }
b69ab31374 if (selectedLineIds.has(selId)) {
b69ab31375 lineClassName += ' selected';
b69ab31376 }
b69ab31377 pushLineButtons(sign, undefined, bi);
b69ab31378 mainContent.push(
b69ab31379 <div key={bi} className={lineClassName} data-sel-id={selId}>
b69ab31380 {highlightedBLines == null
b69ab31381 ? bLineSpan(bLines[bi])
b69ab31382 : applyTokenizationToLine(bLines[bi], highlightedBLines[bi])}
b69ab31383 </div>,
b69ab31384 );
b69ab31385 lineKind.push(lineClassName);
b69ab31386 }
b69ab31387 }
b69ab31388 });
b69ab31389
b69ab31390 return {
b69ab31391 leftGutter,
b69ab31392 leftButtons,
b69ab31393 mainContent,
b69ab31394 rightGutter,
b69ab31395 rightButtons,
b69ab31396 lineKind,
b69ab31397 };
b69ab31398}