7.8 KB228 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 {Hash} from '../../types';
9
10import {Button} from 'isl-components/Button';
11import {Icon} from 'isl-components/Icon';
12import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
13import {useAtom, useAtomValue} from 'jotai';
14import {useCallback} from 'react';
15import serverAPI from '../../ClientToServerAPI';
16import {
17 editedCommitMessages,
18 getDefaultEditedCommitMessage,
19} from '../../CommitInfoView/CommitInfoState';
20import {Internal} from '../../Internal';
21import {tracker} from '../../analytics';
22import {useFeatureFlagSync} from '../../featureFlags';
23import {T, t} from '../../i18n';
24import {writeAtom} from '../../jotaiUtils';
25import {ImportStackOperation} from '../../operations/ImportStackOperation';
26import {RebaseOperation} from '../../operations/RebaseOperation';
27import {useRunOperation} from '../../operationsState';
28import {latestDag, latestHeadCommit, repositoryInfo} from '../../serverAPIState';
29import {exactRevset, succeedableRevset} from '../../types';
30import {UndoDescription} from './StackEditSubTree';
31import {
32 bumpStackEditMetric,
33 editingStackIntentionHashes,
34 findStartEndRevs,
35 sendStackEditMetrics,
36 useStackEditState,
37} from './stackEditState';
38
39import './StackEditSubTree.css';
40
41export 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