addons/isl/src/stackEdit/ui/FileStackEditor.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 {Block, LineIdx} from 'shared/diff';
b69ab319import type {FileRev, FileStackState} from '../fileStackState';
b69ab3110import type {Mode} from './FileStackEditorLines';
b69ab3111import type {RangeInfo} from './TextEditable';
b69ab3112
b69ab3113import deepEqual from 'fast-deep-equal';
b69ab3114import {Set as ImSet, List, Range} from 'immutable';
b69ab3115import {Checkbox} from 'isl-components/Checkbox';
b69ab3116import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
b69ab3117import {
b69ab3118 collapseContextBlocks,
b69ab3119 readableDiffBlocks as diffBlocks,
b69ab3120 mergeBlocks,
b69ab3121 splitLines,
b69ab3122} from 'shared/diff';
b69ab3123import {nullthrows} from 'shared/utils';
b69ab3124import {CommitTitle} from '../../CommitTitle';
b69ab3125import {Row, ScrollX, ScrollY} from '../../ComponentUtils';
b69ab3126import {FlattenLine} from '../../linelog';
b69ab3127import {max, next, prev} from '../revMath';
b69ab3128import {computeLinesForFileStackEditor} from './FileStackEditorLines';
b69ab3129import {TextEditable} from './TextEditable';
b69ab3130
b69ab3131import './FileStackEditor.css';
b69ab3132
b69ab3133type EditorRowProps = {
b69ab3134 /**
b69ab3135 * File stack to edit.
b69ab3136 *
b69ab3137 * Note: the editor for rev 1 might want to diff against rev 0 and rev 2,
b69ab3138 * and might have buttons to move lines to other revs. So it needs to
b69ab3139 * know the entire stack.
b69ab3140 */
b69ab3141 stack: FileStackState;
b69ab3142
b69ab3143 /** Function to update the stack. */
b69ab3144 setStack: (stack: FileStackState) => void;
b69ab3145
b69ab3146 /** Function to get the "title" of a rev. */
b69ab3147 getTitle?: (rev: FileRev) => string;
b69ab3148
b69ab3149 /**
b69ab3150 * Skip editing (or showing) given revs.
b69ab3151 * This is usually to skip rev 0 (public, empty) if it is absent.
b69ab3152 * In the side-by-side mode, rev 0 is shown it it is an existing empty file
b69ab3153 * (introduced by a previous public commit). rev 0 is not shown if it is
b69ab3154 * absent, aka. rev 1 added the file.
b69ab3155 */
b69ab3156 skip?: (rev: FileRev) => boolean;
b69ab3157
b69ab3158 /** Diff mode. */
b69ab3159 mode: Mode;
b69ab3160
b69ab3161 /** Whether to enable text editing. This will disable conflicting features. */
b69ab3162 textEdit: boolean;
b69ab3163};
b69ab3164
b69ab3165type EditorProps = EditorRowProps & {
b69ab3166 /** The rev in the stack to edit. */
b69ab3167 rev: FileRev;
b69ab3168};
b69ab3169
b69ab3170export function FileStackEditor(props: EditorProps) {
b69ab3171 const mainContentRef = useRef<HTMLPreElement | null>(null);
b69ab3172 const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet);
b69ab3173 const [selectedLineIds, setSelectedLineIds] = useState<ImSet<string>>(ImSet);
b69ab3174 const [widthStyle, setWidthStyle] = useState<string>('unset');
b69ab3175 const {stack, rev, setStack, mode} = props;
b69ab3176 const readOnly = rev === 0;
b69ab3177 const textEdit = !readOnly && props.textEdit;
b69ab3178 const rangeInfos: RangeInfo[] = [];
b69ab3179
b69ab3180 // Selection change is a document event, not a <pre> event.
b69ab3181 useEffect(() => {
b69ab3182 const handleSelect = () => {
b69ab3183 const selection = window.getSelection();
b69ab3184 if (
b69ab3185 textEdit ||
b69ab3186 selection == null ||
b69ab3187 mainContentRef.current == null ||
b69ab3188 !mainContentRef.current.contains(selection.anchorNode)
b69ab3189 ) {
b69ab3190 setSelectedLineIds(ids => (ids.isEmpty() ? ids : ImSet()));
b69ab3191 return;
b69ab3192 }
b69ab3193 const divs = mainContentRef.current.querySelectorAll<HTMLDivElement>('div[data-sel-id]');
b69ab3194 const selIds: Array<string> = [];
b69ab3195 for (const div of divs) {
b69ab3196 const child = div.lastChild;
b69ab3197 if (child && selection.containsNode(child, true)) {
b69ab3198 selIds.push(nullthrows(div.dataset.selId));
b69ab3199 }
b69ab31100 }
b69ab31101 setSelectedLineIds(ImSet(selIds));
b69ab31102 };
b69ab31103 document.addEventListener('selectionchange', handleSelect);
b69ab31104 return () => {
b69ab31105 document.removeEventListener('selectionchange', handleSelect);
b69ab31106 };
b69ab31107 }, [textEdit]);
b69ab31108
b69ab31109 if (mode === 'unified-stack') {
b69ab31110 return null;
b69ab31111 }
b69ab31112
b69ab31113 // Diff with the left side.
b69ab31114 const bText = stack.getRev(rev);
b69ab31115 const bLines = splitLines(bText);
b69ab31116 const aLines = splitLines(stack.getRev(max(prev(rev), 0)));
b69ab31117 const abBlocks = diffBlocks(aLines, bLines);
b69ab31118
b69ab31119 const rightMost = rev + 1 >= stack.revLength;
b69ab31120
b69ab31121 // For side-by-side diff, we also need to diff with the right side.
b69ab31122 let cbBlocks: Array<Block> = [];
b69ab31123 let blocks = abBlocks;
b69ab31124 if (!rightMost && mode === 'side-by-side-diff') {
b69ab31125 const cText = stack.getRev(next(rev));
b69ab31126 const cLines = splitLines(cText);
b69ab31127 cbBlocks = diffBlocks(cLines, bLines);
b69ab31128 blocks = mergeBlocks(abBlocks, cbBlocks);
b69ab31129 }
b69ab31130
b69ab31131 const {leftGutter, leftButtons, mainContent, rightGutter, rightButtons} =
b69ab31132 computeLinesForFileStackEditor(
b69ab31133 stack,
b69ab31134 setStack,
b69ab31135 rev,
b69ab31136 mode,
b69ab31137 aLines,
b69ab31138 bLines,
b69ab31139 undefined,
b69ab31140 undefined,
b69ab31141 abBlocks,
b69ab31142 cbBlocks,
b69ab31143 blocks,
b69ab31144 expandedLines,
b69ab31145 setExpandedLines,
b69ab31146 selectedLineIds,
b69ab31147 rangeInfos,
b69ab31148 textEdit,
b69ab31149 readOnly,
b69ab31150 );
b69ab31151
b69ab31152 const ribbons: Array<JSX.Element> = [];
b69ab31153 if (mode === 'side-by-side-diff' && rev > 0) {
b69ab31154 abBlocks.forEach(([sign, [a1, a2, b1, b2]]) => {
b69ab31155 if (sign === '!') {
b69ab31156 ribbons.push(
b69ab31157 <Ribbon
b69ab31158 a1={`${rev - 1}-${a1}r`}
b69ab31159 a2={`${rev - 1}-${a2 - 1}r`}
b69ab31160 b1={`${rev}-${b1}l`}
b69ab31161 b2={`${rev}-${b2 - 1}l`}
b69ab31162 outerContainerClass="file-stack-editor-outer-scroll-y"
b69ab31163 innerContainerClass="file-stack-editor"
b69ab31164 key={b1}
b69ab31165 className={b1 === b2 ? 'del' : a1 === a2 ? 'add' : 'change'}
b69ab31166 />,
b69ab31167 );
b69ab31168 }
b69ab31169 });
b69ab31170 }
b69ab31171
b69ab31172 const handleTextChange = (value: string) => {
b69ab31173 const newStack = stack.editText(rev, value);
b69ab31174 setStack(newStack);
b69ab31175 };
b69ab31176
b69ab31177 const handleXScroll: React.UIEventHandler<HTMLDivElement> = e => {
b69ab31178 // Dynamically decide between 'width: fit-content' and 'width: unset'.
b69ab31179 // Affects the position of the [->] "move right" button and the width
b69ab31180 // of the line background for LONG LINES.
b69ab31181 //
b69ab31182 // |ScrollX width|
b69ab31183 // ------------------------------------------------------------------------
b69ab31184 // |Editor width | <- width: unset && scrollLeft == 0
b69ab31185 // |Text width - could be long| text could be longer
b69ab31186 // | [->]| "move right" button is visible
b69ab31187 // ------------------------------------------------------------------------
b69ab31188 // |Editor width | <- width: unset && scrollLeft > 0
b69ab31189 // |+/- highlight| +/- background covers partial text
b69ab31190 // | [->]| "move right" at wrong position
b69ab31191 // ------------------------------------------------------------------------
b69ab31192 // |Editor width | <- width: fit-content && scrollLeft > 0
b69ab31193 // |Text width - could be long| long text width = editor width
b69ab31194 // |+/- highlight | +/- background covers all text
b69ab31195 // | [->]| "move right" at the right side of text
b69ab31196 //
b69ab31197 const newWidthStyle = e.currentTarget?.scrollLeft > 0 ? 'fit-content' : 'unset';
b69ab31198 setWidthStyle(newWidthStyle);
b69ab31199 };
b69ab31200
b69ab31201 const mainStyle: React.CSSProperties = {width: widthStyle};
b69ab31202 const mainContentPre = (
b69ab31203 <pre className="main-content" style={mainStyle} ref={mainContentRef}>
b69ab31204 {mainContent}
b69ab31205 </pre>
b69ab31206 );
b69ab31207
b69ab31208 const showLineButtons = !textEdit && !readOnly && mode === 'unified-diff';
b69ab31209
b69ab31210 return (
b69ab31211 <div className="file-stack-editor-ribbon-no-clip">
b69ab31212 {ribbons}
b69ab31213 <div>
b69ab31214 <Row className="file-stack-editor">
b69ab31215 {showLineButtons && <pre className="column-left-buttons">{leftButtons}</pre>}
b69ab31216 <pre className="column-left-gutter">{leftGutter}</pre>
b69ab31217 <ScrollX hideBar={true} size={500} maxSize={500} onScroll={handleXScroll}>
b69ab31218 {textEdit ? (
b69ab31219 <TextEditable value={bText} rangeInfos={rangeInfos} onTextChange={handleTextChange}>
b69ab31220 {mainContentPre}
b69ab31221 </TextEditable>
b69ab31222 ) : (
b69ab31223 mainContentPre
b69ab31224 )}
b69ab31225 </ScrollX>
b69ab31226 <pre className="column-right-gutter">{rightGutter}</pre>
b69ab31227 {showLineButtons && <pre className="column-right-buttons">{rightButtons}</pre>}
b69ab31228 </Row>
b69ab31229 </div>
b69ab31230 </div>
b69ab31231 );
b69ab31232}
b69ab31233
b69ab31234/** The unified stack view is different from other views. */
b69ab31235function FileStackEditorUnifiedStack(props: EditorRowProps) {
b69ab31236 type ClickPosition = {
b69ab31237 rev: FileRev;
b69ab31238 lineIdx: LineIdx;
b69ab31239 checked?: boolean;
b69ab31240 };
b69ab31241 const [clickStart, setClickStart] = useState<ClickPosition | null>(null);
b69ab31242 const [clickEnd, setClickEnd] = useState<ClickPosition | null>(null);
b69ab31243 const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet);
b69ab31244
b69ab31245 const {stack, setStack, textEdit} = props;
b69ab31246 const {skip, getTitle} = getSkipGetTitleOrDefault(props);
b69ab31247
b69ab31248 const rangeInfos: Array<RangeInfo> = [];
b69ab31249
b69ab31250 const lines = stack.convertToFlattenLines();
b69ab31251 const revs = stack.revs().filter(rev => !skip(rev));
b69ab31252 const lastRev = revs.at(-1) ?? -1;
b69ab31253
b69ab31254 // RangeInfo handling required by TextEditable.
b69ab31255 let start = 0;
b69ab31256 const nextRangeId = (len: number): number => {
b69ab31257 const id = rangeInfos.length;
b69ab31258 const end = start + len;
b69ab31259 rangeInfos.push({start, end});
b69ab31260 start = end;
b69ab31261 return id;
b69ab31262 };
b69ab31263
b69ab31264 // Append `baseName` with `color${rev % 4}`.
b69ab31265 const getColorClassName = (baseName: string, rev: number): string => {
b69ab31266 const colorIdx = rev % 4;
b69ab31267 return `${baseName} color${colorIdx}`;
b69ab31268 };
b69ab31269
b69ab31270 // Header. Commit titles.
b69ab31271 const headerRows = revs.map(rev => {
b69ab31272 const padTds = revs.map(rev2 => (
b69ab31273 <th key={rev2} className={getColorClassName('pad', Math.min(rev2, rev))}></th>
b69ab31274 ));
b69ab31275 const title = getTitle(rev);
b69ab31276 return (
b69ab31277 <tr key={rev}>
b69ab31278 {padTds}
b69ab31279 <th className={getColorClassName('commit-title', rev)}>
b69ab31280 <CommitTitle commitMessage={title} tooltipPlacement="left" />
b69ab31281 </th>
b69ab31282 </tr>
b69ab31283 );
b69ab31284 });
b69ab31285
b69ab31286 // Checkbox range selection.
b69ab31287 const getSelRanges = (start: ClickPosition | null, end: ClickPosition | null) => {
b69ab31288 // Minimal number sort. Note Array.sort is a string sort.
b69ab31289 const sort2 = (a: number, b: number) => (a < b ? [a, b] : [b, a]);
b69ab31290
b69ab31291 // Selected range to highlight.
b69ab31292 let lineRange = Range(0, 0);
b69ab31293 let revRange = Range(0, 0);
b69ab31294 if (start != null && end != null) {
b69ab31295 const [rev1, rev2] = sort2(start.rev, end.rev);
b69ab31296 // Skip rev 0 (public, immutable).
b69ab31297 revRange = Range(Math.max(rev1, 1), rev2 + 1);
b69ab31298 const [lineIdx1, lineIdx2] = sort2(start.lineIdx, end.lineIdx);
b69ab31299 lineRange = Range(lineIdx1, lineIdx2 + 1);
b69ab31300 }
b69ab31301 return [lineRange, revRange];
b69ab31302 };
b69ab31303 const [selLineRange, selRevRange] = getSelRanges(clickStart, clickEnd ?? clickStart);
b69ab31304
b69ab31305 const handlePointerDown = (
b69ab31306 lineIdx: LineIdx,
b69ab31307 rev: FileRev,
b69ab31308 checked: boolean,
b69ab31309 e: React.PointerEvent,
b69ab31310 ) => {
b69ab31311 if (e.isPrimary) {
b69ab31312 setClickStart({lineIdx, rev, checked});
b69ab31313 }
b69ab31314 };
b69ab31315 const handlePointerMove = (lineIdx: LineIdx, rev: FileRev, e: React.PointerEvent) => {
b69ab31316 if (e.isPrimary && clickStart != null) {
b69ab31317 const newClickEnd = {lineIdx, rev, checked: false};
b69ab31318 setClickEnd(v => (deepEqual(v, newClickEnd) ? v : newClickEnd));
b69ab31319 }
b69ab31320 };
b69ab31321 const handlePointerUp = (lineIdx: LineIdx, rev: FileRev, e: React.PointerEvent) => {
b69ab31322 setClickEnd(null);
b69ab31323 if (e.isPrimary && clickStart != null) {
b69ab31324 const [lineRange, revRange] = getSelRanges(clickStart, {lineIdx, rev});
b69ab31325 setClickStart(null);
b69ab31326 const newStack = stack.mapAllLines((line, i) => {
b69ab31327 if (lineRange.contains(i)) {
b69ab31328 const newRevs = clickStart.checked
b69ab31329 ? line.revs.union(revRange)
b69ab31330 : line.revs.subtract(revRange);
b69ab31331 return line.set('revs', newRevs);
b69ab31332 } else {
b69ab31333 return line;
b69ab31334 }
b69ab31335 });
b69ab31336 setStack(newStack);
b69ab31337 }
b69ab31338 };
b69ab31339
b69ab31340 // Context line analysis. We "abuse" the `collapseContextBlocks` by faking the `blocks`.
b69ab31341 const blocks: Array<Block> = [];
b69ab31342 const pushSign = (sign: '!' | '=', end: LineIdx) => {
b69ab31343 const lastBlock = blocks.at(-1);
b69ab31344 if (lastBlock == null) {
b69ab31345 blocks.push([sign, [0, end, 0, end]]);
b69ab31346 } else if (lastBlock[0] === sign) {
b69ab31347 lastBlock[1][1] = lastBlock[1][3] = end;
b69ab31348 } else {
b69ab31349 blocks.push([sign, [lastBlock[1][1], end, lastBlock[1][3], end]]);
b69ab31350 }
b69ab31351 };
b69ab31352 lines.forEach((line, i) => {
b69ab31353 const sign = line.revs.size >= revs.length ? '=' : '!';
b69ab31354 pushSign(sign, i + 1);
b69ab31355 });
b69ab31356 const collapsedBlocks = collapseContextBlocks(blocks, (_a, b) => expandedLines.contains(b));
b69ab31357
b69ab31358 const handleContextExpand = (b1: LineIdx, b2: LineIdx) => {
b69ab31359 const newSet = expandedLines.union(Range(b1, b2));
b69ab31360 setExpandedLines(newSet);
b69ab31361 };
b69ab31362
b69ab31363 // Body. Checkboxes + Line content, or "~~~~" context button.
b69ab31364 const bodyRows: JSX.Element[] = [];
b69ab31365 collapsedBlocks.forEach(([sign, [, , b1, b2]]) => {
b69ab31366 if (sign === '~') {
b69ab31367 const checkboxes = revs.map(rev => (
b69ab31368 <td key={rev} className={getColorClassName('', rev)}></td>
b69ab31369 ));
b69ab31370
b69ab31371 bodyRows.push(
b69ab31372 <tr key={b1}>
b69ab31373 {checkboxes}
b69ab31374 <td className="context-button" onClick={() => handleContextExpand(b1, b2)}>
b69ab31375 <span> </span>
b69ab31376 </td>
b69ab31377 </tr>,
b69ab31378 );
b69ab31379
b69ab31380 if (textEdit) {
b69ab31381 const len = Range(b1, b2).reduce((acc, i) => acc + nullthrows(lines.get(i)).data.length, 0);
b69ab31382 nextRangeId(len);
b69ab31383 }
b69ab31384
b69ab31385 return;
b69ab31386 }
b69ab31387 for (let i = b1; i < b2; ++i) {
b69ab31388 const line = nullthrows(lines.get(i));
b69ab31389 const checkboxes = revs.map(rev => {
b69ab31390 const checked = line.revs.contains(rev);
b69ab31391 let className = 'checkbox' + (rev > 0 ? ' mutable' : ' immutable');
b69ab31392 if (selLineRange.contains(i) && selRevRange.contains(rev)) {
b69ab31393 className += clickStart?.checked ? ' add' : ' del';
b69ab31394 }
b69ab31395 return (
b69ab31396 <td
b69ab31397 key={rev}
b69ab31398 className={getColorClassName(className, rev)}
b69ab31399 onPointerDown={e => handlePointerDown(i, rev, !checked, e)}
b69ab31400 onPointerMove={e => handlePointerMove(i, rev, e)}
b69ab31401 onPointerUp={e => handlePointerUp(i, rev, e)}
b69ab31402 onDragStart={e => e.preventDefault()}>
b69ab31403 <Checkbox
b69ab31404 tabIndex={-1}
b69ab31405 disabled={rev === 0}
b69ab31406 checked={checked}
b69ab31407 style={{pointerEvents: 'none'}}
b69ab31408 />
b69ab31409 </td>
b69ab31410 );
b69ab31411 });
b69ab31412 let tdClass = 'line';
b69ab31413 if (!line.revs.has(lastRev)) {
b69ab31414 tdClass += ' del';
b69ab31415 } else if (line.revs.size < revs.length) {
b69ab31416 tdClass += ' change';
b69ab31417 }
b69ab31418 const rangeId = textEdit ? nextRangeId(line.data.length) : undefined;
b69ab31419 bodyRows.push(
b69ab31420 <tr key={i}>
b69ab31421 {checkboxes}
b69ab31422 <td className={tdClass}>
b69ab31423 <span className="line" data-range-id={rangeId}>
b69ab31424 {line.data}
b69ab31425 </span>
b69ab31426 </td>
b69ab31427 </tr>,
b69ab31428 );
b69ab31429 }
b69ab31430 });
b69ab31431
b69ab31432 let editor = (
b69ab31433 <table className="file-unified-stack-editor">
b69ab31434 <thead>{headerRows}</thead>
b69ab31435 <tbody>{bodyRows}</tbody>
b69ab31436 </table>
b69ab31437 );
b69ab31438
b69ab31439 if (textEdit) {
b69ab31440 const textLines = lines.map(l => l.data).toArray();
b69ab31441 const text = textLines.join('');
b69ab31442 const handleTextChange = (newText: string) => {
b69ab31443 const immutableRev = 0 as FileRev;
b69ab31444 const immutableRevs: ImSet<FileRev> = ImSet([immutableRev]);
b69ab31445 const newTextLines = splitLines(newText);
b69ab31446 const blocks = diffBlocks(textLines, newTextLines);
b69ab31447 const newFlattenLines: List<FlattenLine> = List<FlattenLine>().withMutations(mut => {
b69ab31448 let flattenLines = mut;
b69ab31449 blocks.forEach(([sign, [a1, a2, b1, b2]]) => {
b69ab31450 if (sign === '=') {
b69ab31451 flattenLines = flattenLines.concat(lines.slice(a1, a2));
b69ab31452 } else if (sign === '!') {
b69ab31453 // Plain text does not have "revs" info.
b69ab31454 // We just reuse the last line on the a-side. This should work fine for
b69ab31455 // single-line insertion or edits.
b69ab31456 const fallbackRevs: ImSet<FileRev> = (lines
b69ab31457 .get(Math.max(a1, a2 - 1))
b69ab31458 ?.revs?.delete(immutableRev) ?? ImSet()) as ImSet<FileRev>;
b69ab31459 // Public (immutableRev, rev 0) lines cannot be deleted. Enforce that.
b69ab31460 const aLines = Range(a1, a2)
b69ab31461 .map(ai => lines.get(ai))
b69ab31462 .filter(l => l != null && l.revs.contains(immutableRev))
b69ab31463 .map(l => (l as FlattenLine).set('revs', immutableRevs));
b69ab31464 // Newly added lines cannot insert to (immutableRev, rev 0) either.
b69ab31465 const bLines = Range(b1, b2).map(bi => {
b69ab31466 const data = newTextLines[bi] ?? '';
b69ab31467 const ai = bi - b1 + a1;
b69ab31468 const revs =
b69ab31469 (ai < a2 ? lines.get(ai)?.revs?.delete(immutableRev) : null) ?? fallbackRevs;
b69ab31470 return FlattenLine({data, revs});
b69ab31471 });
b69ab31472 flattenLines = flattenLines.concat(aLines).concat(bLines);
b69ab31473 }
b69ab31474 });
b69ab31475 return flattenLines;
b69ab31476 });
b69ab31477 const newStack = stack.fromFlattenLines(newFlattenLines, stack.revLength);
b69ab31478 setStack(newStack);
b69ab31479 };
b69ab31480
b69ab31481 editor = (
b69ab31482 <TextEditable rangeInfos={rangeInfos} value={text} onTextChange={handleTextChange}>
b69ab31483 {editor}
b69ab31484 </TextEditable>
b69ab31485 );
b69ab31486 }
b69ab31487
b69ab31488 return <ScrollY maxSize="calc((100vh / var(--zoom)) - 300px)">{editor}</ScrollY>;
b69ab31489}
b69ab31490
b69ab31491export function FileStackEditorRow(props: EditorRowProps) {
b69ab31492 if (props.mode === 'unified-stack') {
b69ab31493 return FileStackEditorUnifiedStack(props);
b69ab31494 }
b69ab31495
b69ab31496 // skip rev 0, the "public" revision for unified diff.
b69ab31497 const {skip, getTitle} = getSkipGetTitleOrDefault(props);
b69ab31498 const revs = props.stack
b69ab31499 .revs()
b69ab31500 .slice(props.mode === 'unified-diff' ? 1 : 0)
b69ab31501 .filter(r => !skip(r));
b69ab31502 return (
b69ab31503 <ScrollX>
b69ab31504 <Row className="file-stack-editor-row">
b69ab31505 {revs.map(rev => {
b69ab31506 const title = getTitle(rev);
b69ab31507 return (
b69ab31508 <div key={rev}>
b69ab31509 <CommitTitle className="filerev-title" commitMessage={title} />
b69ab31510 <FileStackEditor rev={rev} {...props} />
b69ab31511 </div>
b69ab31512 );
b69ab31513 })}
b69ab31514 </Row>
b69ab31515 </ScrollX>
b69ab31516 );
b69ab31517}
b69ab31518
b69ab31519function getSkipGetTitleOrDefault(props: EditorRowProps): {
b69ab31520 skip: (rev: FileRev) => boolean;
b69ab31521 getTitle: (rev: FileRev) => string;
b69ab31522} {
b69ab31523 const skip = props.skip ?? ((rev: FileRev) => rev === 0);
b69ab31524 const getTitle = props.getTitle ?? (() => '');
b69ab31525 return {skip, getTitle};
b69ab31526}
b69ab31527
b69ab31528/**
b69ab31529 * The "connector" between two editors.
b69ab31530 *
b69ab31531 * Takes 4 data-span-id attributes:
b69ab31532 *
b69ab31533 * +------------+ +------------+
b69ab31534 * | containerA | | containerB |
b69ab31535 * | +----+~~~~~~~~+----+ |
b69ab31536 * | | a1 | | b1 | |
b69ab31537 * | +----+ +----+ |
b69ab31538 * | | .. | Ribbon | .. | |
b69ab31539 * | +----+ +----+ |
b69ab31540 * | | a2 | | b2 | |
b69ab31541 * | +----+~~~~~~~~+----+ |
b69ab31542 * | | | |
b69ab31543 * +------------+ +------------+
b69ab31544 *
b69ab31545 * The ribbon is positioned relative to (outer) containerB,
b69ab31546 * the editor on the right side.
b69ab31547 *
b69ab31548 * The ribbon position will be recalculated if either containerA
b69ab31549 * or containerB gets resized or scrolled. Note there are inner
b69ab31550 * and outer containers. The scroll check is on the outer container
b69ab31551 * with the `overflow-y: auto`. The resize check is on the inner
b69ab31552 * container, since the outer container remains the same size
b69ab31553 * once overflowed.
b69ab31554 *
b69ab31555 * The ribbons are drawn outside the scroll container, and need
b69ab31556 * another container to have the `overflow: visible` behavior,
b69ab31557 * like:
b69ab31558 *
b69ab31559 * <div style={{overflow: 'visible', position: 'relative'}}>
b69ab31560 * <Ribbon />
b69ab31561 * <ScrollY className="outerContainer">
b69ab31562 * <Something className="innerContainer" />
b69ab31563 * </ScrollY>
b69ab31564 * </div>
b69ab31565 *
b69ab31566 * If one of a1 and a2 is missing, the a-side range is then
b69ab31567 * considered zero-height. This is useful for pure deletion
b69ab31568 * or insertion. Same for b1 and b2.
b69ab31569 */
b69ab31570function Ribbon(props: {
b69ab31571 a1: string;
b69ab31572 a2: string;
b69ab31573 b1: string;
b69ab31574 b2: string;
b69ab31575 outerContainerClass: string;
b69ab31576 innerContainerClass: string;
b69ab31577 className: string;
b69ab31578}) {
b69ab31579 type RibbonPos = {
b69ab31580 top: number;
b69ab31581 width: number;
b69ab31582 height: number;
b69ab31583 path: string;
b69ab31584 };
b69ab31585 const [pos, setPos] = useState<RibbonPos | null>(null);
b69ab31586 type E = HTMLElement;
b69ab31587
b69ab31588 type Containers = {
b69ab31589 resize: E[];
b69ab31590 scroll: E[];
b69ab31591 };
b69ab31592
b69ab31593 useLayoutEffect(() => {
b69ab31594 // Get the container elements and recaluclate positions.
b69ab31595 // Returns an empty array if the containers are not found.
b69ab31596 const repositionAndGetContainers = (): Containers | undefined => {
b69ab31597 // Find a1, a2, b1, b2. a2 and b2 are nullable.
b69ab31598 const select = (spanId: string): E | null =>
b69ab31599 spanId === ''
b69ab31600 ? null
b69ab31601 : document.querySelector(`.${props.outerContainerClass} [data-span-id="${spanId}"]`);
b69ab31602 const [a1, a2, b1, b2] = [props.a1, props.a2, props.b1, props.b2].map(select);
b69ab31603 const aEither = a1 ?? a2;
b69ab31604 const bEither = b1 ?? b2;
b69ab31605 if (aEither == null || bEither == null) {
b69ab31606 return;
b69ab31607 }
b69ab31608
b69ab31609 // Find containers.
b69ab31610 const findContainer = (span: E, className: string): E | null => {
b69ab31611 for (let e: E | null = span; e != null; e = e.parentElement) {
b69ab31612 if (e.classList.contains(className)) {
b69ab31613 return e;
b69ab31614 }
b69ab31615 }
b69ab31616 return null;
b69ab31617 };
b69ab31618 const [outerA, outerB] = [aEither, bEither].map(e =>
b69ab31619 findContainer(e, props.outerContainerClass),
b69ab31620 );
b69ab31621 const [innerA, innerB] = [aEither, bEither].map(e =>
b69ab31622 findContainer(e, props.innerContainerClass),
b69ab31623 );
b69ab31624 if (outerA == null || outerB == null || innerA == null || innerB == null) {
b69ab31625 return;
b69ab31626 }
b69ab31627
b69ab31628 // Recalculate positions. a2Rect and b2Rect are nullable.
b69ab31629 let newPos: RibbonPos | null = null;
b69ab31630 const [outerARect, outerBRect] = [outerA, outerB].map(e => e.getBoundingClientRect());
b69ab31631 const [a1Rect, a2Rect, b1Rect, b2Rect] = [a1, a2, b1, b2].map(
b69ab31632 e => e && e.getBoundingClientRect(),
b69ab31633 );
b69ab31634 const aTop = a1Rect?.top ?? a2Rect?.bottom;
b69ab31635 const bTop = b1Rect?.top ?? b2Rect?.bottom;
b69ab31636 const aBottom = a2Rect?.bottom ?? aTop;
b69ab31637 const bBottom = b2Rect?.bottom ?? bTop;
b69ab31638 const aRight = a1Rect?.right ?? a2Rect?.right;
b69ab31639 const bLeft = b1Rect?.left ?? b2Rect?.left;
b69ab31640
b69ab31641 if (
b69ab31642 aTop != null &&
b69ab31643 bTop != null &&
b69ab31644 aBottom != null &&
b69ab31645 bBottom != null &&
b69ab31646 aRight != null &&
b69ab31647 bLeft != null
b69ab31648 ) {
b69ab31649 const top = Math.min(aTop, bTop) - outerBRect.top;
b69ab31650 const width = bLeft - aRight;
b69ab31651 const ay1 = Math.max(aTop - bTop, 0);
b69ab31652 const by1 = Math.max(bTop - aTop, 0);
b69ab31653 const height = Math.max(aBottom, bBottom) - Math.min(aTop, bTop);
b69ab31654 const ay2 = ay1 + aBottom - aTop;
b69ab31655 const by2 = by1 + bBottom - bTop;
b69ab31656 const mid = width / 2;
b69ab31657
b69ab31658 // Discard overflow position.
b69ab31659 if (
b69ab31660 top >= 0 &&
b69ab31661 top + Math.max(ay2, by2) <= Math.max(outerARect.height, outerBRect.height)
b69ab31662 ) {
b69ab31663 const path = [
b69ab31664 `M 0 ${ay1}`,
b69ab31665 `C ${mid} ${ay1}, ${mid} ${by1}, ${width} ${by1}`,
b69ab31666 `L ${width} ${by2}`,
b69ab31667 `C ${mid} ${by2}, ${mid} ${ay2}, 0 ${ay2}`,
b69ab31668 `L 0 ${ay1}`,
b69ab31669 ].join(' ');
b69ab31670 newPos = {
b69ab31671 top,
b69ab31672 width,
b69ab31673 height,
b69ab31674 path,
b69ab31675 };
b69ab31676 }
b69ab31677 }
b69ab31678
b69ab31679 setPos(pos => (deepEqual(pos, newPos) ? pos : newPos));
b69ab31680
b69ab31681 return {
b69ab31682 scroll: [outerA, outerB],
b69ab31683 resize: [innerA, innerB],
b69ab31684 };
b69ab31685 };
b69ab31686
b69ab31687 // Calculate position now.
b69ab31688 const containers = repositionAndGetContainers();
b69ab31689
b69ab31690 if (containers == null) {
b69ab31691 return;
b69ab31692 }
b69ab31693
b69ab31694 // Observe resize and scrolling changes of the container.
b69ab31695 const observer = new ResizeObserver(() => repositionAndGetContainers());
b69ab31696 const handleScroll = () => {
b69ab31697 repositionAndGetContainers();
b69ab31698 };
b69ab31699 containers.resize.forEach(c => observer.observe(c));
b69ab31700 containers.scroll.forEach(c => c.addEventListener('scroll', handleScroll));
b69ab31701
b69ab31702 return () => {
b69ab31703 observer.disconnect();
b69ab31704 containers.scroll.forEach(c => c.removeEventListener('scroll', handleScroll));
b69ab31705 };
b69ab31706 }, [
b69ab31707 props.a1,
b69ab31708 props.a2,
b69ab31709 props.b1,
b69ab31710 props.b2,
b69ab31711 props.outerContainerClass,
b69ab31712 props.innerContainerClass,
b69ab31713 props.className,
b69ab31714 ]);
b69ab31715
b69ab31716 if (pos == null) {
b69ab31717 return null;
b69ab31718 }
b69ab31719
b69ab31720 const style: React.CSSProperties = {
b69ab31721 top: pos.top,
b69ab31722 left: 1 - pos.width,
b69ab31723 width: pos.width,
b69ab31724 height: pos.height,
b69ab31725 };
b69ab31726
b69ab31727 return (
b69ab31728 <svg className={`ribbon ${props.className}`} style={style}>
b69ab31729 <path d={pos.path} />
b69ab31730 </svg>
b69ab31731 );
b69ab31732}