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