12.3 KB363 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {DragHandler} from '../../DragHandle';
9import type {CommitRev, CommitState} from '../commitStackState';
10import type {StackEditOpDescription, UseStackEditState} from './stackEditState';
11
12import {is} from 'immutable';
13import {Button} from 'isl-components/Button';
14import {Icon} from 'isl-components/Icon';
15import {Tooltip} from 'isl-components/Tooltip';
16import {useRef, useState} from 'react';
17import {nullthrows} from 'shared/utils';
18import {AnimatedReorderGroup} from '../../AnimatedReorderGroup';
19import {CommitTitle as StandaloneCommitTitle} from '../../CommitTitle';
20import {Row} from '../../ComponentUtils';
21import {DragHandle} from '../../DragHandle';
22import {DraggingOverlay} from '../../DraggingOverlay';
23import {t, T} from '../../i18n';
24import {SplitCommitIcon} from '../../icons/SplitCommitIcon';
25import {reorderedRevs} from '../commitStackState';
26import {ReorderState} from '../reorderState';
27import {bumpStackEditMetric, useStackEditState, WDIR_NODE} from './stackEditState';
28
29import './StackEditSubTree.css';
30
31type ActivateSplitProps = {
32 activateSplitTab?: () => void;
33};
34
35// <StackEditSubTree /> assumes stack is loaded.
36export function StackEditSubTree(props: ActivateSplitProps): React.ReactElement {
37 const stackEdit = useStackEditState();
38 const [reorderState, setReorderState] = useState<ReorderState>(() => new ReorderState());
39
40 const onDragRef = useRef<DragHandler | null>(null);
41 const commitListDivRef = useRef<HTMLDivElement | null>(null);
42
43 const commitStack = stackEdit.commitStack;
44 const revs = reorderState.isDragging()
45 ? reorderState.reorderRevs.slice(1).toArray().reverse()
46 : commitStack.mutableRevs().reverse();
47
48 // What will happen after drop.
49 const draggingHintText: string | null =
50 reorderState.draggingRevs.size > 1 ? t('Dependent commits are moved together') : null;
51
52 const getDragHandler = (rev: CommitRev): DragHandler => {
53 // Track `reorderState` updates in case the <DragHandle/>-captured `reorderState` gets outdated.
54 // Note: this would be unnecessary if React provides `getState()` instead of `state`.
55 let currentReorderState = reorderState;
56 const setCurrentReorderState = (state: ReorderState) => {
57 if (is(state, currentReorderState)) {
58 return;
59 }
60 currentReorderState = state;
61 setReorderState(state);
62 };
63
64 return (x, y, isDragging) => {
65 // Visual update.
66 onDragRef.current?.(x, y, isDragging);
67 // State update.
68 if (isDragging) {
69 if (currentReorderState.isDragging()) {
70 if (commitListDivRef.current) {
71 const offset = calculateReorderOffset(
72 commitListDivRef.current,
73 y,
74 currentReorderState.draggingRev,
75 );
76 const newReorderState = currentReorderState.withOffset(offset);
77 setCurrentReorderState(newReorderState);
78 }
79 } else {
80 setCurrentReorderState(ReorderState.init(commitStack, rev));
81 }
82 } else if (!isDragging && currentReorderState.isDragging()) {
83 // Apply reorder.
84 const order = currentReorderState.reorderRevs.toArray();
85 const commitStack = stackEdit.commitStack;
86 if (commitStack.canReorder(order) && !currentReorderState.isNoop()) {
87 const newStackState = commitStack.reorder(order);
88 stackEdit.push(newStackState, {
89 name: 'move',
90 offset: currentReorderState.offset,
91 depCount: currentReorderState.draggingRevs.size - 1,
92 commit: nullthrows(commitStack.stack.get(currentReorderState.draggingRev)),
93 });
94 bumpStackEditMetric('moveDnD');
95 }
96 // Reset reorder state.
97 setCurrentReorderState(new ReorderState());
98 }
99 };
100 };
101
102 return (
103 <>
104 <div className="stack-edit-subtree" ref={commitListDivRef}>
105 <AnimatedReorderGroup>
106 {revs.map(rev => {
107 return (
108 <StackEditCommit
109 key={rev}
110 rev={rev}
111 stackEdit={stackEdit}
112 isReorderPreview={reorderState.draggingRevs.includes(rev)}
113 onDrag={getDragHandler(rev)}
114 activateSplitTab={props.activateSplitTab}
115 />
116 );
117 })}
118 </AnimatedReorderGroup>
119 </div>
120 {reorderState.isDragging() && (
121 <DraggingOverlay onDragRef={onDragRef} hint={draggingHintText}>
122 {reorderState.draggingRevs
123 .toArray()
124 .reverse()
125 .map(rev => (
126 <StackEditCommit key={rev} rev={rev} stackEdit={stackEdit} />
127 ))}
128 </DraggingOverlay>
129 )}
130 </>
131 );
132}
133
134export function StackEditCommit({
135 rev,
136 stackEdit,
137 onDrag,
138 isReorderPreview,
139 activateSplitTab,
140}: {
141 rev: CommitRev;
142 stackEdit: UseStackEditState;
143 onDrag?: DragHandler;
144 isReorderPreview?: boolean;
145} & ActivateSplitProps): React.ReactElement {
146 const state = stackEdit.commitStack;
147 const canFold = state.canFoldDown(rev);
148 const canDrop = state.canDrop(rev);
149 const canMoveDown = state.canMoveDown(rev);
150 const canMoveUp = state.canMoveUp(rev);
151 const commit = nullthrows(state.stack.get(rev));
152 const titleText = commit.text.split('\n', 1).at(0) ?? '';
153
154 const handleMoveUp = () => {
155 stackEdit.push(state.reorder(reorderedRevs(state, rev)), {name: 'move', offset: 1, commit});
156 bumpStackEditMetric('moveUpDown');
157 };
158 const handleMoveDown = () => {
159 stackEdit.push(state.reorder(reorderedRevs(state, rev - 1)), {
160 name: 'move',
161 offset: -1,
162 commit,
163 });
164 bumpStackEditMetric('moveUpDown');
165 };
166 const handleFoldDown = () => {
167 stackEdit.push(state.foldDown(rev), {name: 'fold', commit});
168 bumpStackEditMetric('fold');
169 };
170 const handleDrop = () => {
171 stackEdit.push(state.drop(rev), {name: 'drop', commit});
172 bumpStackEditMetric('drop');
173 };
174 const handleSplit = () => {
175 stackEdit.setSplitRange(commit.key);
176 // Focus the split panel.
177 activateSplitTab?.();
178 };
179
180 const title =
181 titleText === '' ? (
182 <span className="commit-title untitled">
183 <T>Untitled</T>
184 </span>
185 ) : (
186 <StandaloneCommitTitle commitMessage={commit.text} />
187 );
188 const buttons = (
189 <div className="stack-edit-button-group">
190 <Tooltip
191 title={
192 canMoveUp
193 ? t('Move commit up in the stack')
194 : t(
195 'Cannot move up if this commit is at the top, or if the next commit depends on this commit',
196 )
197 }>
198 <Button disabled={!canMoveUp} onClick={handleMoveUp} icon>
199 <Icon icon="chevron-up" />
200 </Button>
201 </Tooltip>
202 <Tooltip
203 title={
204 canMoveDown
205 ? t('Move commit down in the stack')
206 : t(
207 'Cannot move up if this commit is at the bottom, or if this commit depends on its parent',
208 )
209 }>
210 <Button disabled={!canMoveDown} onClick={handleMoveDown} icon>
211 <Icon icon="chevron-down" />
212 </Button>
213 </Tooltip>
214 <Tooltip
215 title={
216 canFold
217 ? t('Fold the commit with its parent')
218 : t('Can not fold with parent if this commit is at the bottom')
219 }>
220 <Button disabled={!canFold} onClick={handleFoldDown} icon>
221 <Icon icon="fold-down" />
222 </Button>
223 </Tooltip>
224 <Tooltip
225 title={
226 canDrop
227 ? t('Drop the commit in the stack')
228 : t('Cannot drop this commit because it has dependencies')
229 }>
230 <Button disabled={!canDrop} onClick={handleDrop} icon>
231 <Icon icon="close" />
232 </Button>
233 </Tooltip>
234 </div>
235 );
236
237 const rightSideButtons = (
238 <div className="stack-edit-right-side-buttons">
239 <Tooltip title={t('Start interactive split for this commit')}>
240 <Button onClick={handleSplit} icon>
241 <SplitCommitIcon slot="start" />
242 <T>Split</T>
243 </Button>
244 </Tooltip>
245 </div>
246 );
247
248 return (
249 <Row
250 data-reorder-id={onDrag ? commit.key : ''}
251 data-rev={rev}
252 className={`commit${isReorderPreview ? ' commit-reorder-preview' : ''}`}>
253 <DragHandle onDrag={onDrag}>
254 <Icon icon="grabber" />
255 </DragHandle>
256 {buttons}
257 {title}
258 {rightSideButtons}
259 </Row>
260 );
261}
262
263/**
264 * Calculate the reorder "offset" based on the y axis.
265 *
266 * This function assumes the stack rev 0 is used as the "public" (or "immutable")
267 * commit that is not rendered. If that's no longer the case, adjust the
268 * `invisibleRevCount` accordingly.
269 *
270 * This is done by counting how many `.commit`s are below the y axis.
271 * If nothing is reordered, there should be `rev - invisibleRevCount` commits below.
272 * The existing `rev`s on the `.commit`s are not considered, as they can be before
273 * or after the reorder preview, which are noisy to consider.
274 */
275function calculateReorderOffset(
276 container: HTMLDivElement,
277 y: number,
278 draggingRev: CommitRev,
279 invisibleRevCount = 1,
280): number {
281 let belowCount = 0;
282 const parentY: number = nullthrows(container).getBoundingClientRect().y;
283 container.querySelectorAll('.commit').forEach(element => {
284 const commitDiv = element as HTMLDivElement;
285 // commitDiv.getBoundingClientRect() will consider the animation transform.
286 // We don't want to be affected by animation, so we use 'container' here,
287 // assuming 'container' is not animated. The 'container' can be in <ScrollY>,
288 // and should have a 'relative' position.
289 const commitY = parentY + commitDiv.offsetTop;
290 if (commitY > y) {
291 belowCount += 1;
292 }
293 });
294 const offset = invisibleRevCount + belowCount - draggingRev;
295 return offset;
296}
297
298/** Used in undo tooltip. */
299export function UndoDescription({op}: {op?: StackEditOpDescription}): React.ReactElement | null {
300 if (op == null) {
301 return <T>null</T>;
302 }
303 if (op.name === 'move') {
304 const {offset, commit} = op;
305 const depCount = op.depCount ?? 0;
306 const replace = {
307 $commit: <CommitTitle commit={commit} />,
308 $depCount: depCount,
309 $offset: Math.abs(offset).toString(),
310 };
311 if (offset === 1) {
312 return <T replace={replace}>moving up $commit</T>;
313 } else if (offset === -1) {
314 return <T replace={replace}>moving down $commit</T>;
315 } else if (offset > 0) {
316 if (depCount > 0) {
317 return <T replace={replace}>moving up $commit and $depCount more</T>;
318 } else {
319 return <T replace={replace}>moving up $commit by $offset commits</T>;
320 }
321 } else {
322 if (depCount > 0) {
323 return <T replace={replace}>moving down $commit and $depCount more</T>;
324 } else {
325 return <T replace={replace}>moving down $commit by $offset commits</T>;
326 }
327 }
328 } else if (op.name === 'swap') {
329 return <T>swap the order of two commits</T>;
330 } else if (op.name === 'fold') {
331 const replace = {$commit: <CommitTitle commit={op.commit} />};
332 return <T replace={replace}>folding down $commit</T>;
333 } else if (op.name === 'insertBlankCommit') {
334 return <T>inserting a new blank commit</T>;
335 } else if (op.name === 'drop') {
336 const replace = {$commit: <CommitTitle commit={op.commit} />};
337 return <T replace={replace}>dropping $commit</T>;
338 } else if (op.name === 'metaedit') {
339 const replace = {$commit: <CommitTitle commit={op.commit} />};
340 return <T replace={replace}>editing message of $commit</T>;
341 } else if (op.name === 'import') {
342 return <T>import</T>;
343 } else if (op.name === 'fileStack') {
344 return <T replace={{$file: op.fileDesc}}>editing file stack: $file</T>;
345 } else if (op.name === 'split') {
346 return <T replace={{$file: op.path}}>editing $file via interactive split</T>;
347 } else if (op.name === 'splitWithAI') {
348 return <T>split with AI</T>;
349 } else if (op.name === 'absorbMove') {
350 const replace = {$commit: <CommitTitle commit={op.commit} />};
351 return <T replace={replace}>moving a diff chunk to $commit</T>;
352 }
353 return <T>unknown</T>;
354}
355
356/** Used in undo tooltip. Styled. */
357function CommitTitle({commit}: {commit: CommitState}): React.ReactElement {
358 if (commit.originalNodes.contains(WDIR_NODE)) {
359 return <T>the working copy</T>;
360 }
361 return <span className="commit-title">{commit.text.split('\n', 1).at(0)}</span>;
362}
363