36.4 KB989 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 {Comparison} from 'shared/Comparison';
9import type {CommitMessageFields} from './CommitInfoView/types';
10import type {UseUncommittedSelection} from './partialSelection';
11import type {
12 ChangedFile,
13 ChangedFileMode,
14 ChangedFileStatus,
15 MergeConflicts,
16 RepoRelativePath,
17} from './types';
18
19import * as stylex from '@stylexjs/stylex';
20import {Badge} from 'isl-components/Badge';
21import {Banner, BannerKind} from 'isl-components/Banner';
22import {Button} from 'isl-components/Button';
23import {ErrorNotice} from 'isl-components/ErrorNotice';
24import {HorizontallyGrowingTextField} from 'isl-components/HorizontallyGrowingTextField';
25import {Icon} from 'isl-components/Icon';
26import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
27import {useAtom, useAtomValue} from 'jotai';
28import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
29import {ComparisonType} from 'shared/Comparison';
30import {useDeepMemo} from 'shared/hooks';
31import {group, partition} from 'shared/utils';
32import {Avatar} from './Avatar';
33import {File} from './ChangedFile';
34import {
35 ChangedFileDisplayTypePicker,
36 type ChangedFilesDisplayType,
37 changedFilesDisplayType,
38} from './ChangedFileDisplayTypePicker';
39import {Collapsable} from './Collapsable';
40import {Commit} from './Commit';
41import {
42 commitMessageTemplate,
43 commitMode,
44 editedCommitMessages,
45 forceNextCommitToEditAllFields,
46} from './CommitInfoView/CommitInfoState';
47import {
48 commitMessageFieldsSchema,
49 commitMessageFieldsToString,
50} from './CommitInfoView/CommitMessageFields';
51import {PendingDiffStats} from './CommitInfoView/DiffStats';
52import {temporaryCommitTitle} from './CommitTitle';
53import {OpenComparisonViewButton} from './ComparisonView/OpenComparisonViewButton';
54import {Row} from './ComponentUtils';
55import {EmptyState} from './EmptyState';
56import {FileTree, FileTreeFolderHeader} from './FileTree';
57import {useGeneratedFileStatuses} from './GeneratedFile';
58import {Internal} from './Internal';
59import {AbsorbButton} from './StackActions';
60import {confirmSuggestedEditsForFiles} from './SuggestedEdits';
61import {processChangedFiles} from './UncommittedChangesUtils';
62import {UnsavedFilesCount, confirmUnsavedFiles} from './UnsavedFiles';
63import {tracker} from './analytics';
64import {latestCommitMessageFields} from './codeReview/CodeReviewInfo';
65import {islDrawerState} from './drawerState';
66import {externalMergeToolAtom} from './externalMergeTool';
67import {useFeatureFlagSync} from './featureFlags';
68import {T, t} from './i18n';
69import {DownwardArrow} from './icons/DownwardIcon';
70import {localStorageBackedAtom, readAtom, useAtomGet, writeAtom} from './jotaiUtils';
71import {
72 AutoResolveSettingCheckbox,
73 PartialAbortSettingCheckbox,
74 shouldAutoResolveAllBeforeContinue,
75 shouldPartialAbort,
76} from './mergeConflicts/state';
77import {AbortMergeOperation} from './operations/AbortMergeOperation';
78import {AddRemoveOperation} from './operations/AddRemoveOperation';
79import {getAmendOperation} from './operations/AmendOperation';
80import {getCommitOperation} from './operations/CommitOperation';
81import {ContinueOperation} from './operations/ContinueMergeOperation';
82import {DiscardOperation, PartialDiscardOperation} from './operations/DiscardOperation';
83import {PurgeOperation} from './operations/PurgeOperation';
84import {ResolveInExternalMergeToolOperation} from './operations/ResolveInExternalMergeToolOperation';
85import {RevertOperation} from './operations/RevertOperation';
86import {RunMergeDriversOperation} from './operations/RunMergeDriversOperation';
87import {getShelveOperation} from './operations/ShelveOperation';
88import {operationList, useRunOperation} from './operationsState';
89import {useUncommittedSelection} from './partialSelection';
90import platform from './platform';
91import {
92 CommitPreview,
93 dagWithPreviews,
94 optimisticMergeConflicts,
95 uncommittedChangesWithPreviews,
96 useIsOperationRunningOrQueued,
97} from './previews';
98import {repoRootAtom} from './repositoryData';
99import {selectedCommits} from './selection';
100import {
101 latestHeadCommit,
102 submodulePathsByRoot,
103 uncommittedChangesFetchError,
104} from './serverAPIState';
105import {SmartActionsDropdown} from './smartActions/SmartActionsDropdown';
106import {SmartActionsMenu} from './smartActions/SmartActionsMenu';
107import {GeneratedStatus} from './types';
108
109import './UncommittedChanges.css';
110
111export type UIChangedFile = {
112 path: RepoRelativePath;
113 // Compute file mode here rather than in the isl-server,
114 // as the info is not directly available from the `status` command
115 mode: ChangedFileMode;
116 // disambiguated path, or rename with arrow
117 label: string;
118 status: ChangedFileStatus;
119 visualStatus: VisualChangedFileStatus;
120 copiedFrom?: RepoRelativePath;
121 renamedFrom?: RepoRelativePath;
122 tooltip: string;
123};
124
125export type VisualChangedFileStatus = ChangedFileStatus | 'Renamed' | 'Copied';
126
127type SectionProps = Omit<React.ComponentProps<typeof LinearFileList>, 'files'> & {
128 filesByPrefix: Map<string, Array<UIChangedFile>>;
129};
130
131function SectionedFileList({filesByPrefix, ...rest}: SectionProps) {
132 const [collapsedSections, setCollapsedSections] = useState(new Set<string>());
133 return (
134 <div className="file-tree">
135 {Array.from(filesByPrefix.entries(), ([prefix, files]) => {
136 const isCollapsed = collapsedSections.has(prefix);
137 const isEverythingSelected = files.every(file =>
138 rest.selection?.isFullySelected(file.path),
139 );
140 const isPartiallySelected = files.some(file =>
141 rest.selection?.isFullyOrPartiallySelected(file.path),
142 );
143 return (
144 <div className="file-tree-section" key={prefix}>
145 <FileTreeFolderHeader
146 checkedState={
147 isEverythingSelected ? true : isPartiallySelected ? 'indeterminate' : false
148 }
149 toggleChecked={checked => {
150 if (checked) {
151 rest.selection?.select(...files.map(file => file.path));
152 } else {
153 rest.selection?.deselect(...files.map(file => file.path));
154 }
155 }}
156 isCollapsed={isCollapsed}
157 toggleCollapsed={() =>
158 setCollapsedSections(previous =>
159 previous.has(prefix)
160 ? new Set(Array.from(previous).filter(e => e !== prefix))
161 : new Set(Array.from(previous).concat(prefix)),
162 )
163 }
164 folder={prefix}
165 />
166 {!isCollapsed ? (
167 <div className="file-tree-level">
168 <LinearFileList {...rest} files={files} />
169 </div>
170 ) : null}
171 </div>
172 );
173 })}
174 </div>
175 );
176}
177
178/**
179 * Display a list of changed files.
180 *
181 * (Case 1) If filesSubset is too long, but filesSubset.length === totalFiles, pagination buttons
182 * are shown. This happens for uncommitted changes, where we have the entire list of files.
183 *
184 * (Case 2) If filesSubset.length < totalFiles, no pagination buttons are shown.
185 * It's expected that filesSubset is already truncated to fit.
186 * This happens initially for committed lists of changes, where we don't have the entire list of files.
187 * Note that we later fetch the remaining files, to end up in (Case 1) again.
188 *
189 * In either case, a banner is shown to warn that not all files are shown.
190 */
191export function ChangedFiles(props: {
192 filesSubset: ReadonlyArray<ChangedFile>;
193 totalFiles: number;
194 comparison: Comparison;
195 selection?: UseUncommittedSelection;
196 place?: Place;
197}) {
198 const displayType = useAtomValue(changedFilesDisplayType);
199 const {filesSubset, totalFiles, ...rest} = props;
200 const PAGE_SIZE = 500;
201 const PAGE_FETCH_COUNT = 2;
202 const [pageNum, setPageNum] = useState(0);
203 const isLastPage = pageNum >= Math.floor((totalFiles - 1) / PAGE_SIZE);
204 const rangeStart = pageNum * PAGE_SIZE;
205 const rangeEnd = Math.min(totalFiles, (pageNum + 1) * PAGE_SIZE);
206 const hasAdditionalPages = filesSubset.length > PAGE_SIZE;
207
208 // We paginate files, but also paginate our fetches for generated statuses
209 // at a larger granularity. This allows all manual files within that window
210 // to be sorted to the front. This wider pagination is neceessary so we can control
211 // how many files we query for generated statuses.
212 const fetchPage = Math.floor(pageNum - (pageNum % PAGE_FETCH_COUNT));
213 const fetchRangeStart = fetchPage * PAGE_SIZE;
214 const fetchRangeEnd = (fetchPage + PAGE_FETCH_COUNT) * PAGE_SIZE;
215 const filesToQueryGeneratedStatus = useMemo(
216 () => filesSubset.slice(fetchRangeStart, fetchRangeEnd).map(f => f.path),
217 [filesSubset, fetchRangeStart, fetchRangeEnd],
218 );
219
220 const filesMissingDueToFetchLimit =
221 filesToQueryGeneratedStatus.length === 0 && totalFiles > 0 && filesSubset.length > 0;
222
223 const generatedStatuses = useGeneratedFileStatuses(filesToQueryGeneratedStatus);
224 const filesToSort = filesSubset.slice(fetchRangeStart, fetchRangeEnd);
225 filesToSort.sort((a, b) => {
226 const genStatA = generatedStatuses[a.path] ?? 0;
227 const genStatB = generatedStatuses[b.path] ?? 0;
228 return genStatA - genStatB;
229 });
230 const filesToShow = filesToSort.slice(rangeStart - fetchRangeStart, rangeEnd - fetchRangeStart);
231 const root = useAtomValue(repoRootAtom);
232 const currSubmodulePaths = useAtomValue(submodulePathsByRoot(root));
233 const processedFiles = useDeepMemo(
234 () => processChangedFiles(filesToShow, currSubmodulePaths),
235 [filesToShow, currSubmodulePaths],
236 );
237
238 const prefixes: {key: string; prefix: string}[] = useMemo(
239 () => Internal.repoPrefixes ?? [{key: 'default', prefix: ''}],
240 [],
241 );
242 const firstNonDefaultPrefix = prefixes.find(
243 p => p.prefix.length > 0 && filesToSort.some(f => f.path.indexOf(p.prefix) === 0),
244 );
245 const shouldShowRepoHeaders =
246 prefixes.length > 1 &&
247 firstNonDefaultPrefix != null &&
248 filesToSort.find(f => f.path.indexOf(firstNonDefaultPrefix?.prefix) === -1) != null;
249
250 const filesByPrefix = new Map<string, Array<UIChangedFile>>();
251 for (const file of processedFiles) {
252 for (const {key, prefix} of prefixes) {
253 if (file.path.indexOf(prefix) === 0) {
254 if (!filesByPrefix.has(key)) {
255 filesByPrefix.set(key, []);
256 }
257 filesByPrefix.get(key)?.push(file);
258 break;
259 }
260 }
261 }
262
263 useEffect(() => {
264 // If the list of files is updated to have fewer files, we need to reset
265 // the pageNum state to be in the proper range again.
266 const lastPageIndex = Math.floor((totalFiles - 1) / PAGE_SIZE);
267 if (pageNum > lastPageIndex) {
268 setPageNum(Math.max(0, lastPageIndex));
269 }
270 }, [totalFiles, pageNum]);
271
272 return (
273 <div className="changed-files" data-testid="changed-files">
274 {totalFiles > filesToShow.length ? (
275 <Banner
276 key={'alert'}
277 icon={<Icon icon="info" />}
278 buttons={
279 hasAdditionalPages ? (
280 <div className="changed-files-pages-buttons">
281 <Tooltip title={t('See previous page of files')}>
282 <Button
283 data-testid="changed-files-previous-page"
284 icon
285 disabled={pageNum === 0}
286 onClick={() => {
287 setPageNum(old => old - 1);
288 }}>
289 <Icon icon="arrow-left" />
290 </Button>
291 </Tooltip>
292 <Tooltip title={t('See next page of files')}>
293 <Button
294 data-testid="changed-files-next-page"
295 icon
296 disabled={isLastPage}
297 onClick={() => {
298 setPageNum(old => old + 1);
299 }}>
300 <Icon icon="arrow-right" />
301 </Button>
302 </Tooltip>
303 </div>
304 ) : null
305 }>
306 {pageNum === 0 ? (
307 <T replace={{$numShown: filesToShow.length, $total: totalFiles}}>
308 Showing first $numShown files out of $total total
309 </T>
310 ) : (
311 <T replace={{$rangeStart: rangeStart + 1, $rangeEnd: rangeEnd, $total: totalFiles}}>
312 Showing files $rangeStart – $rangeEnd out of $total total
313 </T>
314 )}
315 </Banner>
316 ) : null}
317 {filesMissingDueToFetchLimit ? (
318 <Banner
319 key="not-everything-fetched"
320 icon={<Icon icon="warning" />}
321 kind={BannerKind.warning}>
322 <T replace={{$maxFiles: PAGE_SIZE * PAGE_FETCH_COUNT}}>
323 There are more than $maxFiles files, not all files have been fetched
324 </T>
325 </Banner>
326 ) : totalFiles > PAGE_SIZE * PAGE_FETCH_COUNT ? (
327 <Banner key="too-many-files" icon={<Icon icon="warning" />} kind={BannerKind.warning}>
328 <T replace={{$maxFiles: PAGE_SIZE * PAGE_FETCH_COUNT}}>
329 There are more than $maxFiles files, some files may appear out of order
330 </T>
331 </Banner>
332 ) : null}
333 {displayType === 'tree' ? (
334 <FileTree {...rest} files={processedFiles} displayType={displayType} />
335 ) : shouldShowRepoHeaders ? (
336 <SectionedFileList
337 {...rest}
338 filesByPrefix={filesByPrefix}
339 displayType={displayType}
340 generatedStatuses={generatedStatuses}
341 />
342 ) : (
343 <LinearFileList
344 {...rest}
345 files={processedFiles}
346 displayType={displayType}
347 generatedStatuses={generatedStatuses}
348 />
349 )}
350 </div>
351 );
352}
353
354const generatedFilesInitiallyExpanded = localStorageBackedAtom<boolean>(
355 'isl.expand-generated-files',
356 false,
357);
358
359export const __TEST__ = {
360 generatedFilesInitiallyExpanded,
361};
362
363function LinearFileList(props: {
364 files: Array<UIChangedFile>;
365 displayType: ChangedFilesDisplayType;
366 generatedStatuses: Record<RepoRelativePath, GeneratedStatus>;
367 comparison: Comparison;
368 selection?: UseUncommittedSelection;
369 place?: Place;
370}) {
371 const {files, generatedStatuses, ...rest} = props;
372
373 const groupedByGenerated = group(files, file => generatedStatuses[file.path]);
374 const [initiallyExpanded, setInitiallyExpanded] = useAtom(generatedFilesInitiallyExpanded);
375
376 function GeneratedFilesCollapsableSection(status: GeneratedStatus) {
377 const group = groupedByGenerated[status] ?? [];
378 if (group.length === 0) {
379 return null;
380 }
381 return (
382 <Collapsable
383 title={
384 <T
385 replace={{
386 $count: <Badge>{group.length}</Badge>,
387 }}>
388 {status === GeneratedStatus.PartiallyGenerated
389 ? 'Partially Generated Files $count'
390 : 'Generated Files $count'}
391 </T>
392 }
393 startExpanded={status === GeneratedStatus.PartiallyGenerated || initiallyExpanded}
394 onToggle={expanded => setInitiallyExpanded(expanded)}>
395 {group.map(file => (
396 <File key={file.path} {...rest} file={file} generatedStatus={status} />
397 ))}
398 </Collapsable>
399 );
400 }
401
402 return (
403 <div className="changed-files-list-container">
404 <div className="changed-files-list">
405 {groupedByGenerated[GeneratedStatus.Manual]?.map(file => (
406 <File
407 key={file.path}
408 {...rest}
409 file={file}
410 generatedStatus={generatedStatuses[file.path] ?? GeneratedStatus.Manual}
411 />
412 ))}
413 {GeneratedFilesCollapsableSection(GeneratedStatus.PartiallyGenerated)}
414 {GeneratedFilesCollapsableSection(GeneratedStatus.Generated)}
415 </div>
416 </div>
417 );
418}
419
420export type Place = 'main' | 'amend sidebar' | 'commit sidebar';
421
422export function UncommittedChanges({place}: {place: Place}) {
423 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
424 const error = useAtomValue(uncommittedChangesFetchError);
425 // TODO: use dagWithPreviews instead, and update CommitOperation
426 const headCommit = useAtomValue(latestHeadCommit);
427 const schema = useAtomValue(commitMessageFieldsSchema);
428 const template = useAtomValue(commitMessageTemplate);
429
430 const conflicts = useAtomValue(optimisticMergeConflicts);
431
432 const selection = useUncommittedSelection();
433 const commitTitleRef = useRef<HTMLInputElement>(null);
434
435 const runOperation = useRunOperation();
436
437 const useV2SmartActions = useFeatureFlagSync(Internal.featureFlags?.SmartActionsRedesign);
438
439 const openCommitForm = useCallback(
440 (which: 'commit' | 'amend') => {
441 // make sure view is expanded
442 writeAtom(islDrawerState, val => ({...val, right: {...val.right, collapsed: false}}));
443
444 // show head commit & set to correct mode
445 writeAtom(selectedCommits, new Set());
446 writeAtom(commitMode, which);
447
448 // Start editing fields when amending so you can go right into typing.
449 if (which === 'amend') {
450 writeAtom(forceNextCommitToEditAllFields, true);
451 if (headCommit != null) {
452 const latestMessage = readAtom(latestCommitMessageFields(headCommit.hash));
453 if (latestMessage) {
454 writeAtom(editedCommitMessages(headCommit.hash), {
455 ...latestMessage,
456 });
457 }
458 }
459 }
460
461 const quickCommitTyped = commitTitleRef.current?.value;
462 if (which === 'commit' && quickCommitTyped != null && quickCommitTyped != '') {
463 writeAtom(editedCommitMessages('head'), value => ({
464 ...value,
465 Title: quickCommitTyped,
466 }));
467 // delete what was written in the quick commit form
468 commitTitleRef.current != null && (commitTitleRef.current.value = '');
469 }
470 },
471 [headCommit],
472 );
473
474 const onConfirmQuickCommit = async () => {
475 const shouldContinue = await confirmUnsavedFiles();
476 if (!shouldContinue) {
477 return;
478 }
479
480 if (!(await confirmSuggestedEditsForFiles('quick-commit', 'accept', selection.selection))) {
481 return;
482 }
483
484 const titleEl = commitTitleRef.current;
485 const title = titleEl?.value || template?.Title || temporaryCommitTitle();
486 // use the template, unless a specific quick title is given
487 const fields: CommitMessageFields = {...template, Title: title};
488 const message = commitMessageFieldsToString(schema, fields);
489 const allFiles = uncommittedChanges.map(file => file.path);
490 const operation = getCommitOperation(message, headCommit, selection.selection, allFiles);
491 selection.discardPartialSelections();
492 runOperation(operation);
493 if (titleEl) {
494 // clear out message now that we've used it
495 titleEl.value = '';
496 }
497 };
498
499 if (error) {
500 return <ErrorNotice title={t('Failed to fetch Uncommitted Changes')} error={error} />;
501 }
502 if (uncommittedChanges.length === 0 && conflicts == null) {
503 return null;
504 }
505 const allFilesSelected = selection.isEverythingSelected();
506 const noFilesSelected = selection.isNothingSelected();
507 const hasChunkSelection = selection.hasChunkSelection();
508
509 const allConflictsResolved =
510 conflicts?.files?.every(conflict => conflict.status === 'Resolved') ?? false;
511
512 // only show addremove button if some files are untracked/missing
513 const UNTRACKED_OR_MISSING = ['?', '!'];
514 const addremoveButton = uncommittedChanges.some(file =>
515 UNTRACKED_OR_MISSING.includes(file.status),
516 ) ? (
517 <Tooltip
518 delayMs={DOCUMENTATION_DELAY}
519 title={t('Add all untracked files and remove all missing files.')}>
520 <Button
521 icon
522 key="addremove"
523 data-testid="addremove-button"
524 onClick={() => {
525 // If all files are selected, no need to pass specific files to addremove.
526 const filesToAddRemove = allFilesSelected
527 ? []
528 : uncommittedChanges
529 .filter(file => UNTRACKED_OR_MISSING.includes(file.status))
530 .filter(file => selection.isFullyOrPartiallySelected(file.path))
531 .map(file => file.path);
532 runOperation(new AddRemoveOperation(filesToAddRemove));
533 }}>
534 <Icon slot="start" icon="expand-all" />
535 <T>Add/Remove</T>
536 </Button>
537 </Tooltip>
538 ) : null;
539
540 const onShelve = async () => {
541 if (!(await confirmSuggestedEditsForFiles('shelve', 'accept', selection.selection))) {
542 return;
543 }
544
545 const title = commitTitleRef.current?.value || undefined;
546 const allFiles = uncommittedChanges.map(file => file.path);
547 const operation = getShelveOperation(title, selection.selection, allFiles);
548 runOperation(operation);
549 };
550
551 const canAmend = headCommit && headCommit.phase !== 'public' && headCommit.successorInfo == null;
552
553 return (
554 <div className="uncommitted-changes">
555 {conflicts != null ? (
556 <div className="conflicts-header">
557 <strong>
558 {allConflictsResolved ? (
559 <T>All Merge Conflicts Resolved</T>
560 ) : (
561 <T>Unresolved Merge Conflicts</T>
562 )}
563 </strong>
564 {conflicts.state === 'loading' ? (
565 <div data-testid="merge-conflicts-spinner">
566 <Icon icon="loading" />
567 </div>
568 ) : null}
569 {allConflictsResolved ? null : (
570 <T replace={{$cmd: conflicts.command}}>Resolve conflicts to continue $cmd</T>
571 )}
572 </div>
573 ) : null}
574 <div className="button-row">
575 {conflicts != null ? (
576 <MergeConflictButtons allConflictsResolved={allConflictsResolved} conflicts={conflicts} />
577 ) : (
578 <>
579 <ChangedFileDisplayTypePicker />
580 <OpenComparisonViewButton
581 comparison={{
582 type:
583 place === 'amend sidebar'
584 ? ComparisonType.HeadChanges
585 : ComparisonType.UncommittedChanges,
586 }}
587 />
588 <Button
589 icon
590 key="select-all"
591 disabled={allFilesSelected}
592 onClick={() => {
593 selection.selectAll();
594 }}>
595 <Icon slot="start" icon="check-all" />
596 <T>Select All</T>
597 </Button>
598 <Button
599 icon
600 key="deselect-all"
601 data-testid="deselect-all-button"
602 disabled={noFilesSelected}
603 onClick={() => {
604 selection.deselectAll();
605 }}>
606 <Icon slot="start" icon="close-all" />
607 <T>Deselect All</T>
608 </Button>
609 {addremoveButton}
610 <Tooltip
611 delayMs={DOCUMENTATION_DELAY}
612 title={t(
613 'Discard selected uncommitted changes, including untracked files.\n\nNote: Changes will be irreversibly lost.',
614 )}>
615 <Button
616 icon
617 disabled={noFilesSelected}
618 data-testid={'discard-all-selected-button'}
619 onClick={async () => {
620 if (
621 !(await confirmSuggestedEditsForFiles('discard', 'reject', selection.selection))
622 ) {
623 return;
624 }
625 if (!(await platform.confirm(t('confirmDiscardChanges')))) {
626 return;
627 }
628 if (allFilesSelected) {
629 // all changes selected -> use clean goto rather than reverting each file. This is generally faster.
630
631 // to "discard", we need to both remove uncommitted changes
632 runOperation(new DiscardOperation());
633 // ...and delete untracked files.
634 // Technically we only need to do the purge when we have untracked files, though there's a chance there's files we don't know about yet while status is running.
635 runOperation(new PurgeOperation());
636 } else if (selection.hasChunkSelection()) {
637 // TODO(quark): Make PartialDiscardOperation replace the above and below cases.
638 const allFiles = uncommittedChanges.map(file => file.path);
639 const operation = new PartialDiscardOperation(selection.selection, allFiles);
640 selection.discardPartialSelections();
641 runOperation(operation);
642 } else {
643 const selectedFiles = uncommittedChanges.filter(file =>
644 selection.isFullyOrPartiallySelected(file.path),
645 );
646 const [selectedTrackedFiles, selectedUntrackedFiles] = partition(
647 selectedFiles,
648 file => file.status !== '?', // only untracked, not missing
649 );
650 // Added files should be first reverted, then purged, so they are not tracked and also deleted.
651 // This way, the partial selection discard matches the non-partial discard.
652 const addedFilesToAlsoPurge = selectedFiles.filter(file => file.status === 'A');
653 if (selectedTrackedFiles.length > 0) {
654 // only a subset of files selected -> we need to revert selected tracked files individually
655 runOperation(new RevertOperation(selectedTrackedFiles.map(f => f.path)));
656 }
657 if (selectedUntrackedFiles.length > 0 || addedFilesToAlsoPurge.length > 0) {
658 // untracked files must be purged separately to delete from disk.
659 runOperation(
660 new PurgeOperation(
661 [...selectedUntrackedFiles, ...addedFilesToAlsoPurge].map(f => f.path),
662 ),
663 );
664 }
665 }
666 }}>
667 <Icon slot="start" icon="trashcan" />
668 <T>Discard</T>
669 </Button>
670 </Tooltip>
671 <AbsorbButton />
672 {useV2SmartActions && <SmartActionsDropdown key="smartActions" />}
673 </>
674 )}
675 </div>
676 {conflicts != null ? (
677 <ChangedFiles
678 filesSubset={conflicts.files ?? []}
679 totalFiles={conflicts.files?.length ?? 0}
680 place={place}
681 comparison={{
682 type: ComparisonType.UncommittedChanges,
683 }}
684 />
685 ) : (
686 <ChangedFiles
687 filesSubset={uncommittedChanges}
688 totalFiles={uncommittedChanges.length}
689 place={place}
690 selection={selection}
691 comparison={{
692 type: ComparisonType.UncommittedChanges,
693 }}
694 />
695 )}
696 <UnsavedFilesCount />
697 {conflicts != null || place !== 'main' ? null : (
698 <div className="button-rows">
699 <div className="button-row">
700 <PendingDiffStats />
701 </div>
702 <div className="button-row">
703 <span className="quick-commit-inputs">
704 <Button
705 icon
706 disabled={noFilesSelected}
707 data-testid="quick-commit-button"
708 onClick={onConfirmQuickCommit}>
709 <Icon slot="start" icon="plus" />
710 <T>Commit</T>
711 </Button>
712 <HorizontallyGrowingTextField
713 data-testid="quick-commit-title"
714 placeholder="Title"
715 ref={commitTitleRef}
716 onKeyPress={e => {
717 if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) {
718 onConfirmQuickCommit();
719 }
720 }}
721 />
722 </span>
723 <Button
724 icon
725 className="show-on-hover"
726 onClick={() => {
727 openCommitForm('commit');
728 }}>
729 <Icon slot="start" icon="edit" />
730 <T>Commit as...</T>
731 </Button>
732 <Tooltip
733 title={t(
734 'Save selected uncommitted changes for later unshelving. Removes these changes from the working copy.',
735 )}>
736 <Button
737 disabled={noFilesSelected || hasChunkSelection}
738 icon
739 className="show-on-hover"
740 onClick={onShelve}>
741 <Icon slot="start" icon="archive" />
742 <T>Shelve</T>
743 </Button>
744 </Tooltip>
745 <span className="show-on-hover">
746 {!useV2SmartActions && <SmartActionsMenu key="smartActions" />}
747 </span>
748 </div>
749 {canAmend && (
750 <div className="button-row">
751 <Button
752 icon
753 disabled={noFilesSelected || !headCommit}
754 data-testid="uncommitted-changes-quick-amend-button"
755 onClick={async () => {
756 const shouldContinue = await confirmUnsavedFiles();
757 if (!shouldContinue) {
758 return;
759 }
760
761 if (
762 !(await confirmSuggestedEditsForFiles(
763 'quick-amend',
764 'accept',
765 selection.selection,
766 ))
767 ) {
768 return;
769 }
770
771 const allFiles = uncommittedChanges.map(file => file.path);
772 const operation = getAmendOperation(
773 undefined,
774 headCommit,
775 selection.selection,
776 allFiles,
777 );
778 selection.discardPartialSelections();
779 runOperation(operation);
780 }}>
781 <Icon slot="start" icon="debug-step-into" />
782 <T>Amend</T>
783 </Button>
784 <Button
785 icon
786 className="show-on-hover"
787 onClick={() => {
788 openCommitForm('amend');
789 }}>
790 <Icon slot="start" icon="edit" />
791 <T>Amend as...</T>
792 </Button>
793 </div>
794 )}
795 </div>
796 )}
797 {place === 'main' && <ConflictingIncomingCommit />}
798 </div>
799 );
800}
801
802const styles = stylex.create({
803 conflictingIncomingContainer: {
804 gap: 'var(--halfpad)',
805 position: 'relative',
806 paddingLeft: '20px',
807 paddingTop: '5px',
808 marginBottom: '-5px',
809 color: 'var(--scm-added-foreground)',
810 },
811 downwardArrow: {
812 position: 'absolute',
813 top: '20px',
814 left: '5px',
815 },
816});
817
818function ConflictingIncomingCommit() {
819 const conflicts = useAtomValue(optimisticMergeConflicts);
820 // "other" is the incoming / source / your commit
821 const commit = useAtomGet(dagWithPreviews, conflicts?.hashes?.other);
822 if (commit == null) {
823 return null;
824 }
825 return (
826 <Row xstyle={styles.conflictingIncomingContainer}>
827 <DownwardArrow {...stylex.props(styles.downwardArrow)} />
828 <Avatar username={commit.author} />
829 <Commit
830 commit={commit}
831 hasChildren={false}
832 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
833 />
834 </Row>
835 );
836}
837
838function MergeConflictButtons({
839 conflicts,
840 allConflictsResolved,
841}: {
842 conflicts: MergeConflicts;
843 allConflictsResolved: boolean;
844}) {
845 const runOperation = useRunOperation();
846 // usually we only care if the operation is queued or actively running,
847 // but since we don't use optimistic state for continue/abort,
848 // we also need to consider recently run commands to disable the buttons.
849 // But only if the abort/continue command succeeded.
850 // TODO: is this reliable? Is it possible to get stuck with buttons disabled because
851 // we think it's still running?
852 const lastRunOperation = useAtomValue(operationList).currentOperation;
853 const justFinishedContinue =
854 lastRunOperation?.operation instanceof ContinueOperation && lastRunOperation.exitCode === 0;
855 const justFinishedAbort =
856 lastRunOperation?.operation instanceof AbortMergeOperation && lastRunOperation.exitCode === 0;
857 const isRunningContinue = !!useIsOperationRunningOrQueued(ContinueOperation);
858 const isRunningAbort = !!useIsOperationRunningOrQueued(AbortMergeOperation);
859 const isRunningResolveExternal = !!useIsOperationRunningOrQueued(
860 ResolveInExternalMergeToolOperation,
861 );
862 const shouldDisableButtons =
863 isRunningContinue ||
864 isRunningAbort ||
865 isRunningResolveExternal ||
866 justFinishedContinue ||
867 justFinishedAbort;
868
869 const externalMergeTool = useAtomValue(externalMergeToolAtom);
870
871 const useV2SmartActions = useFeatureFlagSync(Internal.featureFlags?.SmartActionsRedesign);
872 const branchMerge = Internal.getSubtreeContinueOperation?.(dagWithPreviews, conflicts);
873
874 return (
875 <Row style={{flexWrap: 'wrap', marginBottom: 'var(--pad)'}}>
876 <Button
877 primary
878 key="continue"
879 disabled={!allConflictsResolved || shouldDisableButtons}
880 data-testid="conflict-continue-button"
881 onClick={async () => {
882 const conflictFiles =
883 conflicts.state === 'loaded' ? conflicts.files.map(f => f.path) : [];
884 if (!(await confirmSuggestedEditsForFiles('merge-continue', 'accept', conflictFiles))) {
885 return;
886 }
887
888 if (readAtom(shouldAutoResolveAllBeforeContinue)) {
889 runOperation(new RunMergeDriversOperation());
890 }
891 if (branchMerge) {
892 runOperation(branchMerge);
893 } else {
894 runOperation(new ContinueOperation());
895 }
896 }}>
897 <Icon slot="start" icon={isRunningContinue ? 'loading' : 'debug-continue'} />
898 <T>Continue</T>
899 </Button>
900 <Button
901 key="abort"
902 disabled={shouldDisableButtons}
903 onClick={() => {
904 const partialAbortAvailable = conflicts?.command === 'rebase';
905 const isPartialAbort = partialAbortAvailable && readAtom(shouldPartialAbort);
906 runOperation(new AbortMergeOperation(conflicts, isPartialAbort));
907 }}>
908 <Icon slot="start" icon={isRunningAbort ? 'loading' : 'circle-slash'} />
909 <T>Abort</T>
910 </Button>
911 {useV2SmartActions ? (
912 <SmartActionsDropdown />
913 ) : (
914 Internal.ResolveMergeConflictsWithAIButton && (
915 <Internal.ResolveMergeConflictsWithAIButton
916 conflicts={conflicts}
917 disabled={allConflictsResolved || shouldDisableButtons}
918 />
919 )
920 )}
921 {externalMergeTool == null ? (
922 platform.upsellExternalMergeTool ? (
923 <Tooltip
924 title={
925 <div>
926 <T replace={{$tool: <code>{externalMergeTool}</code>, $br: <br />}}>
927 You can configure an external merge tool to use for resolving conflicts.$br
928 </T>
929 </div>
930 }>
931 <Button
932 icon
933 disabled={allConflictsResolved || shouldDisableButtons}
934 onClick={() => {
935 tracker.track('ClickedConfigureExternalMergeTool');
936 const link = Internal.externalMergeToolDocsLink;
937 if (link) {
938 platform.openExternalLink(link);
939 return;
940 }
941 platform.confirm(
942 t('Configuring External Merge Tools'),
943 t(
944 'You can configure ISL to use an external merge tool for resolving conflicts.\n' +
945 'Set both `ui.merge = mymergetool` and `merge-tool.mymergetool`.\n' +
946 'See `sl help config.merge-tools` for more information about setting up merge tools.\n',
947 ),
948 );
949 }}>
950 <Icon icon="gear" />
951 <T>Configure External Merge Tool</T>
952 </Button>
953 </Tooltip>
954 ) : null
955 ) : (
956 <Tooltip
957 title={
958 <div>
959 <T replace={{$tool: <code>{externalMergeTool}</code>, $br: <br />}}>
960 Open your configured external merge tool $tool to resolve all the conflicts.$br
961 Waits for the merge tool to exit before continuing.
962 </T>
963 {allConflictsResolved ? (
964 <>
965 <br />
966 <T>Disabled since all conflicts have been resolved.</T>
967 </>
968 ) : null}
969 </div>
970 }>
971 <Button
972 icon
973 disabled={allConflictsResolved || shouldDisableButtons}
974 onClick={() => {
975 runOperation(new ResolveInExternalMergeToolOperation(externalMergeTool));
976 }}>
977 <Icon icon="link-external" />
978 <T>Open External Merge Tool</T>
979 </Button>
980 </Tooltip>
981 )}
982 {Internal.showInlineAutoRunMergeDriversOption === true && (
983 <AutoResolveSettingCheckbox subtle />
984 )}
985 {conflicts?.command === 'rebase' && <PartialAbortSettingCheckbox subtle />}
986 </Row>
987 );
988}
989