addons/isl/src/stackEdit/ui/AbsorbStackEditPanel.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 {Map as ImMap} from 'immutable';
b69ab319import type {ReactNode} from 'react';
b69ab3110import type {Comparison} from 'shared/Comparison';
b69ab3111import type {ContextMenuItem} from 'shared/ContextMenu';
b69ab3112import type {ParsedDiff} from 'shared/patch/types';
b69ab3113import type {Context} from '../../ComparisonView/SplitDiffView/types';
b69ab3114import type {DragHandler} from '../../DragHandle';
b69ab3115import type {RenderGlyphResult} from '../../RenderDag';
b69ab3116import type {Dag} from '../../dag/dag';
b69ab3117import type {DagCommitInfo} from '../../dag/dagCommitInfo';
b69ab3118import type {HashSet} from '../../dag/set';
b69ab3119import type {AbsorbEdit, AbsorbEditId} from '../absorb';
b69ab3120import type {CommitRev, CommitStackState, FileRev, FileStackIndex} from '../commitStackState';
b69ab3121
b69ab3122import * as stylex from '@stylexjs/stylex';
b69ab3123import {Banner, BannerKind} from 'isl-components/Banner';
b69ab3124import {Button} from 'isl-components/Button';
b69ab3125import {Column, Row} from 'isl-components/Flex';
b69ab3126import {Icon} from 'isl-components/Icon';
b69ab3127import {Tooltip} from 'isl-components/Tooltip';
b69ab3128import {stylexPropsWithClassName} from 'isl-components/utils';
b69ab3129import {atom, useAtomValue} from 'jotai';
b69ab3130import React, {useEffect, useMemo, useRef} from 'react';
b69ab3131import {ComparisonType} from 'shared/Comparison';
b69ab3132import {useContextMenu} from 'shared/ContextMenu';
b69ab3133import {firstLine, nullthrows} from 'shared/utils';
b69ab3134import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader';
b69ab3135import {SplitDiffTable} from '../../ComparisonView/SplitDiffView/SplitDiffHunk';
b69ab3136import {ScrollY} from '../../ComponentUtils';
b69ab3137import {DragHandle} from '../../DragHandle';
b69ab3138import {DraggingOverlay} from '../../DraggingOverlay';
b69ab3139import {defaultRenderGlyph, RenderDag} from '../../RenderDag';
b69ab3140import {YOU_ARE_HERE_VIRTUAL_COMMIT} from '../../dag/virtualCommit';
b69ab3141import {t, T} from '../../i18n';
b69ab3142import {readAtom, writeAtom} from '../../jotaiUtils';
b69ab3143import {themeState} from '../../theme';
b69ab3144import {prev} from '../revMath';
b69ab3145import {calculateDagFromStack} from '../stackDag';
b69ab3146import {stackEditStack, useStackEditState} from './stackEditState';
b69ab3147
b69ab3148const styles = stylex.create({
b69ab3149 container: {
b69ab3150 padding: 'var(--pad)',
b69ab3151 },
b69ab3152 absorbEditSingleChunk: {
b69ab3153 border: '1px solid var(--tooltip-border)',
b69ab3154 // The negative margins match <FileHeader />.
b69ab3155 marginLeft: -1,
b69ab3156 marginRight: -1,
b69ab3157 marginBottom: -1,
b69ab3158 display: 'flex',
b69ab3159 borderTopWidth: 0,
b69ab3160 ':not(#__unused__):hover .send-to-commit': {
b69ab3161 visibility: 'visible',
b69ab3162 },
b69ab3163 ':not(#__unused__):focus-within .send-to-commit': {
b69ab3164 visibility: 'visible',
b69ab3165 },
b69ab3166 },
b69ab3167 inDraggingOverlay: {
b69ab3168 border: 'none',
b69ab3169 },
b69ab3170 beingDragged: {
b69ab3171 opacity: 0.5,
b69ab3172 },
b69ab3173 dragHandlerWrapper: {
b69ab3174 width: 'fit-content',
b69ab3175 display: 'flex',
b69ab3176 alignItems: 'center',
b69ab3177 backgroundColor: {
b69ab3178 ':hover': 'var(--tooltip-background)',
b69ab3179 },
b69ab3180 position: 'relative',
b69ab3181 },
b69ab3182 dragHandle: {
b69ab3183 padding: '0 var(--pad)',
b69ab3184 alignItems: 'center',
b69ab3185 height: '100%',
b69ab3186 userSelect: 'none',
b69ab3187 cursor: 'grab',
b69ab3188 },
b69ab3189 candidateDropTarget: {
b69ab3190 backgroundColor: 'var(--tooltip-background)',
b69ab3191 },
b69ab3192 sendToCommitButton: {
b69ab3193 position: 'absolute',
b69ab3194 left: '100%',
b69ab3195 zIndex: 100,
b69ab3196 visibility: 'hidden',
b69ab3197 borderRadius: '5px',
b69ab3198 marginInline: 'var(--pad)',
b69ab3199 ':not(#__unused__) .tooltip-creator': {
b69ab31100 backgroundColor: 'var(--background)',
b69ab31101 borderRadius: '5px',
b69ab31102 },
b69ab31103 },
b69ab31104 absorbEditCode: {
b69ab31105 borderCollapse: 'collapse',
b69ab31106 wordBreak: 'break-all',
b69ab31107 whiteSpace: 'pre-wrap',
b69ab31108 // Fill the width when there are long lines in another diff chunk.
b69ab31109 flexGrow: 1,
b69ab31110 },
b69ab31111 absorbEditPathTitle: {
b69ab31112 padding: 'var(--halfpad) var(--pad)',
b69ab31113 },
b69ab31114 addLine: {
b69ab31115 backgroundColor: 'var(--diffEditor-insertedLineBackground)',
b69ab31116 },
b69ab31117 delLine: {
b69ab31118 backgroundColor: 'var(--diffEditor-removedLineBackground)',
b69ab31119 },
b69ab31120 lineContentCell: {
b69ab31121 minWidth: 300,
b69ab31122 },
b69ab31123 commitTitle: {
b69ab31124 padding: 'var(--halfpad) var(--pad)',
b69ab31125 transition: 'opacity 0.1s ease-out',
b69ab31126 },
b69ab31127 deemphasizeCommitTitle: {
b69ab31128 opacity: 0.5,
b69ab31129 },
b69ab31130 inlineIcon: {
b69ab31131 verticalAlign: 'top',
b69ab31132 height: 12,
b69ab31133 },
b69ab31134 scrollYPadding: {
b69ab31135 paddingRight: 'var(--pad)',
b69ab31136 },
b69ab31137 commitExtras: {
b69ab31138 paddingLeft: 'var(--pad)',
b69ab31139 marginBottom: 'var(--pad)',
b69ab31140 },
b69ab31141 instruction: {
b69ab31142 width: '100%',
b69ab31143 },
b69ab31144 uncommittedChanges: {
b69ab31145 opacity: 0.9,
b69ab31146 fontVariant: 'all-small-caps',
b69ab31147 fontSize: '90%',
b69ab31148 fontWeight: 'bold',
b69ab31149 marginBottom: 'var(--halfpad)',
b69ab31150 },
b69ab31151 fileHint: {
b69ab31152 padding: 'var(--pad)',
b69ab31153 outline: '1px solid var(--panel-view-border)',
b69ab31154 background: 'var(--hint-background)',
b69ab31155 display: 'flex',
b69ab31156 gap: 'var(--halfpad)',
b69ab31157 },
b69ab31158 unmoveable: {
b69ab31159 cursor: 'not-allowed',
b69ab31160 },
b69ab31161});
b69ab31162
b69ab31163/** The `AbsorbEdit` that is currently being dragged. */
b69ab31164const draggingAbsorbEdit = atom<AbsorbEdit | null>(null);
b69ab31165const draggingHint = atom<string | null>(null);
b69ab31166const onDragRef: {current: null | DragHandler} = {current: null};
b69ab31167
b69ab31168export function AbsorbStackEditPanel() {
b69ab31169 useResetCollapsedFilesOnMount();
b69ab31170 const stackEdit = useStackEditState();
b69ab31171 const stack = stackEdit.commitStack;
b69ab31172 const dag = calculateDagFromStack(stack);
b69ab31173 const subset = relevantSubset(stack, dag);
b69ab31174
b69ab31175 return (
b69ab31176 <>
b69ab31177 <Column xstyle={styles.container}>
b69ab31178 <AbsorbInstruction dag={dag} subset={subset} />
b69ab31179 <ScrollY maxSize="calc(100vh - 200px)" {...stylex.props(styles.scrollYPadding)}>
b69ab31180 <RenderDag
b69ab31181 className="absorb-dag"
b69ab31182 dag={dag}
b69ab31183 renderCommit={renderCommit}
b69ab31184 renderCommitExtras={renderCommitExtras}
b69ab31185 renderGlyph={RenderGlyph}
b69ab31186 subset={subset}
b69ab31187 style={{
b69ab31188 /* make it "containing block" so findDragDestinationCommitKey works */
b69ab31189 position: 'relative',
b69ab31190 }}
b69ab31191 />
b69ab31192 </ScrollY>
b69ab31193 </Column>
b69ab31194 <AbsorbDraggingOverlay />
b69ab31195 </>
b69ab31196 );
b69ab31197}
b69ab31198
b69ab31199function AbsorbInstruction(props: {subset: HashSet; dag: Dag}) {
b69ab31200 const {dag, subset} = props;
b69ab31201 const hasOmittedCommits = subset.size < dag.all().size;
b69ab31202 const hasDndDestinations = subset.intersect(dag.draft()).size > 1;
b69ab31203 let bannerKind = BannerKind.default;
b69ab31204 const tips: ReactNode[] = [];
b69ab31205 if (hasDndDestinations) {
b69ab31206 tips.push(
b69ab31207 <T>Changes have been automatically distributed through your stack.</T>,
b69ab31208 <T
b69ab31209 replace={{$grabber: <Icon icon="grabber" size="S" {...stylex.props(styles.inlineIcon)} />}}>
b69ab31210 Drag $grabber to move changes to different commits
b69ab31211 </T>,
b69ab31212 );
b69ab31213 if (hasOmittedCommits) {
b69ab31214 tips.push(<T>Only commits that modify related files/areas are shown.</T>);
b69ab31215 }
b69ab31216 } else {
b69ab31217 bannerKind = BannerKind.warning;
b69ab31218 tips.push(<T>Nothing to absorb. The commit stack did not modify relevant files.</T>);
b69ab31219 }
b69ab31220
b69ab31221 return (
b69ab31222 <Row xstyle={styles.instruction}>
b69ab31223 <Banner xstyle={styles.instruction} kind={bannerKind}>
b69ab31224 <Icon icon="info" />
b69ab31225 <div>
b69ab31226 {tips.map((tip, idx) => (
b69ab31227 <React.Fragment key={idx}>
b69ab31228 {idx > 0 && <br />}
b69ab31229 {tip}
b69ab31230 </React.Fragment>
b69ab31231 ))}
b69ab31232 </div>
b69ab31233 </Banner>
b69ab31234 </Row>
b69ab31235 );
b69ab31236}
b69ab31237
b69ab31238const candidateDropTargetRevs = atom<readonly CommitRev[] | undefined>(get => {
b69ab31239 const edit = get(draggingAbsorbEdit);
b69ab31240 const stack = get(stackEditStack);
b69ab31241 if (edit == null || stack == null) {
b69ab31242 return undefined;
b69ab31243 }
b69ab31244 return stack.getAbsorbCommitRevs(nullthrows(edit.fileStackIndex), edit.absorbEditId)
b69ab31245 .candidateRevs;
b69ab31246});
b69ab31247
b69ab31248function RenderGlyph(info: DagCommitInfo): RenderGlyphResult {
b69ab31249 const revs = useAtomValue(candidateDropTargetRevs);
b69ab31250 const rev = info.stackRev;
b69ab31251 const [kind, inner] = defaultRenderGlyph(info);
b69ab31252 let newInner = inner;
b69ab31253 if (kind === 'inside-tile' && rev != null && revs?.includes(rev)) {
b69ab31254 // This is a candidate drop target. Wrap in a SVG circle.
b69ab31255 const circle = (
b69ab31256 <circle cx={0} cy={0} r={8} fill="transparent" stroke="var(--focus-border)" strokeWidth={4} />
b69ab31257 );
b69ab31258 newInner = (
b69ab31259 <>
b69ab31260 {circle}
b69ab31261 {inner}
b69ab31262 </>
b69ab31263 );
b69ab31264 }
b69ab31265 return [kind, newInner];
b69ab31266}
b69ab31267
b69ab31268function AbsorbDraggingOverlay() {
b69ab31269 const absorbEdit = useAtomValue(draggingAbsorbEdit);
b69ab31270 const hint = useAtomValue(draggingHint);
b69ab31271 const stack = useAtomValue(stackEditStack);
b69ab31272
b69ab31273 const fileStackIndex = absorbEdit?.fileStackIndex;
b69ab31274 // Path extension is used by the syntax highlighter
b69ab31275 let path = '';
b69ab31276 if (stack && fileStackIndex) {
b69ab31277 const fileRev = absorbEdit.selectedRev ?? (0 as FileRev);
b69ab31278 path = nullthrows(stack.getFileStackPath(fileStackIndex, fileRev));
b69ab31279 }
b69ab31280
b69ab31281 return (
b69ab31282 <DraggingOverlay onDragRef={onDragRef} hint={hint}>
b69ab31283 {absorbEdit && <SingleAbsorbEdit path={path} edit={absorbEdit} inDraggingOverlay={true} />}
b69ab31284 </DraggingOverlay>
b69ab31285 );
b69ab31286}
b69ab31287
b69ab31288/**
b69ab31289 * Subset of `dag` to render in an absorb UI. It skips draft commits that are
b69ab31290 * not absorb destinations. For example, A01..A50, a 50-commit stack, absorbing
b69ab31291 * `x.txt` change. There are only 3 commits that touch `x.txt`, so the absorb
b69ab31292 * destination only includes those 3 commits.
b69ab31293 */
b69ab31294function relevantSubset(stack: CommitStackState, dag: Dag) {
b69ab31295 const revs = stack.getAllAbsorbCandidateCommitRevs();
b69ab31296 const keys = [...revs].map(rev => nullthrows(stack.get(rev)?.key));
b69ab31297 // Also include the (base) public commit and the `wdir()` virtual commit.
b69ab31298 keys.push(YOU_ARE_HERE_VIRTUAL_COMMIT.hash);
b69ab31299 return dag.present(keys).union(dag.public_());
b69ab31300}
b69ab31301
b69ab31302// NOTE: To avoid re-render, the "renderCommit" and "renderCommitExtras" functions
b69ab31303// need to be "static" instead of anonymous functions.
b69ab31304// Note this is a regular function. To use React hooks, return a React component.
b69ab31305function renderCommit(info: DagCommitInfo) {
b69ab31306 return <RenderCommit info={info} />;
b69ab31307}
b69ab31308
b69ab31309function RenderCommit(props: {info: DagCommitInfo}) {
b69ab31310 const {info} = props;
b69ab31311 const revs = useAtomValue(candidateDropTargetRevs);
b69ab31312 const rev = info.stackRev;
b69ab31313 const fadeout = revs != null && rev != null && revs.includes(rev) === false;
b69ab31314
b69ab31315 if (info.phase === 'public') {
b69ab31316 return <div />;
b69ab31317 }
b69ab31318 // Just show the commit title for now.
b69ab31319 return (
b69ab31320 <div {...stylex.props(styles.commitTitle, fadeout && styles.deemphasizeCommitTitle)}>
b69ab31321 {info.title}
b69ab31322 </div>
b69ab31323 );
b69ab31324}
b69ab31325
b69ab31326function renderCommitExtras(info: DagCommitInfo) {
b69ab31327 return <AbsorbDagCommitExtras info={info} />;
b69ab31328}
b69ab31329
b69ab31330/**
b69ab31331 * Scan the absorb dag DOM and extract [data-reorder-id], or the commit key,
b69ab31332 * from the dragging destination.
b69ab31333 */
b69ab31334function findDragDestinationCommitKey(y: number): string | undefined {
b69ab31335 const container = document.querySelector('.absorb-dag');
b69ab31336 if (container == null) {
b69ab31337 return undefined;
b69ab31338 }
b69ab31339 const containerY = container.getBoundingClientRect().y;
b69ab31340 const relativeY = y - containerY;
b69ab31341 let bestKey: string | undefined = undefined;
b69ab31342 let bestDelta: number = Infinity;
b69ab31343 for (const element of container.querySelectorAll('.render-dag-row-group')) {
b69ab31344 const divElement = element as HTMLDivElement;
b69ab31345 // use offSetTop instead of getBoundingClientRect() to avoid
b69ab31346 // being affected by ongoing animation.
b69ab31347 const y1 = divElement.offsetTop;
b69ab31348 const y2 = y1 + divElement.offsetHeight;
b69ab31349 const commitKey = divElement.getAttribute('data-reorder-id');
b69ab31350 const delta = Math.abs(relativeY - (y1 + y2) / 2);
b69ab31351 if (relativeY >= y1 && commitKey != null && delta < bestDelta) {
b69ab31352 bestKey = commitKey;
b69ab31353 bestDelta = delta;
b69ab31354 }
b69ab31355 }
b69ab31356 return bestKey;
b69ab31357}
b69ab31358
b69ab31359/** Similar to `findDragDestinationCommitKey` but reports the rev. */
b69ab31360function findDragDestinationCommitRev(y: number, stack: CommitStackState): CommitRev | undefined {
b69ab31361 const key = findDragDestinationCommitKey(y);
b69ab31362 if (key == null) {
b69ab31363 return undefined;
b69ab31364 }
b69ab31365 // Convert key to rev.
b69ab31366 return stack.findRev(commit => commit.key === key);
b69ab31367}
b69ab31368
b69ab31369/** Show file paths and diff chunks. */
b69ab31370function AbsorbDagCommitExtras(props: {info: DagCommitInfo}) {
b69ab31371 const {info} = props;
b69ab31372 const stackEdit = useStackEditState();
b69ab31373 const stack = stackEdit.commitStack;
b69ab31374 const rev = info.stackRev;
b69ab31375 if (rev == null) {
b69ab31376 return null;
b69ab31377 }
b69ab31378
b69ab31379 const fileIdxToEdits = stack.absorbExtraByCommitRev(rev);
b69ab31380 if (fileIdxToEdits.isEmpty()) {
b69ab31381 return null;
b69ab31382 }
b69ab31383
b69ab31384 const isWdir = info.hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash;
b69ab31385
b69ab31386 return (
b69ab31387 <div {...stylex.props(styles.commitExtras)}>
b69ab31388 {isWdir && (
b69ab31389 <div {...stylex.props(styles.uncommittedChanges)}>
b69ab31390 <T>Uncommitted Changes</T>
b69ab31391 </div>
b69ab31392 )}
b69ab31393 {fileIdxToEdits
b69ab31394 .map((edits, fileIdx) => (
b69ab31395 <AbsorbEditsForFile
b69ab31396 isWdir={isWdir}
b69ab31397 fileStackIndex={fileIdx}
b69ab31398 absorbEdits={edits}
b69ab31399 key={fileIdx}
b69ab31400 />
b69ab31401 ))
b69ab31402 .valueSeq()}
b69ab31403 </div>
b69ab31404 );
b69ab31405}
b69ab31406
b69ab31407const absorbCollapsedFiles = atom<Map<string, boolean>>(new Map());
b69ab31408
b69ab31409function useCollapsedFile(
b69ab31410 path: string | undefined,
b69ab31411 fileHasAnyDestinations: boolean,
b69ab31412): [boolean, (value: boolean) => void] | [undefined, undefined] {
b69ab31413 const collapsedFiles = useAtomValue(absorbCollapsedFiles);
b69ab31414 if (path == null) {
b69ab31415 return [undefined, undefined];
b69ab31416 }
b69ab31417 const isCollapsed = collapsedFiles.get(path) ?? (fileHasAnyDestinations ? false : true);
b69ab31418 const setCollapsed = (collapsed: boolean) => {
b69ab31419 const newMap = new Map(collapsedFiles);
b69ab31420 newMap.set(path, collapsed);
b69ab31421 writeAtom(absorbCollapsedFiles, newMap);
b69ab31422 };
b69ab31423 return [isCollapsed, setCollapsed];
b69ab31424}
b69ab31425function useResetCollapsedFilesOnMount() {
b69ab31426 useEffect(() => {
b69ab31427 writeAtom(absorbCollapsedFiles, new Map());
b69ab31428 }, []);
b69ab31429}
b69ab31430
b69ab31431function AbsorbEditsForFile(props: {
b69ab31432 isWdir: boolean;
b69ab31433 fileStackIndex: FileStackIndex;
b69ab31434 absorbEdits: ImMap<AbsorbEditId, AbsorbEdit>;
b69ab31435}) {
b69ab31436 const {fileStackIndex, absorbEdits} = props;
b69ab31437 const stack = nullthrows(useAtomValue(stackEditStack));
b69ab31438 const fileStack = nullthrows(stack.fileStacks.get(fileStackIndex));
b69ab31439 // In case the file is renamed, show "path1 -> path2" where path1 is the file
b69ab31440 // name in the commit, and path2 is the file name in the working copy.
b69ab31441 // Note: the line numbers we show are based on the working copy, not the commit.
b69ab31442 // So it seems showing the file name in the working copy is relevant.
b69ab31443 const fileRev = absorbEdits.first()?.selectedRev ?? (0 as FileRev);
b69ab31444 const pathInCommit = stack.getFileStackPath(fileStackIndex, fileRev);
b69ab31445 const wdirRev = prev(fileStack.revLength);
b69ab31446 const pathInWorkingCopy = stack.getFileStackPath(fileStackIndex, wdirRev);
b69ab31447 const path = pathInWorkingCopy ?? pathInCommit;
b69ab31448
b69ab31449 const fileHasAnyDestinations = !props.isWdir
b69ab31450 ? true
b69ab31451 : absorbEdits.some(edit => {
b69ab31452 const absorbEditId = edit.absorbEditId;
b69ab31453 const dests = stack?.getAbsorbCommitRevs(fileStackIndex, absorbEditId);
b69ab31454 return dests != null && dests.candidateRevs.length > 1;
b69ab31455 });
b69ab31456
b69ab31457 const [isCollapsed, setCollapsed] = useCollapsedFile(path, fileHasAnyDestinations);
b69ab31458
b69ab31459 return (
b69ab31460 <div>
b69ab31461 {path && (
b69ab31462 <FileHeader
b69ab31463 {...(isCollapsed == null
b69ab31464 ? {open: undefined, onChangeOpen: undefined}
b69ab31465 : {
b69ab31466 open: isCollapsed === false,
b69ab31467 onChangeOpen: open => setCollapsed(!open),
b69ab31468 })}
b69ab31469 copyFrom={pathInCommit}
b69ab31470 path={path}
b69ab31471 iconType={IconType.Modified}
b69ab31472 />
b69ab31473 )}
b69ab31474 {fileHasAnyDestinations === false && !isCollapsed ? (
b69ab31475 <div {...stylex.props(styles.fileHint)}>
b69ab31476 <Icon icon="warning" />
b69ab31477 <T>This file was not changed in this stack and can't be absorbed</T>
b69ab31478 </div>
b69ab31479 ) : null}
b69ab31480 {
b69ab31481 // Edits are rendered even when collapsed, so the reordering id animation doesn't trigger when collapsing.
b69ab31482 props.absorbEdits
b69ab31483 .map((edit, i) => (
b69ab31484 <SingleAbsorbEdit
b69ab31485 collapsed={isCollapsed}
b69ab31486 path={path}
b69ab31487 edit={edit}
b69ab31488 key={i}
b69ab31489 unmovable={fileHasAnyDestinations === false}
b69ab31490 />
b69ab31491 ))
b69ab31492 .valueSeq()
b69ab31493 }
b69ab31494 </div>
b69ab31495 );
b69ab31496}
b69ab31497
b69ab31498function SingleAbsorbEdit(props: {
b69ab31499 collapsed?: boolean;
b69ab31500 edit: AbsorbEdit;
b69ab31501 inDraggingOverlay?: boolean;
b69ab31502 path?: string;
b69ab31503 unmovable?: boolean;
b69ab31504}) {
b69ab31505 const {edit, inDraggingOverlay, path, unmovable} = props;
b69ab31506 const isDragging = useAtomValue(draggingAbsorbEdit);
b69ab31507 const stackEdit = useStackEditState();
b69ab31508 const reorderId = `absorb-${edit.fileStackIndex}-${edit.absorbEditId}`;
b69ab31509 const ref = useRef<HTMLDivElement | null>(null);
b69ab31510
b69ab31511 const handleDrag = (x: number, y: number, isDragging: boolean) => {
b69ab31512 // Visual update.
b69ab31513 onDragRef.current?.(x, y, isDragging);
b69ab31514 // State update.
b69ab31515 let newDraggingHint: string | null = null;
b69ab31516 if (isDragging) {
b69ab31517 // The 'stack' in the closure might be outdated. Read the latest.
b69ab31518 const stack = readAtom(stackEditStack);
b69ab31519 if (stack == null) {
b69ab31520 return;
b69ab31521 }
b69ab31522 const rev = findDragDestinationCommitRev(y, stack);
b69ab31523 const fileStackIndex = nullthrows(edit.fileStackIndex);
b69ab31524 const absorbEditId = edit.absorbEditId;
b69ab31525 if (
b69ab31526 rev != null &&
b69ab31527 rev !== stack?.getAbsorbCommitRevs(fileStackIndex, absorbEditId).selectedRev
b69ab31528 ) {
b69ab31529 const commit = nullthrows(stack.get(rev));
b69ab31530 let newStack = stack;
b69ab31531 try {
b69ab31532 newStack = stack.setAbsorbEditDestination(fileStackIndex, absorbEditId, rev);
b69ab31533 // `handleDrag` won't be updated with "refreshed" `stackEdit`.
b69ab31534 // So `push` can work like `replaceTopOperation` while dragging.
b69ab31535 stackEdit.push(newStack, {name: 'absorbMove', commit});
b69ab31536 } catch {
b69ab31537 // This should be unreachable.
b69ab31538 newDraggingHint = t(
b69ab31539 'Diff chunk can only be applied to a commit that modifies the file and has matching context lines.',
b69ab31540 );
b69ab31541 }
b69ab31542 }
b69ab31543 }
b69ab31544 // Ensure the hint is cleared when:
b69ab31545 // 1) not dragging. (important because the hint div interferes user interaction even if it's invisible)
b69ab31546 // 2) dragging back from an invalid rev to the current (valid) rev.
b69ab31547 writeAtom(draggingHint, newDraggingHint);
b69ab31548 writeAtom(draggingAbsorbEdit, isDragging ? edit : null);
b69ab31549 };
b69ab31550
b69ab31551 const useThemeHook = () => useAtomValue(themeState);
b69ab31552 const ctx: Context = {
b69ab31553 id: {comparison: {type: ComparisonType.UncommittedChanges} as Comparison, path: path ?? ''},
b69ab31554 collapsed: false,
b69ab31555 setCollapsed: () => null,
b69ab31556 useThemeHook,
b69ab31557 t,
b69ab31558 display: 'unified' as const,
b69ab31559 };
b69ab31560
b69ab31561 const patch = useMemo(() => {
b69ab31562 const lines = [
b69ab31563 ...edit.oldLines.toArray().map(l => `-${l}`),
b69ab31564 ...edit.newLines.toArray().map(l => `+${l}`),
b69ab31565 ];
b69ab31566 return {
b69ab31567 oldFileName: path,
b69ab31568 newFileName: path,
b69ab31569 hunks: [
b69ab31570 {
b69ab31571 oldStart: edit.oldStart,
b69ab31572 oldLines: edit.oldEnd - edit.oldStart,
b69ab31573 newStart: edit.newStart,
b69ab31574 newLines: edit.newEnd - edit.newStart,
b69ab31575 lines,
b69ab31576 linedelimiters: new Array(lines.length).fill('\n'),
b69ab31577 },
b69ab31578 ],
b69ab31579 } as ParsedDiff;
b69ab31580 }, [edit, path]);
b69ab31581
b69ab31582 return (
b69ab31583 <div
b69ab31584 ref={ref}
b69ab31585 {...stylex.props(
b69ab31586 styles.absorbEditSingleChunk,
b69ab31587 inDraggingOverlay && styles.inDraggingOverlay,
b69ab31588 !inDraggingOverlay && isDragging === edit && styles.beingDragged,
b69ab31589 )}
b69ab31590 data-reorder-id={reorderId}>
b69ab31591 {props.collapsed ? null : (
b69ab31592 <>
b69ab31593 <div {...stylex.props(styles.dragHandlerWrapper)}>
b69ab31594 <DragHandle
b69ab31595 onDrag={unmovable ? undefined : handleDrag}
b69ab31596 xstyle={[styles.dragHandle, unmovable ? styles.unmoveable : undefined]}>
b69ab31597 <Icon icon="grabber" />
b69ab31598 </DragHandle>
b69ab31599 {!inDraggingOverlay && !unmovable && <SendToCommitButton edit={edit} />}
b69ab31600 </div>
b69ab31601 <SplitDiffTable ctx={ctx} path={path ?? ''} patch={patch} />
b69ab31602 </>
b69ab31603 )}
b69ab31604 </div>
b69ab31605 );
b69ab31606}
b69ab31607
b69ab31608function SendToCommitButton({edit}: {edit: AbsorbEdit}) {
b69ab31609 const stackEdit = useStackEditState();
b69ab31610 const menu = useContextMenu(() => {
b69ab31611 const stack = readAtom(stackEditStack);
b69ab31612
b69ab31613 const {fileStackIndex, absorbEditId} = edit;
b69ab31614 if (stack == null || fileStackIndex == null || absorbEditId == null) {
b69ab31615 return [];
b69ab31616 }
b69ab31617
b69ab31618 const items: Array<ContextMenuItem> = [];
b69ab31619
b69ab31620 const absorbRevs = stack.getAbsorbCommitRevs(fileStackIndex, absorbEditId);
b69ab31621 for (const rev of absorbRevs.candidateRevs.toReversed()) {
b69ab31622 const info = nullthrows(stack.get(rev));
b69ab31623
b69ab31624 if (
b69ab31625 rev === absorbRevs.selectedRev ||
b69ab31626 (absorbRevs.selectedRev == null && info.key === YOU_ARE_HERE_VIRTUAL_COMMIT.hash)
b69ab31627 ) {
b69ab31628 // skip rev this edit is already in
b69ab31629 continue;
b69ab31630 }
b69ab31631
b69ab31632 items.push({
b69ab31633 label: (
b69ab31634 <div>
b69ab31635 {info.key === YOU_ARE_HERE_VIRTUAL_COMMIT.hash
b69ab31636 ? 'Uncommitted Changes'
b69ab31637 : firstLine(info.text)}
b69ab31638 </div>
b69ab31639 ),
b69ab31640 onClick: () => {
b69ab31641 const newStack = stack.setAbsorbEditDestination(fileStackIndex, absorbEditId, rev);
b69ab31642 stackEdit.push(newStack, {name: 'absorbMove', commit: info});
b69ab31643 },
b69ab31644 });
b69ab31645 }
b69ab31646 return items;
b69ab31647 });
b69ab31648 return (
b69ab31649 <div {...stylexPropsWithClassName(styles.sendToCommitButton, 'send-to-commit')}>
b69ab31650 <Tooltip title={t('Move to a specific commit')}>
b69ab31651 <Button icon onClick={menu}>
b69ab31652 <Icon icon="insert" />
b69ab31653 </Button>
b69ab31654 </Tooltip>
b69ab31655 </div>
b69ab31656 );
b69ab31657}