addons/isl/src/StackActions.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 {DagCommitInfo} from './dag/dag';
b69ab319import type {Hash} from './types';
b69ab3110
b69ab3111import {Button} from 'isl-components/Button';
b69ab3112import {Icon} from 'isl-components/Icon';
b69ab3113import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
b69ab3114import {useAtom, useAtomValue} from 'jotai';
b69ab3115import {type ContextMenuItem, useContextMenu} from 'shared/ContextMenu';
b69ab3116import {CleanupButton, isStackEligibleForCleanup} from './Cleanup';
b69ab3117import {Row} from './ComponentUtils';
b69ab3118import {shouldShowSubmitStackConfirmation, useShowConfirmSubmitStack} from './ConfirmSubmitStack';
b69ab3119import {HighlightCommitsWhileHovering} from './HighlightedCommits';
b69ab3120import {OperationDisabledButton} from './OperationDisabledButton';
b69ab3121import {RebaseOrphanedStackButton} from './RebaseOntoSuccessor';
b69ab3122import {showSuggestedRebaseForStack, SuggestedRebaseButton} from './SuggestedRebase';
b69ab3123import {allDiffSummaries, codeReviewProvider} from './codeReview/CodeReviewInfo';
b69ab3124import {SyncStatus, syncStatusAtom} from './codeReview/syncStatus';
b69ab3125import {T, t} from './i18n';
b69ab3126import {IconStack} from './icons/IconStack';
b69ab3127import {useRunOperation} from './operationsState';
b69ab3128import {useUncommittedSelection} from './partialSelection';
b69ab3129import {dagWithPreviews} from './previews';
b69ab3130import {latestUncommittedChangesData} from './serverAPIState';
b69ab3131import {useConfirmUnsavedEditsBeforeSplit} from './stackEdit/ui/ConfirmUnsavedEditsBeforeSplit';
b69ab3132import {StackEditIcon} from './stackEdit/ui/StackEditIcon';
b69ab3133import {editingStackIntentionHashes, loadingStackState} from './stackEdit/ui/stackEditState';
b69ab3134import {succeedableRevset} from './types';
b69ab3135
b69ab3136import './StackActions.css';
b69ab3137
b69ab3138/**
b69ab3139 * Actions at the bottom of a stack of commits that acts on the whole stack,
b69ab3140 * like submitting, hiding, editing the stack.
b69ab3141 */
b69ab3142export function StackActions({hash}: {hash: Hash}): React.ReactElement | null {
b69ab3143 const reviewProvider = useAtomValue(codeReviewProvider);
b69ab3144 const diffMap = useAtomValue(allDiffSummaries);
b69ab3145 const stackHashes = useAtomValue(editingStackIntentionHashes)[1];
b69ab3146 const loadingState = useAtomValue(loadingStackState);
b69ab3147 const suggestedRebase = useAtomValue(showSuggestedRebaseForStack(hash));
b69ab3148 const dag = useAtomValue(dagWithPreviews);
b69ab3149 const runOperation = useRunOperation();
b69ab3150 const syncStatusMap = useAtomValue(syncStatusAtom);
b69ab3151
b69ab3152 // buttons at the bottom of the stack
b69ab3153 const actions = [];
b69ab3154 // additional actions hidden behind [...] menu.
b69ab3155 // Non-empty only when actions is non-empty.
b69ab3156 const moreActions: Array<ContextMenuItem> = [];
b69ab3157 const confirmShouldSubmit = useShowConfirmSubmitStack();
b69ab3158 const contextMenu = useContextMenu(() => moreActions);
b69ab3159
b69ab3160 const isStackEditingActivated =
b69ab3161 stackHashes.size > 0 &&
b69ab3162 loadingState.state === 'hasValue' &&
b69ab3163 dag
b69ab3164 .descendants(hash)
b69ab3165 .toSeq()
b69ab3166 .some(h => stackHashes.has(h));
b69ab3167
b69ab3168 const showCleanupButton =
b69ab3169 reviewProvider == null || diffMap?.value == null
b69ab3170 ? false
b69ab3171 : isStackEligibleForCleanup(hash, dag, diffMap.value, reviewProvider);
b69ab3172
b69ab3173 const info = dag.get(hash);
b69ab3174
b69ab3175 if (info == null) {
b69ab3176 return null;
b69ab3177 }
b69ab3178
b69ab3179 if (reviewProvider !== null && !isStackEditingActivated) {
b69ab3180 const reviewActions =
b69ab3181 diffMap.value == null
b69ab3182 ? {}
b69ab3183 : reviewProvider?.getSupportedStackActions(hash, dag, diffMap.value);
b69ab3184 const resubmittableStack = reviewActions?.resubmittableStack;
b69ab3185 const submittableStack = reviewActions?.submittableStack;
b69ab3186 const MIN_STACK_SIZE_TO_SUGGEST_SUBMIT = 2; // don't show "submit stack" on single commits... they're not really "stacks".
b69ab3187
b69ab3188 const locallyChangedCommits = resubmittableStack?.filter(
b69ab3189 c => syncStatusMap?.get(c.hash) === SyncStatus.LocalIsNewer,
b69ab3190 );
b69ab3191
b69ab3192 const willShowConfirmationModal = shouldShowSubmitStackConfirmation();
b69ab3193
b69ab3194 // any existing diffs -> show resubmit stack,
b69ab3195 if (
b69ab3196 resubmittableStack != null &&
b69ab3197 resubmittableStack.length >= MIN_STACK_SIZE_TO_SUGGEST_SUBMIT
b69ab3198 ) {
b69ab3199 const TooltipContent = () => {
b69ab31100 return (
b69ab31101 <div className="resubmit-stack-tooltip">
b69ab31102 <T replace={{$cmd: reviewProvider?.submitCommandName()}}>
b69ab31103 Submit new version of commits in this stack for review with $cmd.
b69ab31104 </T>
b69ab31105 {willShowConfirmationModal && (
b69ab31106 <div>
b69ab31107 <T>Draft mode and update message can be configured before submitting.</T>
b69ab31108 </div>
b69ab31109 )}
b69ab31110 {locallyChangedCommits != null && locallyChangedCommits.length > 0 && (
b69ab31111 <div>
b69ab31112 <Icon icon="circle-filled" color={'blue'} />
b69ab31113 <T count={locallyChangedCommits.length}>someCommitsUpdatedLocallyAddendum</T>
b69ab31114 </div>
b69ab31115 )}
b69ab31116 </div>
b69ab31117 );
b69ab31118 };
b69ab31119 let icon = <Icon icon="cloud-upload" slot="start" />;
b69ab31120 if (locallyChangedCommits != null && locallyChangedCommits.length > 0) {
b69ab31121 icon = (
b69ab31122 <IconStack slot="start">
b69ab31123 <Icon icon="cloud-upload" />
b69ab31124 <Icon icon="circle-large-filled" color={'blue'} />
b69ab31125 </IconStack>
b69ab31126 );
b69ab31127 }
b69ab31128 actions.push(
b69ab31129 <Tooltip key="resubmit-stack" component={() => <TooltipContent />} placement="bottom">
b69ab31130 <HighlightCommitsWhileHovering toHighlight={resubmittableStack}>
b69ab31131 <OperationDisabledButton
b69ab31132 // Use the diffId in the key so that only this "resubmit stack" button shows the spinner.
b69ab31133 contextKey={`resubmit-stack-on-${info.diffId}`}
b69ab31134 kind="icon"
b69ab31135 icon={icon}
b69ab31136 runOperation={async () => {
b69ab31137 const confirmation = await confirmShouldSubmit('resubmit', resubmittableStack);
b69ab31138 if (!confirmation) {
b69ab31139 return [];
b69ab31140 }
b69ab31141 return reviewProvider.submitOperation(resubmittableStack, {
b69ab31142 draft: confirmation.submitAsDraft,
b69ab31143 updateMessage: confirmation.updateMessage,
b69ab31144 publishWhenReady: confirmation.publishWhenReady,
b69ab31145 });
b69ab31146 }}>
b69ab31147 <T>Resubmit stack</T>
b69ab31148 </OperationDisabledButton>
b69ab31149 </HighlightCommitsWhileHovering>
b69ab31150 </Tooltip>,
b69ab31151 );
b69ab31152 // any non-submitted diffs -> "submit all commits this stack" in hidden group
b69ab31153 if (
b69ab31154 submittableStack != null &&
b69ab31155 submittableStack.length > 0 &&
b69ab31156 submittableStack.length > resubmittableStack.length
b69ab31157 ) {
b69ab31158 moreActions.push({
b69ab31159 label: (
b69ab31160 <HighlightCommitsWhileHovering key="submit-entire-stack" toHighlight={submittableStack}>
b69ab31161 <Row>
b69ab31162 <Icon icon="cloud-upload" slot="start" />
b69ab31163 <T>Submit entire stack</T>
b69ab31164 </Row>
b69ab31165 </HighlightCommitsWhileHovering>
b69ab31166 ),
b69ab31167 onClick: async () => {
b69ab31168 const confirmation = await confirmShouldSubmit('submit-all', submittableStack);
b69ab31169 if (!confirmation) {
b69ab31170 return [];
b69ab31171 }
b69ab31172 runOperation(
b69ab31173 reviewProvider.submitOperation(submittableStack, {
b69ab31174 draft: confirmation.submitAsDraft,
b69ab31175 updateMessage: confirmation.updateMessage,
b69ab31176 publishWhenReady: confirmation.publishWhenReady,
b69ab31177 }),
b69ab31178 );
b69ab31179 },
b69ab31180 });
b69ab31181 }
b69ab31182 // NO non-submitted diffs -> nothing in hidden group
b69ab31183 } else if (
b69ab31184 submittableStack != null &&
b69ab31185 submittableStack.length >= MIN_STACK_SIZE_TO_SUGGEST_SUBMIT
b69ab31186 ) {
b69ab31187 // We need to associate this operation with the stack we're submitting,
b69ab31188 // but during submitting, we'll amend the original commit, so hash is not accurate.
b69ab31189 // Parent is close, but if you had multiple stacks rebased to the same public commit,
b69ab31190 // all those stacks would render the same key and show the same spinner.
b69ab31191 // So parent hash + title heuristic lets us almost always show the spinner for only this stack.
b69ab31192 const contextKey = `submit-stack-on-${info.parents.at(0)}-${info.title.replace(/ /g, '_')}`;
b69ab31193
b69ab31194 const tooltip = t(
b69ab31195 willShowConfirmationModal
b69ab31196 ? 'Submit commits in this stack for review with $cmd.\n\nDraft mode and update message can be configured before submitting.'
b69ab31197 : 'Submit commits in this stack for review with $cmd.',
b69ab31198 {replace: {$cmd: reviewProvider?.submitCommandName()}},
b69ab31199 );
b69ab31200 // NO existing diffs -> show submit stack ()
b69ab31201 actions.push(
b69ab31202 <Tooltip key="submit-stack" title={tooltip} placement="bottom">
b69ab31203 <HighlightCommitsWhileHovering toHighlight={submittableStack}>
b69ab31204 <OperationDisabledButton
b69ab31205 contextKey={contextKey}
b69ab31206 kind="icon"
b69ab31207 icon={<Icon icon="cloud-upload" slot="start" />}
b69ab31208 runOperation={async () => {
b69ab31209 const allCommits = submittableStack;
b69ab31210 const confirmation = await confirmShouldSubmit('submit', allCommits);
b69ab31211 if (!confirmation) {
b69ab31212 return [];
b69ab31213 }
b69ab31214 return reviewProvider.submitOperation(submittableStack, {
b69ab31215 draft: confirmation.submitAsDraft,
b69ab31216 updateMessage: confirmation.updateMessage,
b69ab31217 publishWhenReady: confirmation.publishWhenReady,
b69ab31218 });
b69ab31219 }}>
b69ab31220 <T>Submit stack</T>
b69ab31221 </OperationDisabledButton>
b69ab31222 </HighlightCommitsWhileHovering>
b69ab31223 </Tooltip>,
b69ab31224 );
b69ab31225 }
b69ab31226 }
b69ab31227
b69ab31228 const hasChildren = dag.childHashes(hash).size > 0;
b69ab31229 if (hasChildren) {
b69ab31230 actions.push(<StackEditButton key="edit-stack" info={info} />);
b69ab31231 }
b69ab31232
b69ab31233 if (showCleanupButton) {
b69ab31234 actions.push(<CleanupButton key="cleanup" commit={info} hasChildren={hasChildren} />);
b69ab31235 // cleanup button implies no need to rebase this stack
b69ab31236 } else if (suggestedRebase) {
b69ab31237 // FIXME: Support optimistic commits, requires CommitInfo instead of just Hash
b69ab31238 actions.push(<SuggestedRebaseButton key="suggested-rebase" source={succeedableRevset(hash)} />);
b69ab31239 }
b69ab31240
b69ab31241 actions.push(<RebaseOrphanedStackButton key="rebase-orphaned" hash={hash} />);
b69ab31242
b69ab31243 if (actions.length === 0) {
b69ab31244 return null;
b69ab31245 }
b69ab31246 const moreActionsButton =
b69ab31247 moreActions.length === 0 ? null : (
b69ab31248 <Button key="more-actions" icon onClick={contextMenu}>
b69ab31249 <Icon icon="ellipsis" />
b69ab31250 </Button>
b69ab31251 );
b69ab31252 return (
b69ab31253 <div className="commit-tree-stack-actions" data-testid="commit-tree-stack-actions">
b69ab31254 {actions}
b69ab31255 {moreActionsButton}
b69ab31256 </div>
b69ab31257 );
b69ab31258}
b69ab31259
b69ab31260function StackEditButton({info}: {info: DagCommitInfo}): React.ReactElement | null {
b69ab31261 const uncommitted = useAtomValue(latestUncommittedChangesData);
b69ab31262 const dag = useAtomValue(dagWithPreviews);
b69ab31263 const [[, stackHashes], setStackIntentionHashes] = useAtom(editingStackIntentionHashes);
b69ab31264 const loadingState = useAtomValue(loadingStackState);
b69ab31265 const confirmUnsavedEditsBeforeSplit = useConfirmUnsavedEditsBeforeSplit();
b69ab31266
b69ab31267 const set = dag.nonObsolete(dag.descendants(info.hash));
b69ab31268 if (set.size <= 1) {
b69ab31269 return null;
b69ab31270 }
b69ab31271
b69ab31272 const stackCommits = dag.getBatch(set.toArray());
b69ab31273 const isEditing = stackHashes.size > 0 && set.toSeq().some(h => stackHashes.has(h));
b69ab31274
b69ab31275 const isPreview = info.previewType != null;
b69ab31276 const isLoading = isEditing && loadingState.state === 'loading';
b69ab31277 const isError = isEditing && loadingState.state === 'hasError';
b69ab31278 const isLinear =
b69ab31279 dag.merge(set).size === 0 && dag.heads(set).size === 1 && dag.roots(set).size === 1;
b69ab31280 const isDirty = stackCommits.some(c => c.isDot) && uncommitted.files.length > 0;
b69ab31281 const hasPublic = stackCommits.some(c => c.phase === 'public');
b69ab31282 const disabled = isDirty || !isLinear || isLoading || isError || isPreview || hasPublic;
b69ab31283 const title = isError
b69ab31284 ? t(`Failed to load stack: ${loadingState.error}`)
b69ab31285 : isLoading
b69ab31286 ? loadingState.exportedStack === undefined
b69ab31287 ? t('Reading stack content')
b69ab31288 : t('Analyzing stack content')
b69ab31289 : isDirty
b69ab31290 ? t(
b69ab31291 'Cannot edit stack when there are uncommitted changes.\nCommit or amend your changes first.',
b69ab31292 )
b69ab31293 : isPreview
b69ab31294 ? t('Cannot edit pending changes')
b69ab31295 : hasPublic
b69ab31296 ? t('Cannot edit public commits')
b69ab31297 : isLinear
b69ab31298 ? t('Reorder, fold, or drop commits')
b69ab31299 : t('Cannot edit non-linear stack');
b69ab31300 const highlight = disabled ? [] : stackCommits;
b69ab31301 const tooltipDelay = disabled && !isLoading ? undefined : DOCUMENTATION_DELAY;
b69ab31302 const icon = isLoading ? <Icon icon="loading" slot="start" /> : <StackEditIcon slot="start" />;
b69ab31303
b69ab31304 return (
b69ab31305 <HighlightCommitsWhileHovering key="submit-stack" toHighlight={highlight}>
b69ab31306 <Tooltip title={title} delayMs={tooltipDelay} placement="bottom">
b69ab31307 <Button
b69ab31308 className={`edit-stack-button${disabled ? ' disabled' : ''}`}
b69ab31309 disabled={disabled}
b69ab31310 icon
b69ab31311 onClick={async () => {
b69ab31312 if (!(await confirmUnsavedEditsBeforeSplit(stackCommits, 'edit_stack'))) {
b69ab31313 return;
b69ab31314 }
b69ab31315 setStackIntentionHashes(['general', new Set<Hash>(set)]);
b69ab31316 }}>
b69ab31317 {icon}
b69ab31318 <T>Edit stack</T>
b69ab31319 </Button>
b69ab31320 </Tooltip>
b69ab31321 </HighlightCommitsWhileHovering>
b69ab31322 );
b69ab31323}
b69ab31324
b69ab31325export function AbsorbButton() {
b69ab31326 const selection = useUncommittedSelection();
b69ab31327 const dag = useAtomValue(dagWithPreviews);
b69ab31328 const [[, stackHashes], setStackIntentionHashes] = useAtom(editingStackIntentionHashes);
b69ab31329
b69ab31330 let disableReason = null;
b69ab31331 const dot = dag.resolve('.');
b69ab31332 const stack = dag.ancestors(dot == null ? [] : [dot.hash], {within: dag.draft()});
b69ab31333 if (dot == null) {
b69ab31334 disableReason = t('Absorb requires a working copy');
b69ab31335 } else if (stackHashes.size > 0) {
b69ab31336 disableReason = t('Cannot initialize absorb while editing a stack');
b69ab31337 } else if (dot.phase === 'public') {
b69ab31338 disableReason = t('Absorb only works for draft commits');
b69ab31339 } else if (stack.size <= 1) {
b69ab31340 disableReason = t('Absorb works for a stack of more than one commit. Use "Amend" instead.');
b69ab31341 } else if (dag.merge(stack).size > 0) {
b69ab31342 disableReason = t('Absorb does not work for merge commits');
b69ab31343 } else if (selection.isNothingSelected()) {
b69ab31344 disableReason = t('No files are selected. Absorb requires at least one file.');
b69ab31345 }
b69ab31346
b69ab31347 const tooltipText =
b69ab31348 disableReason ??
b69ab31349 t(
b69ab31350 '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.',
b69ab31351 );
b69ab31352
b69ab31353 return (
b69ab31354 <Tooltip title={tooltipText}>
b69ab31355 <Button
b69ab31356 icon
b69ab31357 key="absorb"
b69ab31358 disabled={disableReason != null}
b69ab31359 onClick={() => {
b69ab31360 // Since "wdir()" might race with the "dot" and "stack" here,
b69ab31361 // use ".%public()", avoid "stack" to avoid inconsistent state
b69ab31362 // (ex. "wdir()" is no longer the "stack" top).
b69ab31363 setStackIntentionHashes(['absorb', new Set<string>(['wdir()', '(.%public())'])]);
b69ab31364 }}>
b69ab31365 <Icon slot="start" icon="replace-all" />
b69ab31366 <T>Absorb</T>
b69ab31367 </Button>
b69ab31368 </Tooltip>
b69ab31369 );
b69ab31370}