| 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 | |
| 8 | import type {Hash} from '../../types'; |
| 9 | |
| 10 | import {Button} from 'isl-components/Button'; |
| 11 | import {Icon} from 'isl-components/Icon'; |
| 12 | import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip'; |
| 13 | import {useAtom, useAtomValue} from 'jotai'; |
| 14 | import {useCallback} from 'react'; |
| 15 | import serverAPI from '../../ClientToServerAPI'; |
| 16 | import { |
| 17 | editedCommitMessages, |
| 18 | getDefaultEditedCommitMessage, |
| 19 | } from '../../CommitInfoView/CommitInfoState'; |
| 20 | import {Internal} from '../../Internal'; |
| 21 | import {tracker} from '../../analytics'; |
| 22 | import {useFeatureFlagSync} from '../../featureFlags'; |
| 23 | import {T, t} from '../../i18n'; |
| 24 | import {writeAtom} from '../../jotaiUtils'; |
| 25 | import {ImportStackOperation} from '../../operations/ImportStackOperation'; |
| 26 | import {RebaseOperation} from '../../operations/RebaseOperation'; |
| 27 | import {useRunOperation} from '../../operationsState'; |
| 28 | import {latestDag, latestHeadCommit, repositoryInfo} from '../../serverAPIState'; |
| 29 | import {exactRevset, succeedableRevset} from '../../types'; |
| 30 | import {UndoDescription} from './StackEditSubTree'; |
| 31 | import { |
| 32 | bumpStackEditMetric, |
| 33 | editingStackIntentionHashes, |
| 34 | findStartEndRevs, |
| 35 | sendStackEditMetrics, |
| 36 | useStackEditState, |
| 37 | } from './stackEditState'; |
| 38 | |
| 39 | import './StackEditSubTree.css'; |
| 40 | |
| 41 | export function StackEditConfirmButtons(): React.ReactElement { |
| 42 | const [[stackIntention], setStackIntentionHashes] = useAtom(editingStackIntentionHashes); |
| 43 | const originalHead = useAtomValue(latestHeadCommit); |
| 44 | const dag = useAtomValue(latestDag); |
| 45 | const runOperation = useRunOperation(); |
| 46 | const stackEdit = useStackEditState(); |
| 47 | |
| 48 | const canUndo = stackEdit.canUndo(); |
| 49 | const canRedo = stackEdit.canRedo(); |
| 50 | |
| 51 | const handleUndo = () => { |
| 52 | stackEdit.undo(); |
| 53 | bumpStackEditMetric('undo'); |
| 54 | }; |
| 55 | |
| 56 | const handleRedo = () => { |
| 57 | stackEdit.redo(); |
| 58 | bumpStackEditMetric('redo'); |
| 59 | }; |
| 60 | |
| 61 | /** |
| 62 | * Invalidate any unsaved edited commit messages for the original commits, |
| 63 | * to prevent detected successions from persisting that state. |
| 64 | * Splitting can cause the top of the stack to be an unexpected |
| 65 | * successor, leading to wrong commit messages. |
| 66 | * We already showed a confirm modal to "apply" your edits to split, |
| 67 | * but we actually need to delete them now that we're really |
| 68 | * doing the split/edit stack. |
| 69 | */ |
| 70 | const invalidateUnsavedCommitMessages = useCallback((commits: Array<Hash>) => { |
| 71 | for (const hash of commits) { |
| 72 | writeAtom(editedCommitMessages(hash), getDefaultEditedCommitMessage()); |
| 73 | } |
| 74 | }, []); |
| 75 | |
| 76 | const handleSaveChanges = () => { |
| 77 | const originalHash = originalHead?.hash; |
| 78 | const stack = stackEdit.commitStack.applyAbsorbEdits(); |
| 79 | const isAbsorb = stackEdit.intention === 'absorb'; |
| 80 | const importStack = stack.calculateImportStack({ |
| 81 | goto: originalHash, |
| 82 | rewriteDate: Date.now() / 1000, |
| 83 | // Do not write anything to the working copy, for absorb (esp. with partial selection) |
| 84 | skipWdir: isAbsorb, |
| 85 | // Also, preserve dirty files. So if an absorb edit is left "unabsorbed" in the "wdir()", |
| 86 | // it will be preserved without being dropped. |
| 87 | preserveDirtyFiles: isAbsorb, |
| 88 | }); |
| 89 | const op = new ImportStackOperation(importStack, stack.originalStack); |
| 90 | runOperation(op); |
| 91 | sendStackEditMetrics(stackEdit, true); |
| 92 | |
| 93 | invalidateUnsavedCommitMessages(stack.originalStack.map(c => c.node)); |
| 94 | |
| 95 | // For standalone split, follow-up with a rebase. |
| 96 | // Note: the rebase might fail with conflicted pending changes. |
| 97 | // rebase is technically incorrect if the user edits the changes. |
| 98 | // We should move the rebase logic to debugimportstack and make |
| 99 | // it handle pending changes just fine. |
| 100 | const stackTop = stack.originalStack.at(-1)?.node; |
| 101 | if (stackIntention === 'split' && stackTop != null) { |
| 102 | const children = dag.children(stackTop); |
| 103 | if (children.size > 0) { |
| 104 | const rebaseOp = new RebaseOperation( |
| 105 | exactRevset(children.toArray().join('|')), |
| 106 | succeedableRevset(stackTop) /* stack top of the new successor */, |
| 107 | ); |
| 108 | runOperation(rebaseOp); |
| 109 | } |
| 110 | } |
| 111 | // Exit stack editing. |
| 112 | setStackIntentionHashes(['general', new Set()]); |
| 113 | }; |
| 114 | |
| 115 | const handleCancel = () => { |
| 116 | sendStackEditMetrics(stackEdit, false); |
| 117 | setStackIntentionHashes(['general', new Set<Hash>()]); |
| 118 | }; |
| 119 | |
| 120 | // Get the commit hash for AI Split feature |
| 121 | const [startRev] = findStartEndRevs(stackEdit); |
| 122 | const {commitStack} = stackEdit; |
| 123 | const repo = useAtomValue(repositoryInfo); |
| 124 | const repoPath = repo?.repoRoot; |
| 125 | const enableDevmateSplit = useFeatureFlagSync(Internal.featureFlags?.DevmateSplitCommit) ?? false; |
| 126 | |
| 127 | // Get the commit hash from the start of the split range |
| 128 | const startCommit = startRev != null ? commitStack.get(startRev) : null; |
| 129 | const splitCommitHash = |
| 130 | startCommit?.originalNodes != null ? [...startCommit.originalNodes][0] : null; |
| 131 | |
| 132 | const handleAISplit = () => { |
| 133 | if (splitCommitHash == null) { |
| 134 | return; |
| 135 | } |
| 136 | const numFilesInCommit = startCommit?.files?.size ?? 0; |
| 137 | |
| 138 | // Bump the metric to track clicks for acceptance rate calculation |
| 139 | bumpStackEditMetric('clickedAiSplit'); |
| 140 | |
| 141 | tracker.track('DevmateSplitWithDevmateButtonClicked', { |
| 142 | extras: { |
| 143 | action: 'SplitCommit', |
| 144 | source: 'splitUI', |
| 145 | commitHash: splitCommitHash, |
| 146 | numFilesInCommit, |
| 147 | stackIntention, |
| 148 | }, |
| 149 | }); |
| 150 | serverAPI.postMessage({ |
| 151 | type: 'platform/splitCommitWithAI', |
| 152 | diffCommit: splitCommitHash, |
| 153 | repoPath, |
| 154 | }); |
| 155 | }; |
| 156 | |
| 157 | let cancelTooltip = t('Discard stack editing changes'); |
| 158 | let confirmTooltip = t('Save stack editing changes'); |
| 159 | let confirmText = t('Save changes'); |
| 160 | switch (stackIntention) { |
| 161 | case 'split': |
| 162 | cancelTooltip = t('Cancel split'); |
| 163 | confirmTooltip = t('Apply split changes'); |
| 164 | confirmText = t('Split'); |
| 165 | break; |
| 166 | case 'absorb': |
| 167 | cancelTooltip = t('Cancel absorb'); |
| 168 | confirmTooltip = t('Apply absorb changes'); |
| 169 | confirmText = t('Absorb'); |
| 170 | break; |
| 171 | } |
| 172 | |
| 173 | // Show [AI Split] [Undo] [Redo] [Cancel] [Save changes]. |
| 174 | return ( |
| 175 | <> |
| 176 | {stackIntention === 'split' && |
| 177 | enableDevmateSplit && |
| 178 | splitCommitHash != null && |
| 179 | Internal.AISplitButton && <Internal.AISplitButton onClick={handleAISplit} />} |
| 180 | {enableDevmateSplit && splitCommitHash != null && <div className="stack-edit-spacer" />} |
| 181 | <Tooltip |
| 182 | component={() => |
| 183 | canUndo ? ( |
| 184 | <T replace={{$op: <UndoDescription op={stackEdit.undoOperationDescription()} />}}> |
| 185 | Undo $op |
| 186 | </T> |
| 187 | ) : ( |
| 188 | <T>No operations to undo</T> |
| 189 | ) |
| 190 | } |
| 191 | placement="bottom"> |
| 192 | <Button icon disabled={!canUndo} onClick={handleUndo}> |
| 193 | <Icon icon="discard" /> |
| 194 | </Button> |
| 195 | </Tooltip> |
| 196 | <Tooltip |
| 197 | component={() => |
| 198 | canRedo ? ( |
| 199 | <T replace={{$op: <UndoDescription op={stackEdit.redoOperationDescription()} />}}> |
| 200 | Redo $op |
| 201 | </T> |
| 202 | ) : ( |
| 203 | <T>No operations to redo</T> |
| 204 | ) |
| 205 | } |
| 206 | placement="bottom"> |
| 207 | <Button icon disabled={!canRedo} onClick={handleRedo}> |
| 208 | <Icon icon="redo" /> |
| 209 | </Button> |
| 210 | </Tooltip> |
| 211 | <Tooltip title={cancelTooltip} delayMs={DOCUMENTATION_DELAY} placement="bottom"> |
| 212 | <Button className="cancel-edit-stack-button" onClick={handleCancel}> |
| 213 | <T>Cancel</T> |
| 214 | </Button> |
| 215 | </Tooltip> |
| 216 | <Tooltip title={confirmTooltip} delayMs={DOCUMENTATION_DELAY} placement="bottom"> |
| 217 | <Button |
| 218 | className="confirm-edit-stack-button" |
| 219 | data-testid="confirm-edit-stack-button" |
| 220 | primary |
| 221 | onClick={handleSaveChanges}> |
| 222 | {confirmText} |
| 223 | </Button> |
| 224 | </Tooltip> |
| 225 | </> |
| 226 | ); |
| 227 | } |
| 228 | |