addons/isl/src/stackEdit/ui/StackEditSubTree.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 {DragHandler} from '../../DragHandle';
b69ab319import type {CommitRev, CommitState} from '../commitStackState';
b69ab3110import type {StackEditOpDescription, UseStackEditState} from './stackEditState';
b69ab3111
b69ab3112import {is} from 'immutable';
b69ab3113import {Button} from 'isl-components/Button';
b69ab3114import {Icon} from 'isl-components/Icon';
b69ab3115import {Tooltip} from 'isl-components/Tooltip';
b69ab3116import {useRef, useState} from 'react';
b69ab3117import {nullthrows} from 'shared/utils';
b69ab3118import {AnimatedReorderGroup} from '../../AnimatedReorderGroup';
b69ab3119import {CommitTitle as StandaloneCommitTitle} from '../../CommitTitle';
b69ab3120import {Row} from '../../ComponentUtils';
b69ab3121import {DragHandle} from '../../DragHandle';
b69ab3122import {DraggingOverlay} from '../../DraggingOverlay';
b69ab3123import {t, T} from '../../i18n';
b69ab3124import {SplitCommitIcon} from '../../icons/SplitCommitIcon';
b69ab3125import {reorderedRevs} from '../commitStackState';
b69ab3126import {ReorderState} from '../reorderState';
b69ab3127import {bumpStackEditMetric, useStackEditState, WDIR_NODE} from './stackEditState';
b69ab3128
b69ab3129import './StackEditSubTree.css';
b69ab3130
b69ab3131type ActivateSplitProps = {
b69ab3132 activateSplitTab?: () => void;
b69ab3133};
b69ab3134
b69ab3135// <StackEditSubTree /> assumes stack is loaded.
b69ab3136export function StackEditSubTree(props: ActivateSplitProps): React.ReactElement {
b69ab3137 const stackEdit = useStackEditState();
b69ab3138 const [reorderState, setReorderState] = useState<ReorderState>(() => new ReorderState());
b69ab3139
b69ab3140 const onDragRef = useRef<DragHandler | null>(null);
b69ab3141 const commitListDivRef = useRef<HTMLDivElement | null>(null);
b69ab3142
b69ab3143 const commitStack = stackEdit.commitStack;
b69ab3144 const revs = reorderState.isDragging()
b69ab3145 ? reorderState.reorderRevs.slice(1).toArray().reverse()
b69ab3146 : commitStack.mutableRevs().reverse();
b69ab3147
b69ab3148 // What will happen after drop.
b69ab3149 const draggingHintText: string | null =
b69ab3150 reorderState.draggingRevs.size > 1 ? t('Dependent commits are moved together') : null;
b69ab3151
b69ab3152 const getDragHandler = (rev: CommitRev): DragHandler => {
b69ab3153 // Track `reorderState` updates in case the <DragHandle/>-captured `reorderState` gets outdated.
b69ab3154 // Note: this would be unnecessary if React provides `getState()` instead of `state`.
b69ab3155 let currentReorderState = reorderState;
b69ab3156 const setCurrentReorderState = (state: ReorderState) => {
b69ab3157 if (is(state, currentReorderState)) {
b69ab3158 return;
b69ab3159 }
b69ab3160 currentReorderState = state;
b69ab3161 setReorderState(state);
b69ab3162 };
b69ab3163
b69ab3164 return (x, y, isDragging) => {
b69ab3165 // Visual update.
b69ab3166 onDragRef.current?.(x, y, isDragging);
b69ab3167 // State update.
b69ab3168 if (isDragging) {
b69ab3169 if (currentReorderState.isDragging()) {
b69ab3170 if (commitListDivRef.current) {
b69ab3171 const offset = calculateReorderOffset(
b69ab3172 commitListDivRef.current,
b69ab3173 y,
b69ab3174 currentReorderState.draggingRev,
b69ab3175 );
b69ab3176 const newReorderState = currentReorderState.withOffset(offset);
b69ab3177 setCurrentReorderState(newReorderState);
b69ab3178 }
b69ab3179 } else {
b69ab3180 setCurrentReorderState(ReorderState.init(commitStack, rev));
b69ab3181 }
b69ab3182 } else if (!isDragging && currentReorderState.isDragging()) {
b69ab3183 // Apply reorder.
b69ab3184 const order = currentReorderState.reorderRevs.toArray();
b69ab3185 const commitStack = stackEdit.commitStack;
b69ab3186 if (commitStack.canReorder(order) && !currentReorderState.isNoop()) {
b69ab3187 const newStackState = commitStack.reorder(order);
b69ab3188 stackEdit.push(newStackState, {
b69ab3189 name: 'move',
b69ab3190 offset: currentReorderState.offset,
b69ab3191 depCount: currentReorderState.draggingRevs.size - 1,
b69ab3192 commit: nullthrows(commitStack.stack.get(currentReorderState.draggingRev)),
b69ab3193 });
b69ab3194 bumpStackEditMetric('moveDnD');
b69ab3195 }
b69ab3196 // Reset reorder state.
b69ab3197 setCurrentReorderState(new ReorderState());
b69ab3198 }
b69ab3199 };
b69ab31100 };
b69ab31101
b69ab31102 return (
b69ab31103 <>
b69ab31104 <div className="stack-edit-subtree" ref={commitListDivRef}>
b69ab31105 <AnimatedReorderGroup>
b69ab31106 {revs.map(rev => {
b69ab31107 return (
b69ab31108 <StackEditCommit
b69ab31109 key={rev}
b69ab31110 rev={rev}
b69ab31111 stackEdit={stackEdit}
b69ab31112 isReorderPreview={reorderState.draggingRevs.includes(rev)}
b69ab31113 onDrag={getDragHandler(rev)}
b69ab31114 activateSplitTab={props.activateSplitTab}
b69ab31115 />
b69ab31116 );
b69ab31117 })}
b69ab31118 </AnimatedReorderGroup>
b69ab31119 </div>
b69ab31120 {reorderState.isDragging() && (
b69ab31121 <DraggingOverlay onDragRef={onDragRef} hint={draggingHintText}>
b69ab31122 {reorderState.draggingRevs
b69ab31123 .toArray()
b69ab31124 .reverse()
b69ab31125 .map(rev => (
b69ab31126 <StackEditCommit key={rev} rev={rev} stackEdit={stackEdit} />
b69ab31127 ))}
b69ab31128 </DraggingOverlay>
b69ab31129 )}
b69ab31130 </>
b69ab31131 );
b69ab31132}
b69ab31133
b69ab31134export function StackEditCommit({
b69ab31135 rev,
b69ab31136 stackEdit,
b69ab31137 onDrag,
b69ab31138 isReorderPreview,
b69ab31139 activateSplitTab,
b69ab31140}: {
b69ab31141 rev: CommitRev;
b69ab31142 stackEdit: UseStackEditState;
b69ab31143 onDrag?: DragHandler;
b69ab31144 isReorderPreview?: boolean;
b69ab31145} & ActivateSplitProps): React.ReactElement {
b69ab31146 const state = stackEdit.commitStack;
b69ab31147 const canFold = state.canFoldDown(rev);
b69ab31148 const canDrop = state.canDrop(rev);
b69ab31149 const canMoveDown = state.canMoveDown(rev);
b69ab31150 const canMoveUp = state.canMoveUp(rev);
b69ab31151 const commit = nullthrows(state.stack.get(rev));
b69ab31152 const titleText = commit.text.split('\n', 1).at(0) ?? '';
b69ab31153
b69ab31154 const handleMoveUp = () => {
b69ab31155 stackEdit.push(state.reorder(reorderedRevs(state, rev)), {name: 'move', offset: 1, commit});
b69ab31156 bumpStackEditMetric('moveUpDown');
b69ab31157 };
b69ab31158 const handleMoveDown = () => {
b69ab31159 stackEdit.push(state.reorder(reorderedRevs(state, rev - 1)), {
b69ab31160 name: 'move',
b69ab31161 offset: -1,
b69ab31162 commit,
b69ab31163 });
b69ab31164 bumpStackEditMetric('moveUpDown');
b69ab31165 };
b69ab31166 const handleFoldDown = () => {
b69ab31167 stackEdit.push(state.foldDown(rev), {name: 'fold', commit});
b69ab31168 bumpStackEditMetric('fold');
b69ab31169 };
b69ab31170 const handleDrop = () => {
b69ab31171 stackEdit.push(state.drop(rev), {name: 'drop', commit});
b69ab31172 bumpStackEditMetric('drop');
b69ab31173 };
b69ab31174 const handleSplit = () => {
b69ab31175 stackEdit.setSplitRange(commit.key);
b69ab31176 // Focus the split panel.
b69ab31177 activateSplitTab?.();
b69ab31178 };
b69ab31179
b69ab31180 const title =
b69ab31181 titleText === '' ? (
b69ab31182 <span className="commit-title untitled">
b69ab31183 <T>Untitled</T>
b69ab31184 </span>
b69ab31185 ) : (
b69ab31186 <StandaloneCommitTitle commitMessage={commit.text} />
b69ab31187 );
b69ab31188 const buttons = (
b69ab31189 <div className="stack-edit-button-group">
b69ab31190 <Tooltip
b69ab31191 title={
b69ab31192 canMoveUp
b69ab31193 ? t('Move commit up in the stack')
b69ab31194 : t(
b69ab31195 'Cannot move up if this commit is at the top, or if the next commit depends on this commit',
b69ab31196 )
b69ab31197 }>
b69ab31198 <Button disabled={!canMoveUp} onClick={handleMoveUp} icon>
b69ab31199 <Icon icon="chevron-up" />
b69ab31200 </Button>
b69ab31201 </Tooltip>
b69ab31202 <Tooltip
b69ab31203 title={
b69ab31204 canMoveDown
b69ab31205 ? t('Move commit down in the stack')
b69ab31206 : t(
b69ab31207 'Cannot move up if this commit is at the bottom, or if this commit depends on its parent',
b69ab31208 )
b69ab31209 }>
b69ab31210 <Button disabled={!canMoveDown} onClick={handleMoveDown} icon>
b69ab31211 <Icon icon="chevron-down" />
b69ab31212 </Button>
b69ab31213 </Tooltip>
b69ab31214 <Tooltip
b69ab31215 title={
b69ab31216 canFold
b69ab31217 ? t('Fold the commit with its parent')
b69ab31218 : t('Can not fold with parent if this commit is at the bottom')
b69ab31219 }>
b69ab31220 <Button disabled={!canFold} onClick={handleFoldDown} icon>
b69ab31221 <Icon icon="fold-down" />
b69ab31222 </Button>
b69ab31223 </Tooltip>
b69ab31224 <Tooltip
b69ab31225 title={
b69ab31226 canDrop
b69ab31227 ? t('Drop the commit in the stack')
b69ab31228 : t('Cannot drop this commit because it has dependencies')
b69ab31229 }>
b69ab31230 <Button disabled={!canDrop} onClick={handleDrop} icon>
b69ab31231 <Icon icon="close" />
b69ab31232 </Button>
b69ab31233 </Tooltip>
b69ab31234 </div>
b69ab31235 );
b69ab31236
b69ab31237 const rightSideButtons = (
b69ab31238 <div className="stack-edit-right-side-buttons">
b69ab31239 <Tooltip title={t('Start interactive split for this commit')}>
b69ab31240 <Button onClick={handleSplit} icon>
b69ab31241 <SplitCommitIcon slot="start" />
b69ab31242 <T>Split</T>
b69ab31243 </Button>
b69ab31244 </Tooltip>
b69ab31245 </div>
b69ab31246 );
b69ab31247
b69ab31248 return (
b69ab31249 <Row
b69ab31250 data-reorder-id={onDrag ? commit.key : ''}
b69ab31251 data-rev={rev}
b69ab31252 className={`commit${isReorderPreview ? ' commit-reorder-preview' : ''}`}>
b69ab31253 <DragHandle onDrag={onDrag}>
b69ab31254 <Icon icon="grabber" />
b69ab31255 </DragHandle>
b69ab31256 {buttons}
b69ab31257 {title}
b69ab31258 {rightSideButtons}
b69ab31259 </Row>
b69ab31260 );
b69ab31261}
b69ab31262
b69ab31263/**
b69ab31264 * Calculate the reorder "offset" based on the y axis.
b69ab31265 *
b69ab31266 * This function assumes the stack rev 0 is used as the "public" (or "immutable")
b69ab31267 * commit that is not rendered. If that's no longer the case, adjust the
b69ab31268 * `invisibleRevCount` accordingly.
b69ab31269 *
b69ab31270 * This is done by counting how many `.commit`s are below the y axis.
b69ab31271 * If nothing is reordered, there should be `rev - invisibleRevCount` commits below.
b69ab31272 * The existing `rev`s on the `.commit`s are not considered, as they can be before
b69ab31273 * or after the reorder preview, which are noisy to consider.
b69ab31274 */
b69ab31275function calculateReorderOffset(
b69ab31276 container: HTMLDivElement,
b69ab31277 y: number,
b69ab31278 draggingRev: CommitRev,
b69ab31279 invisibleRevCount = 1,
b69ab31280): number {
b69ab31281 let belowCount = 0;
b69ab31282 const parentY: number = nullthrows(container).getBoundingClientRect().y;
b69ab31283 container.querySelectorAll('.commit').forEach(element => {
b69ab31284 const commitDiv = element as HTMLDivElement;
b69ab31285 // commitDiv.getBoundingClientRect() will consider the animation transform.
b69ab31286 // We don't want to be affected by animation, so we use 'container' here,
b69ab31287 // assuming 'container' is not animated. The 'container' can be in <ScrollY>,
b69ab31288 // and should have a 'relative' position.
b69ab31289 const commitY = parentY + commitDiv.offsetTop;
b69ab31290 if (commitY > y) {
b69ab31291 belowCount += 1;
b69ab31292 }
b69ab31293 });
b69ab31294 const offset = invisibleRevCount + belowCount - draggingRev;
b69ab31295 return offset;
b69ab31296}
b69ab31297
b69ab31298/** Used in undo tooltip. */
b69ab31299export function UndoDescription({op}: {op?: StackEditOpDescription}): React.ReactElement | null {
b69ab31300 if (op == null) {
b69ab31301 return <T>null</T>;
b69ab31302 }
b69ab31303 if (op.name === 'move') {
b69ab31304 const {offset, commit} = op;
b69ab31305 const depCount = op.depCount ?? 0;
b69ab31306 const replace = {
b69ab31307 $commit: <CommitTitle commit={commit} />,
b69ab31308 $depCount: depCount,
b69ab31309 $offset: Math.abs(offset).toString(),
b69ab31310 };
b69ab31311 if (offset === 1) {
b69ab31312 return <T replace={replace}>moving up $commit</T>;
b69ab31313 } else if (offset === -1) {
b69ab31314 return <T replace={replace}>moving down $commit</T>;
b69ab31315 } else if (offset > 0) {
b69ab31316 if (depCount > 0) {
b69ab31317 return <T replace={replace}>moving up $commit and $depCount more</T>;
b69ab31318 } else {
b69ab31319 return <T replace={replace}>moving up $commit by $offset commits</T>;
b69ab31320 }
b69ab31321 } else {
b69ab31322 if (depCount > 0) {
b69ab31323 return <T replace={replace}>moving down $commit and $depCount more</T>;
b69ab31324 } else {
b69ab31325 return <T replace={replace}>moving down $commit by $offset commits</T>;
b69ab31326 }
b69ab31327 }
b69ab31328 } else if (op.name === 'swap') {
b69ab31329 return <T>swap the order of two commits</T>;
b69ab31330 } else if (op.name === 'fold') {
b69ab31331 const replace = {$commit: <CommitTitle commit={op.commit} />};
b69ab31332 return <T replace={replace}>folding down $commit</T>;
b69ab31333 } else if (op.name === 'insertBlankCommit') {
b69ab31334 return <T>inserting a new blank commit</T>;
b69ab31335 } else if (op.name === 'drop') {
b69ab31336 const replace = {$commit: <CommitTitle commit={op.commit} />};
b69ab31337 return <T replace={replace}>dropping $commit</T>;
b69ab31338 } else if (op.name === 'metaedit') {
b69ab31339 const replace = {$commit: <CommitTitle commit={op.commit} />};
b69ab31340 return <T replace={replace}>editing message of $commit</T>;
b69ab31341 } else if (op.name === 'import') {
b69ab31342 return <T>import</T>;
b69ab31343 } else if (op.name === 'fileStack') {
b69ab31344 return <T replace={{$file: op.fileDesc}}>editing file stack: $file</T>;
b69ab31345 } else if (op.name === 'split') {
b69ab31346 return <T replace={{$file: op.path}}>editing $file via interactive split</T>;
b69ab31347 } else if (op.name === 'splitWithAI') {
b69ab31348 return <T>split with AI</T>;
b69ab31349 } else if (op.name === 'absorbMove') {
b69ab31350 const replace = {$commit: <CommitTitle commit={op.commit} />};
b69ab31351 return <T replace={replace}>moving a diff chunk to $commit</T>;
b69ab31352 }
b69ab31353 return <T>unknown</T>;
b69ab31354}
b69ab31355
b69ab31356/** Used in undo tooltip. Styled. */
b69ab31357function CommitTitle({commit}: {commit: CommitState}): React.ReactElement {
b69ab31358 if (commit.originalNodes.contains(WDIR_NODE)) {
b69ab31359 return <T>the working copy</T>;
b69ab31360 }
b69ab31361 return <span className="commit-title">{commit.text.split('\n', 1).at(0)}</span>;
b69ab31362}