| 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 | |
| 8 | import type {Map as ImMap} from 'immutable'; |
| 9 | import type {ReactNode} from 'react'; |
| 10 | import type {Comparison} from 'shared/Comparison'; |
| 11 | import type {ContextMenuItem} from 'shared/ContextMenu'; |
| 12 | import type {ParsedDiff} from 'shared/patch/types'; |
| 13 | import type {Context} from '../../ComparisonView/SplitDiffView/types'; |
| 14 | import type {DragHandler} from '../../DragHandle'; |
| 15 | import type {RenderGlyphResult} from '../../RenderDag'; |
| 16 | import type {Dag} from '../../dag/dag'; |
| 17 | import type {DagCommitInfo} from '../../dag/dagCommitInfo'; |
| 18 | import type {HashSet} from '../../dag/set'; |
| 19 | import type {AbsorbEdit, AbsorbEditId} from '../absorb'; |
| 20 | import type {CommitRev, CommitStackState, FileRev, FileStackIndex} from '../commitStackState'; |
| 21 | |
| 22 | import * as stylex from '@stylexjs/stylex'; |
| 23 | import {Banner, BannerKind} from 'isl-components/Banner'; |
| 24 | import {Button} from 'isl-components/Button'; |
| 25 | import {Column, Row} from 'isl-components/Flex'; |
| 26 | import {Icon} from 'isl-components/Icon'; |
| 27 | import {Tooltip} from 'isl-components/Tooltip'; |
| 28 | import {stylexPropsWithClassName} from 'isl-components/utils'; |
| 29 | import {atom, useAtomValue} from 'jotai'; |
| 30 | import React, {useEffect, useMemo, useRef} from 'react'; |
| 31 | import {ComparisonType} from 'shared/Comparison'; |
| 32 | import {useContextMenu} from 'shared/ContextMenu'; |
| 33 | import {firstLine, nullthrows} from 'shared/utils'; |
| 34 | import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader'; |
| 35 | import {SplitDiffTable} from '../../ComparisonView/SplitDiffView/SplitDiffHunk'; |
| 36 | import {ScrollY} from '../../ComponentUtils'; |
| 37 | import {DragHandle} from '../../DragHandle'; |
| 38 | import {DraggingOverlay} from '../../DraggingOverlay'; |
| 39 | import {defaultRenderGlyph, RenderDag} from '../../RenderDag'; |
| 40 | import {YOU_ARE_HERE_VIRTUAL_COMMIT} from '../../dag/virtualCommit'; |
| 41 | import {t, T} from '../../i18n'; |
| 42 | import {readAtom, writeAtom} from '../../jotaiUtils'; |
| 43 | import {themeState} from '../../theme'; |
| 44 | import {prev} from '../revMath'; |
| 45 | import {calculateDagFromStack} from '../stackDag'; |
| 46 | import {stackEditStack, useStackEditState} from './stackEditState'; |
| 47 | |
| 48 | const 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. */ |
| 164 | const draggingAbsorbEdit = atom<AbsorbEdit | null>(null); |
| 165 | const draggingHint = atom<string | null>(null); |
| 166 | const onDragRef: {current: null | DragHandler} = {current: null}; |
| 167 | |
| 168 | export 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 | |
| 199 | function 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 | |
| 238 | const 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 | |
| 248 | function 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 | |
| 268 | function 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 | */ |
| 294 | function 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. |
| 305 | function renderCommit(info: DagCommitInfo) { |
| 306 | return <RenderCommit info={info} />; |
| 307 | } |
| 308 | |
| 309 | function 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 | |
| 326 | function 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 | */ |
| 334 | function 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. */ |
| 360 | function 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. */ |
| 370 | function 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 | |
| 407 | const absorbCollapsedFiles = atom<Map<string, boolean>>(new Map()); |
| 408 | |
| 409 | function 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 | } |
| 425 | function useResetCollapsedFilesOnMount() { |
| 426 | useEffect(() => { |
| 427 | writeAtom(absorbCollapsedFiles, new Map()); |
| 428 | }, []); |
| 429 | } |
| 430 | |
| 431 | function 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 | |
| 498 | function 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 | |
| 608 | function 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 | |