20.7 KB658 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 {Map as ImMap} from 'immutable';
9import type {ReactNode} from 'react';
10import type {Comparison} from 'shared/Comparison';
11import type {ContextMenuItem} from 'shared/ContextMenu';
12import type {ParsedDiff} from 'shared/patch/types';
13import type {Context} from '../../ComparisonView/SplitDiffView/types';
14import type {DragHandler} from '../../DragHandle';
15import type {RenderGlyphResult} from '../../RenderDag';
16import type {Dag} from '../../dag/dag';
17import type {DagCommitInfo} from '../../dag/dagCommitInfo';
18import type {HashSet} from '../../dag/set';
19import type {AbsorbEdit, AbsorbEditId} from '../absorb';
20import type {CommitRev, CommitStackState, FileRev, FileStackIndex} from '../commitStackState';
21
22import * as stylex from '@stylexjs/stylex';
23import {Banner, BannerKind} from 'isl-components/Banner';
24import {Button} from 'isl-components/Button';
25import {Column, Row} from 'isl-components/Flex';
26import {Icon} from 'isl-components/Icon';
27import {Tooltip} from 'isl-components/Tooltip';
28import {stylexPropsWithClassName} from 'isl-components/utils';
29import {atom, useAtomValue} from 'jotai';
30import React, {useEffect, useMemo, useRef} from 'react';
31import {ComparisonType} from 'shared/Comparison';
32import {useContextMenu} from 'shared/ContextMenu';
33import {firstLine, nullthrows} from 'shared/utils';
34import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader';
35import {SplitDiffTable} from '../../ComparisonView/SplitDiffView/SplitDiffHunk';
36import {ScrollY} from '../../ComponentUtils';
37import {DragHandle} from '../../DragHandle';
38import {DraggingOverlay} from '../../DraggingOverlay';
39import {defaultRenderGlyph, RenderDag} from '../../RenderDag';
40import {YOU_ARE_HERE_VIRTUAL_COMMIT} from '../../dag/virtualCommit';
41import {t, T} from '../../i18n';
42import {readAtom, writeAtom} from '../../jotaiUtils';
43import {themeState} from '../../theme';
44import {prev} from '../revMath';
45import {calculateDagFromStack} from '../stackDag';
46import {stackEditStack, useStackEditState} from './stackEditState';
47
48const styles = stylex.create({
49 container: {
50 padding: 'var(--pad)',
51 },
52 absorbEditSingleChunk: {
53 border: '1px solid var(--tooltip-border)',
54 // The negative margins match <FileHeader />.
55 marginLeft: -1,
56 marginRight: -1,
57 marginBottom: -1,
58 display: 'flex',
59 borderTopWidth: 0,
60 ':not(#__unused__):hover .send-to-commit': {
61 visibility: 'visible',
62 },
63 ':not(#__unused__):focus-within .send-to-commit': {
64 visibility: 'visible',
65 },
66 },
67 inDraggingOverlay: {
68 border: 'none',
69 },
70 beingDragged: {
71 opacity: 0.5,
72 },
73 dragHandlerWrapper: {
74 width: 'fit-content',
75 display: 'flex',
76 alignItems: 'center',
77 backgroundColor: {
78 ':hover': 'var(--tooltip-background)',
79 },
80 position: 'relative',
81 },
82 dragHandle: {
83 padding: '0 var(--pad)',
84 alignItems: 'center',
85 height: '100%',
86 userSelect: 'none',
87 cursor: 'grab',
88 },
89 candidateDropTarget: {
90 backgroundColor: 'var(--tooltip-background)',
91 },
92 sendToCommitButton: {
93 position: 'absolute',
94 left: '100%',
95 zIndex: 100,
96 visibility: 'hidden',
97 borderRadius: '5px',
98 marginInline: 'var(--pad)',
99 ':not(#__unused__) .tooltip-creator': {
100 backgroundColor: 'var(--background)',
101 borderRadius: '5px',
102 },
103 },
104 absorbEditCode: {
105 borderCollapse: 'collapse',
106 wordBreak: 'break-all',
107 whiteSpace: 'pre-wrap',
108 // Fill the width when there are long lines in another diff chunk.
109 flexGrow: 1,
110 },
111 absorbEditPathTitle: {
112 padding: 'var(--halfpad) var(--pad)',
113 },
114 addLine: {
115 backgroundColor: 'var(--diffEditor-insertedLineBackground)',
116 },
117 delLine: {
118 backgroundColor: 'var(--diffEditor-removedLineBackground)',
119 },
120 lineContentCell: {
121 minWidth: 300,
122 },
123 commitTitle: {
124 padding: 'var(--halfpad) var(--pad)',
125 transition: 'opacity 0.1s ease-out',
126 },
127 deemphasizeCommitTitle: {
128 opacity: 0.5,
129 },
130 inlineIcon: {
131 verticalAlign: 'top',
132 height: 12,
133 },
134 scrollYPadding: {
135 paddingRight: 'var(--pad)',
136 },
137 commitExtras: {
138 paddingLeft: 'var(--pad)',
139 marginBottom: 'var(--pad)',
140 },
141 instruction: {
142 width: '100%',
143 },
144 uncommittedChanges: {
145 opacity: 0.9,
146 fontVariant: 'all-small-caps',
147 fontSize: '90%',
148 fontWeight: 'bold',
149 marginBottom: 'var(--halfpad)',
150 },
151 fileHint: {
152 padding: 'var(--pad)',
153 outline: '1px solid var(--panel-view-border)',
154 background: 'var(--hint-background)',
155 display: 'flex',
156 gap: 'var(--halfpad)',
157 },
158 unmoveable: {
159 cursor: 'not-allowed',
160 },
161});
162
163/** The `AbsorbEdit` that is currently being dragged. */
164const draggingAbsorbEdit = atom<AbsorbEdit | null>(null);
165const draggingHint = atom<string | null>(null);
166const onDragRef: {current: null | DragHandler} = {current: null};
167
168export function AbsorbStackEditPanel() {
169 useResetCollapsedFilesOnMount();
170 const stackEdit = useStackEditState();
171 const stack = stackEdit.commitStack;
172 const dag = calculateDagFromStack(stack);
173 const subset = relevantSubset(stack, dag);
174
175 return (
176 <>
177 <Column xstyle={styles.container}>
178 <AbsorbInstruction dag={dag} subset={subset} />
179 <ScrollY maxSize="calc(100vh - 200px)" {...stylex.props(styles.scrollYPadding)}>
180 <RenderDag
181 className="absorb-dag"
182 dag={dag}
183 renderCommit={renderCommit}
184 renderCommitExtras={renderCommitExtras}
185 renderGlyph={RenderGlyph}
186 subset={subset}
187 style={{
188 /* make it "containing block" so findDragDestinationCommitKey works */
189 position: 'relative',
190 }}
191 />
192 </ScrollY>
193 </Column>
194 <AbsorbDraggingOverlay />
195 </>
196 );
197}
198
199function AbsorbInstruction(props: {subset: HashSet; dag: Dag}) {
200 const {dag, subset} = props;
201 const hasOmittedCommits = subset.size < dag.all().size;
202 const hasDndDestinations = subset.intersect(dag.draft()).size > 1;
203 let bannerKind = BannerKind.default;
204 const tips: ReactNode[] = [];
205 if (hasDndDestinations) {
206 tips.push(
207 <T>Changes have been automatically distributed through your stack.</T>,
208 <T
209 replace={{$grabber: <Icon icon="grabber" size="S" {...stylex.props(styles.inlineIcon)} />}}>
210 Drag $grabber to move changes to different commits
211 </T>,
212 );
213 if (hasOmittedCommits) {
214 tips.push(<T>Only commits that modify related files/areas are shown.</T>);
215 }
216 } else {
217 bannerKind = BannerKind.warning;
218 tips.push(<T>Nothing to absorb. The commit stack did not modify relevant files.</T>);
219 }
220
221 return (
222 <Row xstyle={styles.instruction}>
223 <Banner xstyle={styles.instruction} kind={bannerKind}>
224 <Icon icon="info" />
225 <div>
226 {tips.map((tip, idx) => (
227 <React.Fragment key={idx}>
228 {idx > 0 && <br />}
229 {tip}
230 </React.Fragment>
231 ))}
232 </div>
233 </Banner>
234 </Row>
235 );
236}
237
238const candidateDropTargetRevs = atom<readonly CommitRev[] | undefined>(get => {
239 const edit = get(draggingAbsorbEdit);
240 const stack = get(stackEditStack);
241 if (edit == null || stack == null) {
242 return undefined;
243 }
244 return stack.getAbsorbCommitRevs(nullthrows(edit.fileStackIndex), edit.absorbEditId)
245 .candidateRevs;
246});
247
248function RenderGlyph(info: DagCommitInfo): RenderGlyphResult {
249 const revs = useAtomValue(candidateDropTargetRevs);
250 const rev = info.stackRev;
251 const [kind, inner] = defaultRenderGlyph(info);
252 let newInner = inner;
253 if (kind === 'inside-tile' && rev != null && revs?.includes(rev)) {
254 // This is a candidate drop target. Wrap in a SVG circle.
255 const circle = (
256 <circle cx={0} cy={0} r={8} fill="transparent" stroke="var(--focus-border)" strokeWidth={4} />
257 );
258 newInner = (
259 <>
260 {circle}
261 {inner}
262 </>
263 );
264 }
265 return [kind, newInner];
266}
267
268function AbsorbDraggingOverlay() {
269 const absorbEdit = useAtomValue(draggingAbsorbEdit);
270 const hint = useAtomValue(draggingHint);
271 const stack = useAtomValue(stackEditStack);
272
273 const fileStackIndex = absorbEdit?.fileStackIndex;
274 // Path extension is used by the syntax highlighter
275 let path = '';
276 if (stack && fileStackIndex) {
277 const fileRev = absorbEdit.selectedRev ?? (0 as FileRev);
278 path = nullthrows(stack.getFileStackPath(fileStackIndex, fileRev));
279 }
280
281 return (
282 <DraggingOverlay onDragRef={onDragRef} hint={hint}>
283 {absorbEdit && <SingleAbsorbEdit path={path} edit={absorbEdit} inDraggingOverlay={true} />}
284 </DraggingOverlay>
285 );
286}
287
288/**
289 * Subset of `dag` to render in an absorb UI. It skips draft commits that are
290 * not absorb destinations. For example, A01..A50, a 50-commit stack, absorbing
291 * `x.txt` change. There are only 3 commits that touch `x.txt`, so the absorb
292 * destination only includes those 3 commits.
293 */
294function relevantSubset(stack: CommitStackState, dag: Dag) {
295 const revs = stack.getAllAbsorbCandidateCommitRevs();
296 const keys = [...revs].map(rev => nullthrows(stack.get(rev)?.key));
297 // Also include the (base) public commit and the `wdir()` virtual commit.
298 keys.push(YOU_ARE_HERE_VIRTUAL_COMMIT.hash);
299 return dag.present(keys).union(dag.public_());
300}
301
302// NOTE: To avoid re-render, the "renderCommit" and "renderCommitExtras" functions
303// need to be "static" instead of anonymous functions.
304// Note this is a regular function. To use React hooks, return a React component.
305function renderCommit(info: DagCommitInfo) {
306 return <RenderCommit info={info} />;
307}
308
309function RenderCommit(props: {info: DagCommitInfo}) {
310 const {info} = props;
311 const revs = useAtomValue(candidateDropTargetRevs);
312 const rev = info.stackRev;
313 const fadeout = revs != null && rev != null && revs.includes(rev) === false;
314
315 if (info.phase === 'public') {
316 return <div />;
317 }
318 // Just show the commit title for now.
319 return (
320 <div {...stylex.props(styles.commitTitle, fadeout && styles.deemphasizeCommitTitle)}>
321 {info.title}
322 </div>
323 );
324}
325
326function renderCommitExtras(info: DagCommitInfo) {
327 return <AbsorbDagCommitExtras info={info} />;
328}
329
330/**
331 * Scan the absorb dag DOM and extract [data-reorder-id], or the commit key,
332 * from the dragging destination.
333 */
334function findDragDestinationCommitKey(y: number): string | undefined {
335 const container = document.querySelector('.absorb-dag');
336 if (container == null) {
337 return undefined;
338 }
339 const containerY = container.getBoundingClientRect().y;
340 const relativeY = y - containerY;
341 let bestKey: string | undefined = undefined;
342 let bestDelta: number = Infinity;
343 for (const element of container.querySelectorAll('.render-dag-row-group')) {
344 const divElement = element as HTMLDivElement;
345 // use offSetTop instead of getBoundingClientRect() to avoid
346 // being affected by ongoing animation.
347 const y1 = divElement.offsetTop;
348 const y2 = y1 + divElement.offsetHeight;
349 const commitKey = divElement.getAttribute('data-reorder-id');
350 const delta = Math.abs(relativeY - (y1 + y2) / 2);
351 if (relativeY >= y1 && commitKey != null && delta < bestDelta) {
352 bestKey = commitKey;
353 bestDelta = delta;
354 }
355 }
356 return bestKey;
357}
358
359/** Similar to `findDragDestinationCommitKey` but reports the rev. */
360function findDragDestinationCommitRev(y: number, stack: CommitStackState): CommitRev | undefined {
361 const key = findDragDestinationCommitKey(y);
362 if (key == null) {
363 return undefined;
364 }
365 // Convert key to rev.
366 return stack.findRev(commit => commit.key === key);
367}
368
369/** Show file paths and diff chunks. */
370function AbsorbDagCommitExtras(props: {info: DagCommitInfo}) {
371 const {info} = props;
372 const stackEdit = useStackEditState();
373 const stack = stackEdit.commitStack;
374 const rev = info.stackRev;
375 if (rev == null) {
376 return null;
377 }
378
379 const fileIdxToEdits = stack.absorbExtraByCommitRev(rev);
380 if (fileIdxToEdits.isEmpty()) {
381 return null;
382 }
383
384 const isWdir = info.hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash;
385
386 return (
387 <div {...stylex.props(styles.commitExtras)}>
388 {isWdir && (
389 <div {...stylex.props(styles.uncommittedChanges)}>
390 <T>Uncommitted Changes</T>
391 </div>
392 )}
393 {fileIdxToEdits
394 .map((edits, fileIdx) => (
395 <AbsorbEditsForFile
396 isWdir={isWdir}
397 fileStackIndex={fileIdx}
398 absorbEdits={edits}
399 key={fileIdx}
400 />
401 ))
402 .valueSeq()}
403 </div>
404 );
405}
406
407const absorbCollapsedFiles = atom<Map<string, boolean>>(new Map());
408
409function useCollapsedFile(
410 path: string | undefined,
411 fileHasAnyDestinations: boolean,
412): [boolean, (value: boolean) => void] | [undefined, undefined] {
413 const collapsedFiles = useAtomValue(absorbCollapsedFiles);
414 if (path == null) {
415 return [undefined, undefined];
416 }
417 const isCollapsed = collapsedFiles.get(path) ?? (fileHasAnyDestinations ? false : true);
418 const setCollapsed = (collapsed: boolean) => {
419 const newMap = new Map(collapsedFiles);
420 newMap.set(path, collapsed);
421 writeAtom(absorbCollapsedFiles, newMap);
422 };
423 return [isCollapsed, setCollapsed];
424}
425function useResetCollapsedFilesOnMount() {
426 useEffect(() => {
427 writeAtom(absorbCollapsedFiles, new Map());
428 }, []);
429}
430
431function AbsorbEditsForFile(props: {
432 isWdir: boolean;
433 fileStackIndex: FileStackIndex;
434 absorbEdits: ImMap<AbsorbEditId, AbsorbEdit>;
435}) {
436 const {fileStackIndex, absorbEdits} = props;
437 const stack = nullthrows(useAtomValue(stackEditStack));
438 const fileStack = nullthrows(stack.fileStacks.get(fileStackIndex));
439 // In case the file is renamed, show "path1 -> path2" where path1 is the file
440 // name in the commit, and path2 is the file name in the working copy.
441 // Note: the line numbers we show are based on the working copy, not the commit.
442 // So it seems showing the file name in the working copy is relevant.
443 const fileRev = absorbEdits.first()?.selectedRev ?? (0 as FileRev);
444 const pathInCommit = stack.getFileStackPath(fileStackIndex, fileRev);
445 const wdirRev = prev(fileStack.revLength);
446 const pathInWorkingCopy = stack.getFileStackPath(fileStackIndex, wdirRev);
447 const path = pathInWorkingCopy ?? pathInCommit;
448
449 const fileHasAnyDestinations = !props.isWdir
450 ? true
451 : absorbEdits.some(edit => {
452 const absorbEditId = edit.absorbEditId;
453 const dests = stack?.getAbsorbCommitRevs(fileStackIndex, absorbEditId);
454 return dests != null && dests.candidateRevs.length > 1;
455 });
456
457 const [isCollapsed, setCollapsed] = useCollapsedFile(path, fileHasAnyDestinations);
458
459 return (
460 <div>
461 {path && (
462 <FileHeader
463 {...(isCollapsed == null
464 ? {open: undefined, onChangeOpen: undefined}
465 : {
466 open: isCollapsed === false,
467 onChangeOpen: open => setCollapsed(!open),
468 })}
469 copyFrom={pathInCommit}
470 path={path}
471 iconType={IconType.Modified}
472 />
473 )}
474 {fileHasAnyDestinations === false && !isCollapsed ? (
475 <div {...stylex.props(styles.fileHint)}>
476 <Icon icon="warning" />
477 <T>This file was not changed in this stack and can't be absorbed</T>
478 </div>
479 ) : null}
480 {
481 // Edits are rendered even when collapsed, so the reordering id animation doesn't trigger when collapsing.
482 props.absorbEdits
483 .map((edit, i) => (
484 <SingleAbsorbEdit
485 collapsed={isCollapsed}
486 path={path}
487 edit={edit}
488 key={i}
489 unmovable={fileHasAnyDestinations === false}
490 />
491 ))
492 .valueSeq()
493 }
494 </div>
495 );
496}
497
498function SingleAbsorbEdit(props: {
499 collapsed?: boolean;
500 edit: AbsorbEdit;
501 inDraggingOverlay?: boolean;
502 path?: string;
503 unmovable?: boolean;
504}) {
505 const {edit, inDraggingOverlay, path, unmovable} = props;
506 const isDragging = useAtomValue(draggingAbsorbEdit);
507 const stackEdit = useStackEditState();
508 const reorderId = `absorb-${edit.fileStackIndex}-${edit.absorbEditId}`;
509 const ref = useRef<HTMLDivElement | null>(null);
510
511 const handleDrag = (x: number, y: number, isDragging: boolean) => {
512 // Visual update.
513 onDragRef.current?.(x, y, isDragging);
514 // State update.
515 let newDraggingHint: string | null = null;
516 if (isDragging) {
517 // The 'stack' in the closure might be outdated. Read the latest.
518 const stack = readAtom(stackEditStack);
519 if (stack == null) {
520 return;
521 }
522 const rev = findDragDestinationCommitRev(y, stack);
523 const fileStackIndex = nullthrows(edit.fileStackIndex);
524 const absorbEditId = edit.absorbEditId;
525 if (
526 rev != null &&
527 rev !== stack?.getAbsorbCommitRevs(fileStackIndex, absorbEditId).selectedRev
528 ) {
529 const commit = nullthrows(stack.get(rev));
530 let newStack = stack;
531 try {
532 newStack = stack.setAbsorbEditDestination(fileStackIndex, absorbEditId, rev);
533 // `handleDrag` won't be updated with "refreshed" `stackEdit`.
534 // So `push` can work like `replaceTopOperation` while dragging.
535 stackEdit.push(newStack, {name: 'absorbMove', commit});
536 } catch {
537 // This should be unreachable.
538 newDraggingHint = t(
539 'Diff chunk can only be applied to a commit that modifies the file and has matching context lines.',
540 );
541 }
542 }
543 }
544 // Ensure the hint is cleared when:
545 // 1) not dragging. (important because the hint div interferes user interaction even if it's invisible)
546 // 2) dragging back from an invalid rev to the current (valid) rev.
547 writeAtom(draggingHint, newDraggingHint);
548 writeAtom(draggingAbsorbEdit, isDragging ? edit : null);
549 };
550
551 const useThemeHook = () => useAtomValue(themeState);
552 const ctx: Context = {
553 id: {comparison: {type: ComparisonType.UncommittedChanges} as Comparison, path: path ?? ''},
554 collapsed: false,
555 setCollapsed: () => null,
556 useThemeHook,
557 t,
558 display: 'unified' as const,
559 };
560
561 const patch = useMemo(() => {
562 const lines = [
563 ...edit.oldLines.toArray().map(l => `-${l}`),
564 ...edit.newLines.toArray().map(l => `+${l}`),
565 ];
566 return {
567 oldFileName: path,
568 newFileName: path,
569 hunks: [
570 {
571 oldStart: edit.oldStart,
572 oldLines: edit.oldEnd - edit.oldStart,
573 newStart: edit.newStart,
574 newLines: edit.newEnd - edit.newStart,
575 lines,
576 linedelimiters: new Array(lines.length).fill('\n'),
577 },
578 ],
579 } as ParsedDiff;
580 }, [edit, path]);
581
582 return (
583 <div
584 ref={ref}
585 {...stylex.props(
586 styles.absorbEditSingleChunk,
587 inDraggingOverlay && styles.inDraggingOverlay,
588 !inDraggingOverlay && isDragging === edit && styles.beingDragged,
589 )}
590 data-reorder-id={reorderId}>
591 {props.collapsed ? null : (
592 <>
593 <div {...stylex.props(styles.dragHandlerWrapper)}>
594 <DragHandle
595 onDrag={unmovable ? undefined : handleDrag}
596 xstyle={[styles.dragHandle, unmovable ? styles.unmoveable : undefined]}>
597 <Icon icon="grabber" />
598 </DragHandle>
599 {!inDraggingOverlay && !unmovable && <SendToCommitButton edit={edit} />}
600 </div>
601 <SplitDiffTable ctx={ctx} path={path ?? ''} patch={patch} />
602 </>
603 )}
604 </div>
605 );
606}
607
608function SendToCommitButton({edit}: {edit: AbsorbEdit}) {
609 const stackEdit = useStackEditState();
610 const menu = useContextMenu(() => {
611 const stack = readAtom(stackEditStack);
612
613 const {fileStackIndex, absorbEditId} = edit;
614 if (stack == null || fileStackIndex == null || absorbEditId == null) {
615 return [];
616 }
617
618 const items: Array<ContextMenuItem> = [];
619
620 const absorbRevs = stack.getAbsorbCommitRevs(fileStackIndex, absorbEditId);
621 for (const rev of absorbRevs.candidateRevs.toReversed()) {
622 const info = nullthrows(stack.get(rev));
623
624 if (
625 rev === absorbRevs.selectedRev ||
626 (absorbRevs.selectedRev == null && info.key === YOU_ARE_HERE_VIRTUAL_COMMIT.hash)
627 ) {
628 // skip rev this edit is already in
629 continue;
630 }
631
632 items.push({
633 label: (
634 <div>
635 {info.key === YOU_ARE_HERE_VIRTUAL_COMMIT.hash
636 ? 'Uncommitted Changes'
637 : firstLine(info.text)}
638 </div>
639 ),
640 onClick: () => {
641 const newStack = stack.setAbsorbEditDestination(fileStackIndex, absorbEditId, rev);
642 stackEdit.push(newStack, {name: 'absorbMove', commit: info});
643 },
644 });
645 }
646 return items;
647 });
648 return (
649 <div {...stylexPropsWithClassName(styles.sendToCommitButton, 'send-to-commit')}>
650 <Tooltip title={t('Move to a specific commit')}>
651 <Button icon onClick={menu}>
652 <Icon icon="insert" />
653 </Button>
654 </Tooltip>
655 </div>
656 );
657}
658