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