14.8 KB371 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 {DagCommitInfo} from './dag/dag';
9import type {Hash} from './types';
10
11import {Button} from 'isl-components/Button';
12import {Icon} from 'isl-components/Icon';
13import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
14import {useAtom, useAtomValue} from 'jotai';
15import {type ContextMenuItem, useContextMenu} from 'shared/ContextMenu';
16import {CleanupButton, isStackEligibleForCleanup} from './Cleanup';
17import {Row} from './ComponentUtils';
18import {shouldShowSubmitStackConfirmation, useShowConfirmSubmitStack} from './ConfirmSubmitStack';
19import {HighlightCommitsWhileHovering} from './HighlightedCommits';
20import {OperationDisabledButton} from './OperationDisabledButton';
21import {RebaseOrphanedStackButton} from './RebaseOntoSuccessor';
22import {showSuggestedRebaseForStack, SuggestedRebaseButton} from './SuggestedRebase';
23import {allDiffSummaries, codeReviewProvider} from './codeReview/CodeReviewInfo';
24import {SyncStatus, syncStatusAtom} from './codeReview/syncStatus';
25import {T, t} from './i18n';
26import {IconStack} from './icons/IconStack';
27import {useRunOperation} from './operationsState';
28import {useUncommittedSelection} from './partialSelection';
29import {dagWithPreviews} from './previews';
30import {latestUncommittedChangesData} from './serverAPIState';
31import {useConfirmUnsavedEditsBeforeSplit} from './stackEdit/ui/ConfirmUnsavedEditsBeforeSplit';
32import {StackEditIcon} from './stackEdit/ui/StackEditIcon';
33import {editingStackIntentionHashes, loadingStackState} from './stackEdit/ui/stackEditState';
34import {succeedableRevset} from './types';
35
36import './StackActions.css';
37
38/**
39 * Actions at the bottom of a stack of commits that acts on the whole stack,
40 * like submitting, hiding, editing the stack.
41 */
42export function StackActions({hash}: {hash: Hash}): React.ReactElement | null {
43 const reviewProvider = useAtomValue(codeReviewProvider);
44 const diffMap = useAtomValue(allDiffSummaries);
45 const stackHashes = useAtomValue(editingStackIntentionHashes)[1];
46 const loadingState = useAtomValue(loadingStackState);
47 const suggestedRebase = useAtomValue(showSuggestedRebaseForStack(hash));
48 const dag = useAtomValue(dagWithPreviews);
49 const runOperation = useRunOperation();
50 const syncStatusMap = useAtomValue(syncStatusAtom);
51
52 // buttons at the bottom of the stack
53 const actions = [];
54 // additional actions hidden behind [...] menu.
55 // Non-empty only when actions is non-empty.
56 const moreActions: Array<ContextMenuItem> = [];
57 const confirmShouldSubmit = useShowConfirmSubmitStack();
58 const contextMenu = useContextMenu(() => moreActions);
59
60 const isStackEditingActivated =
61 stackHashes.size > 0 &&
62 loadingState.state === 'hasValue' &&
63 dag
64 .descendants(hash)
65 .toSeq()
66 .some(h => stackHashes.has(h));
67
68 const showCleanupButton =
69 reviewProvider == null || diffMap?.value == null
70 ? false
71 : isStackEligibleForCleanup(hash, dag, diffMap.value, reviewProvider);
72
73 const info = dag.get(hash);
74
75 if (info == null) {
76 return null;
77 }
78
79 if (reviewProvider !== null && !isStackEditingActivated) {
80 const reviewActions =
81 diffMap.value == null
82 ? {}
83 : reviewProvider?.getSupportedStackActions(hash, dag, diffMap.value);
84 const resubmittableStack = reviewActions?.resubmittableStack;
85 const submittableStack = reviewActions?.submittableStack;
86 const MIN_STACK_SIZE_TO_SUGGEST_SUBMIT = 2; // don't show "submit stack" on single commits... they're not really "stacks".
87
88 const locallyChangedCommits = resubmittableStack?.filter(
89 c => syncStatusMap?.get(c.hash) === SyncStatus.LocalIsNewer,
90 );
91
92 const willShowConfirmationModal = shouldShowSubmitStackConfirmation();
93
94 // any existing diffs -> show resubmit stack,
95 if (
96 resubmittableStack != null &&
97 resubmittableStack.length >= MIN_STACK_SIZE_TO_SUGGEST_SUBMIT
98 ) {
99 const TooltipContent = () => {
100 return (
101 <div className="resubmit-stack-tooltip">
102 <T replace={{$cmd: reviewProvider?.submitCommandName()}}>
103 Submit new version of commits in this stack for review with $cmd.
104 </T>
105 {willShowConfirmationModal && (
106 <div>
107 <T>Draft mode and update message can be configured before submitting.</T>
108 </div>
109 )}
110 {locallyChangedCommits != null && locallyChangedCommits.length > 0 && (
111 <div>
112 <Icon icon="circle-filled" color={'blue'} />
113 <T count={locallyChangedCommits.length}>someCommitsUpdatedLocallyAddendum</T>
114 </div>
115 )}
116 </div>
117 );
118 };
119 let icon = <Icon icon="cloud-upload" slot="start" />;
120 if (locallyChangedCommits != null && locallyChangedCommits.length > 0) {
121 icon = (
122 <IconStack slot="start">
123 <Icon icon="cloud-upload" />
124 <Icon icon="circle-large-filled" color={'blue'} />
125 </IconStack>
126 );
127 }
128 actions.push(
129 <Tooltip key="resubmit-stack" component={() => <TooltipContent />} placement="bottom">
130 <HighlightCommitsWhileHovering toHighlight={resubmittableStack}>
131 <OperationDisabledButton
132 // Use the diffId in the key so that only this "resubmit stack" button shows the spinner.
133 contextKey={`resubmit-stack-on-${info.diffId}`}
134 kind="icon"
135 icon={icon}
136 runOperation={async () => {
137 const confirmation = await confirmShouldSubmit('resubmit', resubmittableStack);
138 if (!confirmation) {
139 return [];
140 }
141 return reviewProvider.submitOperation(resubmittableStack, {
142 draft: confirmation.submitAsDraft,
143 updateMessage: confirmation.updateMessage,
144 publishWhenReady: confirmation.publishWhenReady,
145 });
146 }}>
147 <T>Resubmit stack</T>
148 </OperationDisabledButton>
149 </HighlightCommitsWhileHovering>
150 </Tooltip>,
151 );
152 // any non-submitted diffs -> "submit all commits this stack" in hidden group
153 if (
154 submittableStack != null &&
155 submittableStack.length > 0 &&
156 submittableStack.length > resubmittableStack.length
157 ) {
158 moreActions.push({
159 label: (
160 <HighlightCommitsWhileHovering key="submit-entire-stack" toHighlight={submittableStack}>
161 <Row>
162 <Icon icon="cloud-upload" slot="start" />
163 <T>Submit entire stack</T>
164 </Row>
165 </HighlightCommitsWhileHovering>
166 ),
167 onClick: async () => {
168 const confirmation = await confirmShouldSubmit('submit-all', submittableStack);
169 if (!confirmation) {
170 return [];
171 }
172 runOperation(
173 reviewProvider.submitOperation(submittableStack, {
174 draft: confirmation.submitAsDraft,
175 updateMessage: confirmation.updateMessage,
176 publishWhenReady: confirmation.publishWhenReady,
177 }),
178 );
179 },
180 });
181 }
182 // NO non-submitted diffs -> nothing in hidden group
183 } else if (
184 submittableStack != null &&
185 submittableStack.length >= MIN_STACK_SIZE_TO_SUGGEST_SUBMIT
186 ) {
187 // We need to associate this operation with the stack we're submitting,
188 // but during submitting, we'll amend the original commit, so hash is not accurate.
189 // Parent is close, but if you had multiple stacks rebased to the same public commit,
190 // all those stacks would render the same key and show the same spinner.
191 // So parent hash + title heuristic lets us almost always show the spinner for only this stack.
192 const contextKey = `submit-stack-on-${info.parents.at(0)}-${info.title.replace(/ /g, '_')}`;
193
194 const tooltip = t(
195 willShowConfirmationModal
196 ? 'Submit commits in this stack for review with $cmd.\n\nDraft mode and update message can be configured before submitting.'
197 : 'Submit commits in this stack for review with $cmd.',
198 {replace: {$cmd: reviewProvider?.submitCommandName()}},
199 );
200 // NO existing diffs -> show submit stack ()
201 actions.push(
202 <Tooltip key="submit-stack" title={tooltip} placement="bottom">
203 <HighlightCommitsWhileHovering toHighlight={submittableStack}>
204 <OperationDisabledButton
205 contextKey={contextKey}
206 kind="icon"
207 icon={<Icon icon="cloud-upload" slot="start" />}
208 runOperation={async () => {
209 const allCommits = submittableStack;
210 const confirmation = await confirmShouldSubmit('submit', allCommits);
211 if (!confirmation) {
212 return [];
213 }
214 return reviewProvider.submitOperation(submittableStack, {
215 draft: confirmation.submitAsDraft,
216 updateMessage: confirmation.updateMessage,
217 publishWhenReady: confirmation.publishWhenReady,
218 });
219 }}>
220 <T>Submit stack</T>
221 </OperationDisabledButton>
222 </HighlightCommitsWhileHovering>
223 </Tooltip>,
224 );
225 }
226 }
227
228 const hasChildren = dag.childHashes(hash).size > 0;
229 if (hasChildren) {
230 actions.push(<StackEditButton key="edit-stack" info={info} />);
231 }
232
233 if (showCleanupButton) {
234 actions.push(<CleanupButton key="cleanup" commit={info} hasChildren={hasChildren} />);
235 // cleanup button implies no need to rebase this stack
236 } else if (suggestedRebase) {
237 // FIXME: Support optimistic commits, requires CommitInfo instead of just Hash
238 actions.push(<SuggestedRebaseButton key="suggested-rebase" source={succeedableRevset(hash)} />);
239 }
240
241 actions.push(<RebaseOrphanedStackButton key="rebase-orphaned" hash={hash} />);
242
243 if (actions.length === 0) {
244 return null;
245 }
246 const moreActionsButton =
247 moreActions.length === 0 ? null : (
248 <Button key="more-actions" icon onClick={contextMenu}>
249 <Icon icon="ellipsis" />
250 </Button>
251 );
252 return (
253 <div className="commit-tree-stack-actions" data-testid="commit-tree-stack-actions">
254 {actions}
255 {moreActionsButton}
256 </div>
257 );
258}
259
260function StackEditButton({info}: {info: DagCommitInfo}): React.ReactElement | null {
261 const uncommitted = useAtomValue(latestUncommittedChangesData);
262 const dag = useAtomValue(dagWithPreviews);
263 const [[, stackHashes], setStackIntentionHashes] = useAtom(editingStackIntentionHashes);
264 const loadingState = useAtomValue(loadingStackState);
265 const confirmUnsavedEditsBeforeSplit = useConfirmUnsavedEditsBeforeSplit();
266
267 const set = dag.nonObsolete(dag.descendants(info.hash));
268 if (set.size <= 1) {
269 return null;
270 }
271
272 const stackCommits = dag.getBatch(set.toArray());
273 const isEditing = stackHashes.size > 0 && set.toSeq().some(h => stackHashes.has(h));
274
275 const isPreview = info.previewType != null;
276 const isLoading = isEditing && loadingState.state === 'loading';
277 const isError = isEditing && loadingState.state === 'hasError';
278 const isLinear =
279 dag.merge(set).size === 0 && dag.heads(set).size === 1 && dag.roots(set).size === 1;
280 const isDirty = stackCommits.some(c => c.isDot) && uncommitted.files.length > 0;
281 const hasPublic = stackCommits.some(c => c.phase === 'public');
282 const disabled = isDirty || !isLinear || isLoading || isError || isPreview || hasPublic;
283 const title = isError
284 ? t(`Failed to load stack: ${loadingState.error}`)
285 : isLoading
286 ? loadingState.exportedStack === undefined
287 ? t('Reading stack content')
288 : t('Analyzing stack content')
289 : isDirty
290 ? t(
291 'Cannot edit stack when there are uncommitted changes.\nCommit or amend your changes first.',
292 )
293 : isPreview
294 ? t('Cannot edit pending changes')
295 : hasPublic
296 ? t('Cannot edit public commits')
297 : isLinear
298 ? t('Reorder, fold, or drop commits')
299 : t('Cannot edit non-linear stack');
300 const highlight = disabled ? [] : stackCommits;
301 const tooltipDelay = disabled && !isLoading ? undefined : DOCUMENTATION_DELAY;
302 const icon = isLoading ? <Icon icon="loading" slot="start" /> : <StackEditIcon slot="start" />;
303
304 return (
305 <HighlightCommitsWhileHovering key="submit-stack" toHighlight={highlight}>
306 <Tooltip title={title} delayMs={tooltipDelay} placement="bottom">
307 <Button
308 className={`edit-stack-button${disabled ? ' disabled' : ''}`}
309 disabled={disabled}
310 icon
311 onClick={async () => {
312 if (!(await confirmUnsavedEditsBeforeSplit(stackCommits, 'edit_stack'))) {
313 return;
314 }
315 setStackIntentionHashes(['general', new Set<Hash>(set)]);
316 }}>
317 {icon}
318 <T>Edit stack</T>
319 </Button>
320 </Tooltip>
321 </HighlightCommitsWhileHovering>
322 );
323}
324
325export function AbsorbButton() {
326 const selection = useUncommittedSelection();
327 const dag = useAtomValue(dagWithPreviews);
328 const [[, stackHashes], setStackIntentionHashes] = useAtom(editingStackIntentionHashes);
329
330 let disableReason = null;
331 const dot = dag.resolve('.');
332 const stack = dag.ancestors(dot == null ? [] : [dot.hash], {within: dag.draft()});
333 if (dot == null) {
334 disableReason = t('Absorb requires a working copy');
335 } else if (stackHashes.size > 0) {
336 disableReason = t('Cannot initialize absorb while editing a stack');
337 } else if (dot.phase === 'public') {
338 disableReason = t('Absorb only works for draft commits');
339 } else if (stack.size <= 1) {
340 disableReason = t('Absorb works for a stack of more than one commit. Use "Amend" instead.');
341 } else if (dag.merge(stack).size > 0) {
342 disableReason = t('Absorb does not work for merge commits');
343 } else if (selection.isNothingSelected()) {
344 disableReason = t('No files are selected. Absorb requires at least one file.');
345 }
346
347 const tooltipText =
348 disableReason ??
349 t(
350 'Absorb changes into the stack. Amend each change to the commit that introduced the surrounding line changes. You can customize the amend destination in a dialog.',
351 );
352
353 return (
354 <Tooltip title={tooltipText}>
355 <Button
356 icon
357 key="absorb"
358 disabled={disableReason != null}
359 onClick={() => {
360 // Since "wdir()" might race with the "dot" and "stack" here,
361 // use ".%public()", avoid "stack" to avoid inconsistent state
362 // (ex. "wdir()" is no longer the "stack" top).
363 setStackIntentionHashes(['absorb', new Set<string>(['wdir()', '(.%public())'])]);
364 }}>
365 <Icon slot="start" icon="replace-all" />
366 <T>Absorb</T>
367 </Button>
368 </Tooltip>
369 );
370}
371