addons/isl/src/UncommittedChanges.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 {Comparison} from 'shared/Comparison';
b69ab319import type {CommitMessageFields} from './CommitInfoView/types';
b69ab3110import type {UseUncommittedSelection} from './partialSelection';
b69ab3111import type {
b69ab3112 ChangedFile,
b69ab3113 ChangedFileMode,
b69ab3114 ChangedFileStatus,
b69ab3115 MergeConflicts,
b69ab3116 RepoRelativePath,
b69ab3117} from './types';
b69ab3118
b69ab3119import * as stylex from '@stylexjs/stylex';
b69ab3120import {Badge} from 'isl-components/Badge';
b69ab3121import {Banner, BannerKind} from 'isl-components/Banner';
b69ab3122import {Button} from 'isl-components/Button';
b69ab3123import {ErrorNotice} from 'isl-components/ErrorNotice';
b69ab3124import {HorizontallyGrowingTextField} from 'isl-components/HorizontallyGrowingTextField';
b69ab3125import {Icon} from 'isl-components/Icon';
b69ab3126import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
b69ab3127import {useAtom, useAtomValue} from 'jotai';
b69ab3128import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
b69ab3129import {ComparisonType} from 'shared/Comparison';
b69ab3130import {useDeepMemo} from 'shared/hooks';
b69ab3131import {group, partition} from 'shared/utils';
b69ab3132import {Avatar} from './Avatar';
b69ab3133import {File} from './ChangedFile';
b69ab3134import {
b69ab3135 ChangedFileDisplayTypePicker,
b69ab3136 type ChangedFilesDisplayType,
b69ab3137 changedFilesDisplayType,
b69ab3138} from './ChangedFileDisplayTypePicker';
b69ab3139import {Collapsable} from './Collapsable';
b69ab3140import {Commit} from './Commit';
b69ab3141import {
b69ab3142 commitMessageTemplate,
b69ab3143 commitMode,
b69ab3144 editedCommitMessages,
b69ab3145 forceNextCommitToEditAllFields,
b69ab3146} from './CommitInfoView/CommitInfoState';
b69ab3147import {
b69ab3148 commitMessageFieldsSchema,
b69ab3149 commitMessageFieldsToString,
b69ab3150} from './CommitInfoView/CommitMessageFields';
b69ab3151import {PendingDiffStats} from './CommitInfoView/DiffStats';
b69ab3152import {temporaryCommitTitle} from './CommitTitle';
b69ab3153import {OpenComparisonViewButton} from './ComparisonView/OpenComparisonViewButton';
b69ab3154import {Row} from './ComponentUtils';
5ffddc655import {EmptyState} from './EmptyState';
b69ab3156import {FileTree, FileTreeFolderHeader} from './FileTree';
b69ab3157import {useGeneratedFileStatuses} from './GeneratedFile';
b69ab3158import {Internal} from './Internal';
b69ab3159import {AbsorbButton} from './StackActions';
b69ab3160import {confirmSuggestedEditsForFiles} from './SuggestedEdits';
b69ab3161import {processChangedFiles} from './UncommittedChangesUtils';
b69ab3162import {UnsavedFilesCount, confirmUnsavedFiles} from './UnsavedFiles';
b69ab3163import {tracker} from './analytics';
b69ab3164import {latestCommitMessageFields} from './codeReview/CodeReviewInfo';
b69ab3165import {islDrawerState} from './drawerState';
b69ab3166import {externalMergeToolAtom} from './externalMergeTool';
b69ab3167import {useFeatureFlagSync} from './featureFlags';
b69ab3168import {T, t} from './i18n';
b69ab3169import {DownwardArrow} from './icons/DownwardIcon';
b69ab3170import {localStorageBackedAtom, readAtom, useAtomGet, writeAtom} from './jotaiUtils';
b69ab3171import {
b69ab3172 AutoResolveSettingCheckbox,
b69ab3173 PartialAbortSettingCheckbox,
b69ab3174 shouldAutoResolveAllBeforeContinue,
b69ab3175 shouldPartialAbort,
b69ab3176} from './mergeConflicts/state';
b69ab3177import {AbortMergeOperation} from './operations/AbortMergeOperation';
b69ab3178import {AddRemoveOperation} from './operations/AddRemoveOperation';
b69ab3179import {getAmendOperation} from './operations/AmendOperation';
b69ab3180import {getCommitOperation} from './operations/CommitOperation';
b69ab3181import {ContinueOperation} from './operations/ContinueMergeOperation';
b69ab3182import {DiscardOperation, PartialDiscardOperation} from './operations/DiscardOperation';
b69ab3183import {PurgeOperation} from './operations/PurgeOperation';
b69ab3184import {ResolveInExternalMergeToolOperation} from './operations/ResolveInExternalMergeToolOperation';
b69ab3185import {RevertOperation} from './operations/RevertOperation';
b69ab3186import {RunMergeDriversOperation} from './operations/RunMergeDriversOperation';
b69ab3187import {getShelveOperation} from './operations/ShelveOperation';
b69ab3188import {operationList, useRunOperation} from './operationsState';
b69ab3189import {useUncommittedSelection} from './partialSelection';
b69ab3190import platform from './platform';
b69ab3191import {
b69ab3192 CommitPreview,
b69ab3193 dagWithPreviews,
b69ab3194 optimisticMergeConflicts,
b69ab3195 uncommittedChangesWithPreviews,
b69ab3196 useIsOperationRunningOrQueued,
b69ab3197} from './previews';
b69ab3198import {repoRootAtom} from './repositoryData';
b69ab3199import {selectedCommits} from './selection';
b69ab31100import {
b69ab31101 latestHeadCommit,
b69ab31102 submodulePathsByRoot,
b69ab31103 uncommittedChangesFetchError,
b69ab31104} from './serverAPIState';
b69ab31105import {SmartActionsDropdown} from './smartActions/SmartActionsDropdown';
b69ab31106import {SmartActionsMenu} from './smartActions/SmartActionsMenu';
b69ab31107import {GeneratedStatus} from './types';
b69ab31108
b69ab31109import './UncommittedChanges.css';
b69ab31110
b69ab31111export type UIChangedFile = {
b69ab31112 path: RepoRelativePath;
b69ab31113 // Compute file mode here rather than in the isl-server,
b69ab31114 // as the info is not directly available from the `status` command
b69ab31115 mode: ChangedFileMode;
b69ab31116 // disambiguated path, or rename with arrow
b69ab31117 label: string;
b69ab31118 status: ChangedFileStatus;
b69ab31119 visualStatus: VisualChangedFileStatus;
b69ab31120 copiedFrom?: RepoRelativePath;
b69ab31121 renamedFrom?: RepoRelativePath;
b69ab31122 tooltip: string;
b69ab31123};
b69ab31124
b69ab31125export type VisualChangedFileStatus = ChangedFileStatus | 'Renamed' | 'Copied';
b69ab31126
b69ab31127type SectionProps = Omit<React.ComponentProps<typeof LinearFileList>, 'files'> & {
b69ab31128 filesByPrefix: Map<string, Array<UIChangedFile>>;
b69ab31129};
b69ab31130
b69ab31131function SectionedFileList({filesByPrefix, ...rest}: SectionProps) {
b69ab31132 const [collapsedSections, setCollapsedSections] = useState(new Set<string>());
b69ab31133 return (
b69ab31134 <div className="file-tree">
b69ab31135 {Array.from(filesByPrefix.entries(), ([prefix, files]) => {
b69ab31136 const isCollapsed = collapsedSections.has(prefix);
b69ab31137 const isEverythingSelected = files.every(file =>
b69ab31138 rest.selection?.isFullySelected(file.path),
b69ab31139 );
b69ab31140 const isPartiallySelected = files.some(file =>
b69ab31141 rest.selection?.isFullyOrPartiallySelected(file.path),
b69ab31142 );
b69ab31143 return (
b69ab31144 <div className="file-tree-section" key={prefix}>
b69ab31145 <FileTreeFolderHeader
b69ab31146 checkedState={
b69ab31147 isEverythingSelected ? true : isPartiallySelected ? 'indeterminate' : false
b69ab31148 }
b69ab31149 toggleChecked={checked => {
b69ab31150 if (checked) {
b69ab31151 rest.selection?.select(...files.map(file => file.path));
b69ab31152 } else {
b69ab31153 rest.selection?.deselect(...files.map(file => file.path));
b69ab31154 }
b69ab31155 }}
b69ab31156 isCollapsed={isCollapsed}
b69ab31157 toggleCollapsed={() =>
b69ab31158 setCollapsedSections(previous =>
b69ab31159 previous.has(prefix)
b69ab31160 ? new Set(Array.from(previous).filter(e => e !== prefix))
b69ab31161 : new Set(Array.from(previous).concat(prefix)),
b69ab31162 )
b69ab31163 }
b69ab31164 folder={prefix}
b69ab31165 />
b69ab31166 {!isCollapsed ? (
b69ab31167 <div className="file-tree-level">
b69ab31168 <LinearFileList {...rest} files={files} />
b69ab31169 </div>
b69ab31170 ) : null}
b69ab31171 </div>
b69ab31172 );
b69ab31173 })}
b69ab31174 </div>
b69ab31175 );
b69ab31176}
b69ab31177
b69ab31178/**
b69ab31179 * Display a list of changed files.
b69ab31180 *
b69ab31181 * (Case 1) If filesSubset is too long, but filesSubset.length === totalFiles, pagination buttons
b69ab31182 * are shown. This happens for uncommitted changes, where we have the entire list of files.
b69ab31183 *
b69ab31184 * (Case 2) If filesSubset.length < totalFiles, no pagination buttons are shown.
b69ab31185 * It's expected that filesSubset is already truncated to fit.
b69ab31186 * This happens initially for committed lists of changes, where we don't have the entire list of files.
b69ab31187 * Note that we later fetch the remaining files, to end up in (Case 1) again.
b69ab31188 *
b69ab31189 * In either case, a banner is shown to warn that not all files are shown.
b69ab31190 */
b69ab31191export function ChangedFiles(props: {
b69ab31192 filesSubset: ReadonlyArray<ChangedFile>;
b69ab31193 totalFiles: number;
b69ab31194 comparison: Comparison;
b69ab31195 selection?: UseUncommittedSelection;
b69ab31196 place?: Place;
b69ab31197}) {
b69ab31198 const displayType = useAtomValue(changedFilesDisplayType);
b69ab31199 const {filesSubset, totalFiles, ...rest} = props;
b69ab31200 const PAGE_SIZE = 500;
b69ab31201 const PAGE_FETCH_COUNT = 2;
b69ab31202 const [pageNum, setPageNum] = useState(0);
b69ab31203 const isLastPage = pageNum >= Math.floor((totalFiles - 1) / PAGE_SIZE);
b69ab31204 const rangeStart = pageNum * PAGE_SIZE;
b69ab31205 const rangeEnd = Math.min(totalFiles, (pageNum + 1) * PAGE_SIZE);
b69ab31206 const hasAdditionalPages = filesSubset.length > PAGE_SIZE;
b69ab31207
b69ab31208 // We paginate files, but also paginate our fetches for generated statuses
b69ab31209 // at a larger granularity. This allows all manual files within that window
b69ab31210 // to be sorted to the front. This wider pagination is neceessary so we can control
b69ab31211 // how many files we query for generated statuses.
b69ab31212 const fetchPage = Math.floor(pageNum - (pageNum % PAGE_FETCH_COUNT));
b69ab31213 const fetchRangeStart = fetchPage * PAGE_SIZE;
b69ab31214 const fetchRangeEnd = (fetchPage + PAGE_FETCH_COUNT) * PAGE_SIZE;
b69ab31215 const filesToQueryGeneratedStatus = useMemo(
b69ab31216 () => filesSubset.slice(fetchRangeStart, fetchRangeEnd).map(f => f.path),
b69ab31217 [filesSubset, fetchRangeStart, fetchRangeEnd],
b69ab31218 );
b69ab31219
b69ab31220 const filesMissingDueToFetchLimit =
b69ab31221 filesToQueryGeneratedStatus.length === 0 && totalFiles > 0 && filesSubset.length > 0;
b69ab31222
b69ab31223 const generatedStatuses = useGeneratedFileStatuses(filesToQueryGeneratedStatus);
b69ab31224 const filesToSort = filesSubset.slice(fetchRangeStart, fetchRangeEnd);
b69ab31225 filesToSort.sort((a, b) => {
b69ab31226 const genStatA = generatedStatuses[a.path] ?? 0;
b69ab31227 const genStatB = generatedStatuses[b.path] ?? 0;
b69ab31228 return genStatA - genStatB;
b69ab31229 });
b69ab31230 const filesToShow = filesToSort.slice(rangeStart - fetchRangeStart, rangeEnd - fetchRangeStart);
b69ab31231 const root = useAtomValue(repoRootAtom);
b69ab31232 const currSubmodulePaths = useAtomValue(submodulePathsByRoot(root));
b69ab31233 const processedFiles = useDeepMemo(
b69ab31234 () => processChangedFiles(filesToShow, currSubmodulePaths),
b69ab31235 [filesToShow, currSubmodulePaths],
b69ab31236 );
b69ab31237
b69ab31238 const prefixes: {key: string; prefix: string}[] = useMemo(
b69ab31239 () => Internal.repoPrefixes ?? [{key: 'default', prefix: ''}],
b69ab31240 [],
b69ab31241 );
b69ab31242 const firstNonDefaultPrefix = prefixes.find(
b69ab31243 p => p.prefix.length > 0 && filesToSort.some(f => f.path.indexOf(p.prefix) === 0),
b69ab31244 );
b69ab31245 const shouldShowRepoHeaders =
b69ab31246 prefixes.length > 1 &&
b69ab31247 firstNonDefaultPrefix != null &&
b69ab31248 filesToSort.find(f => f.path.indexOf(firstNonDefaultPrefix?.prefix) === -1) != null;
b69ab31249
b69ab31250 const filesByPrefix = new Map<string, Array<UIChangedFile>>();
b69ab31251 for (const file of processedFiles) {
b69ab31252 for (const {key, prefix} of prefixes) {
b69ab31253 if (file.path.indexOf(prefix) === 0) {
b69ab31254 if (!filesByPrefix.has(key)) {
b69ab31255 filesByPrefix.set(key, []);
b69ab31256 }
b69ab31257 filesByPrefix.get(key)?.push(file);
b69ab31258 break;
b69ab31259 }
b69ab31260 }
b69ab31261 }
b69ab31262
b69ab31263 useEffect(() => {
b69ab31264 // If the list of files is updated to have fewer files, we need to reset
b69ab31265 // the pageNum state to be in the proper range again.
b69ab31266 const lastPageIndex = Math.floor((totalFiles - 1) / PAGE_SIZE);
b69ab31267 if (pageNum > lastPageIndex) {
b69ab31268 setPageNum(Math.max(0, lastPageIndex));
b69ab31269 }
b69ab31270 }, [totalFiles, pageNum]);
b69ab31271
b69ab31272 return (
b69ab31273 <div className="changed-files" data-testid="changed-files">
b69ab31274 {totalFiles > filesToShow.length ? (
b69ab31275 <Banner
b69ab31276 key={'alert'}
b69ab31277 icon={<Icon icon="info" />}
b69ab31278 buttons={
b69ab31279 hasAdditionalPages ? (
b69ab31280 <div className="changed-files-pages-buttons">
b69ab31281 <Tooltip title={t('See previous page of files')}>
b69ab31282 <Button
b69ab31283 data-testid="changed-files-previous-page"
b69ab31284 icon
b69ab31285 disabled={pageNum === 0}
b69ab31286 onClick={() => {
b69ab31287 setPageNum(old => old - 1);
b69ab31288 }}>
b69ab31289 <Icon icon="arrow-left" />
b69ab31290 </Button>
b69ab31291 </Tooltip>
b69ab31292 <Tooltip title={t('See next page of files')}>
b69ab31293 <Button
b69ab31294 data-testid="changed-files-next-page"
b69ab31295 icon
b69ab31296 disabled={isLastPage}
b69ab31297 onClick={() => {
b69ab31298 setPageNum(old => old + 1);
b69ab31299 }}>
b69ab31300 <Icon icon="arrow-right" />
b69ab31301 </Button>
b69ab31302 </Tooltip>
b69ab31303 </div>
b69ab31304 ) : null
b69ab31305 }>
b69ab31306 {pageNum === 0 ? (
b69ab31307 <T replace={{$numShown: filesToShow.length, $total: totalFiles}}>
b69ab31308 Showing first $numShown files out of $total total
b69ab31309 </T>
b69ab31310 ) : (
b69ab31311 <T replace={{$rangeStart: rangeStart + 1, $rangeEnd: rangeEnd, $total: totalFiles}}>
b69ab31312 Showing files $rangeStart – $rangeEnd out of $total total
b69ab31313 </T>
b69ab31314 )}
b69ab31315 </Banner>
b69ab31316 ) : null}
b69ab31317 {filesMissingDueToFetchLimit ? (
b69ab31318 <Banner
b69ab31319 key="not-everything-fetched"
b69ab31320 icon={<Icon icon="warning" />}
b69ab31321 kind={BannerKind.warning}>
b69ab31322 <T replace={{$maxFiles: PAGE_SIZE * PAGE_FETCH_COUNT}}>
b69ab31323 There are more than $maxFiles files, not all files have been fetched
b69ab31324 </T>
b69ab31325 </Banner>
b69ab31326 ) : totalFiles > PAGE_SIZE * PAGE_FETCH_COUNT ? (
b69ab31327 <Banner key="too-many-files" icon={<Icon icon="warning" />} kind={BannerKind.warning}>
b69ab31328 <T replace={{$maxFiles: PAGE_SIZE * PAGE_FETCH_COUNT}}>
b69ab31329 There are more than $maxFiles files, some files may appear out of order
b69ab31330 </T>
b69ab31331 </Banner>
b69ab31332 ) : null}
b69ab31333 {displayType === 'tree' ? (
b69ab31334 <FileTree {...rest} files={processedFiles} displayType={displayType} />
b69ab31335 ) : shouldShowRepoHeaders ? (
b69ab31336 <SectionedFileList
b69ab31337 {...rest}
b69ab31338 filesByPrefix={filesByPrefix}
b69ab31339 displayType={displayType}
b69ab31340 generatedStatuses={generatedStatuses}
b69ab31341 />
b69ab31342 ) : (
b69ab31343 <LinearFileList
b69ab31344 {...rest}
b69ab31345 files={processedFiles}
b69ab31346 displayType={displayType}
b69ab31347 generatedStatuses={generatedStatuses}
b69ab31348 />
b69ab31349 )}
b69ab31350 </div>
b69ab31351 );
b69ab31352}
b69ab31353
b69ab31354const generatedFilesInitiallyExpanded = localStorageBackedAtom<boolean>(
b69ab31355 'isl.expand-generated-files',
b69ab31356 false,
b69ab31357);
b69ab31358
b69ab31359export const __TEST__ = {
b69ab31360 generatedFilesInitiallyExpanded,
b69ab31361};
b69ab31362
b69ab31363function LinearFileList(props: {
b69ab31364 files: Array<UIChangedFile>;
b69ab31365 displayType: ChangedFilesDisplayType;
b69ab31366 generatedStatuses: Record<RepoRelativePath, GeneratedStatus>;
b69ab31367 comparison: Comparison;
b69ab31368 selection?: UseUncommittedSelection;
b69ab31369 place?: Place;
b69ab31370}) {
b69ab31371 const {files, generatedStatuses, ...rest} = props;
b69ab31372
b69ab31373 const groupedByGenerated = group(files, file => generatedStatuses[file.path]);
b69ab31374 const [initiallyExpanded, setInitiallyExpanded] = useAtom(generatedFilesInitiallyExpanded);
b69ab31375
b69ab31376 function GeneratedFilesCollapsableSection(status: GeneratedStatus) {
b69ab31377 const group = groupedByGenerated[status] ?? [];
b69ab31378 if (group.length === 0) {
b69ab31379 return null;
b69ab31380 }
b69ab31381 return (
b69ab31382 <Collapsable
b69ab31383 title={
b69ab31384 <T
b69ab31385 replace={{
b69ab31386 $count: <Badge>{group.length}</Badge>,
b69ab31387 }}>
b69ab31388 {status === GeneratedStatus.PartiallyGenerated
b69ab31389 ? 'Partially Generated Files $count'
b69ab31390 : 'Generated Files $count'}
b69ab31391 </T>
b69ab31392 }
b69ab31393 startExpanded={status === GeneratedStatus.PartiallyGenerated || initiallyExpanded}
b69ab31394 onToggle={expanded => setInitiallyExpanded(expanded)}>
b69ab31395 {group.map(file => (
b69ab31396 <File key={file.path} {...rest} file={file} generatedStatus={status} />
b69ab31397 ))}
b69ab31398 </Collapsable>
b69ab31399 );
b69ab31400 }
b69ab31401
b69ab31402 return (
b69ab31403 <div className="changed-files-list-container">
b69ab31404 <div className="changed-files-list">
b69ab31405 {groupedByGenerated[GeneratedStatus.Manual]?.map(file => (
b69ab31406 <File
b69ab31407 key={file.path}
b69ab31408 {...rest}
b69ab31409 file={file}
b69ab31410 generatedStatus={generatedStatuses[file.path] ?? GeneratedStatus.Manual}
b69ab31411 />
b69ab31412 ))}
b69ab31413 {GeneratedFilesCollapsableSection(GeneratedStatus.PartiallyGenerated)}
b69ab31414 {GeneratedFilesCollapsableSection(GeneratedStatus.Generated)}
b69ab31415 </div>
b69ab31416 </div>
b69ab31417 );
b69ab31418}
b69ab31419
b69ab31420export type Place = 'main' | 'amend sidebar' | 'commit sidebar';
b69ab31421
b69ab31422export function UncommittedChanges({place}: {place: Place}) {
b69ab31423 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
b69ab31424 const error = useAtomValue(uncommittedChangesFetchError);
b69ab31425 // TODO: use dagWithPreviews instead, and update CommitOperation
b69ab31426 const headCommit = useAtomValue(latestHeadCommit);
b69ab31427 const schema = useAtomValue(commitMessageFieldsSchema);
b69ab31428 const template = useAtomValue(commitMessageTemplate);
b69ab31429
b69ab31430 const conflicts = useAtomValue(optimisticMergeConflicts);
b69ab31431
b69ab31432 const selection = useUncommittedSelection();
b69ab31433 const commitTitleRef = useRef<HTMLInputElement>(null);
b69ab31434
b69ab31435 const runOperation = useRunOperation();
b69ab31436
b69ab31437 const useV2SmartActions = useFeatureFlagSync(Internal.featureFlags?.SmartActionsRedesign);
b69ab31438
b69ab31439 const openCommitForm = useCallback(
b69ab31440 (which: 'commit' | 'amend') => {
b69ab31441 // make sure view is expanded
b69ab31442 writeAtom(islDrawerState, val => ({...val, right: {...val.right, collapsed: false}}));
b69ab31443
b69ab31444 // show head commit & set to correct mode
b69ab31445 writeAtom(selectedCommits, new Set());
b69ab31446 writeAtom(commitMode, which);
b69ab31447
b69ab31448 // Start editing fields when amending so you can go right into typing.
b69ab31449 if (which === 'amend') {
b69ab31450 writeAtom(forceNextCommitToEditAllFields, true);
b69ab31451 if (headCommit != null) {
b69ab31452 const latestMessage = readAtom(latestCommitMessageFields(headCommit.hash));
b69ab31453 if (latestMessage) {
b69ab31454 writeAtom(editedCommitMessages(headCommit.hash), {
b69ab31455 ...latestMessage,
b69ab31456 });
b69ab31457 }
b69ab31458 }
b69ab31459 }
b69ab31460
b69ab31461 const quickCommitTyped = commitTitleRef.current?.value;
b69ab31462 if (which === 'commit' && quickCommitTyped != null && quickCommitTyped != '') {
b69ab31463 writeAtom(editedCommitMessages('head'), value => ({
b69ab31464 ...value,
b69ab31465 Title: quickCommitTyped,
b69ab31466 }));
b69ab31467 // delete what was written in the quick commit form
b69ab31468 commitTitleRef.current != null && (commitTitleRef.current.value = '');
b69ab31469 }
b69ab31470 },
b69ab31471 [headCommit],
b69ab31472 );
b69ab31473
b69ab31474 const onConfirmQuickCommit = async () => {
b69ab31475 const shouldContinue = await confirmUnsavedFiles();
b69ab31476 if (!shouldContinue) {
b69ab31477 return;
b69ab31478 }
b69ab31479
b69ab31480 if (!(await confirmSuggestedEditsForFiles('quick-commit', 'accept', selection.selection))) {
b69ab31481 return;
b69ab31482 }
b69ab31483
b69ab31484 const titleEl = commitTitleRef.current;
b69ab31485 const title = titleEl?.value || template?.Title || temporaryCommitTitle();
b69ab31486 // use the template, unless a specific quick title is given
b69ab31487 const fields: CommitMessageFields = {...template, Title: title};
b69ab31488 const message = commitMessageFieldsToString(schema, fields);
b69ab31489 const allFiles = uncommittedChanges.map(file => file.path);
b69ab31490 const operation = getCommitOperation(message, headCommit, selection.selection, allFiles);
b69ab31491 selection.discardPartialSelections();
b69ab31492 runOperation(operation);
b69ab31493 if (titleEl) {
b69ab31494 // clear out message now that we've used it
b69ab31495 titleEl.value = '';
b69ab31496 }
b69ab31497 };
b69ab31498
b69ab31499 if (error) {
b69ab31500 return <ErrorNotice title={t('Failed to fetch Uncommitted Changes')} error={error} />;
b69ab31501 }
b69ab31502 if (uncommittedChanges.length === 0 && conflicts == null) {
b69ab31503 return null;
b69ab31504 }
b69ab31505 const allFilesSelected = selection.isEverythingSelected();
b69ab31506 const noFilesSelected = selection.isNothingSelected();
b69ab31507 const hasChunkSelection = selection.hasChunkSelection();
b69ab31508
b69ab31509 const allConflictsResolved =
b69ab31510 conflicts?.files?.every(conflict => conflict.status === 'Resolved') ?? false;
b69ab31511
b69ab31512 // only show addremove button if some files are untracked/missing
b69ab31513 const UNTRACKED_OR_MISSING = ['?', '!'];
b69ab31514 const addremoveButton = uncommittedChanges.some(file =>
b69ab31515 UNTRACKED_OR_MISSING.includes(file.status),
b69ab31516 ) ? (
b69ab31517 <Tooltip
b69ab31518 delayMs={DOCUMENTATION_DELAY}
b69ab31519 title={t('Add all untracked files and remove all missing files.')}>
b69ab31520 <Button
b69ab31521 icon
b69ab31522 key="addremove"
b69ab31523 data-testid="addremove-button"
b69ab31524 onClick={() => {
b69ab31525 // If all files are selected, no need to pass specific files to addremove.
b69ab31526 const filesToAddRemove = allFilesSelected
b69ab31527 ? []
b69ab31528 : uncommittedChanges
b69ab31529 .filter(file => UNTRACKED_OR_MISSING.includes(file.status))
b69ab31530 .filter(file => selection.isFullyOrPartiallySelected(file.path))
b69ab31531 .map(file => file.path);
b69ab31532 runOperation(new AddRemoveOperation(filesToAddRemove));
b69ab31533 }}>
b69ab31534 <Icon slot="start" icon="expand-all" />
b69ab31535 <T>Add/Remove</T>
b69ab31536 </Button>
b69ab31537 </Tooltip>
b69ab31538 ) : null;
b69ab31539
b69ab31540 const onShelve = async () => {
b69ab31541 if (!(await confirmSuggestedEditsForFiles('shelve', 'accept', selection.selection))) {
b69ab31542 return;
b69ab31543 }
b69ab31544
b69ab31545 const title = commitTitleRef.current?.value || undefined;
b69ab31546 const allFiles = uncommittedChanges.map(file => file.path);
b69ab31547 const operation = getShelveOperation(title, selection.selection, allFiles);
b69ab31548 runOperation(operation);
b69ab31549 };
b69ab31550
b69ab31551 const canAmend = headCommit && headCommit.phase !== 'public' && headCommit.successorInfo == null;
b69ab31552
b69ab31553 return (
b69ab31554 <div className="uncommitted-changes">
b69ab31555 {conflicts != null ? (
b69ab31556 <div className="conflicts-header">
b69ab31557 <strong>
b69ab31558 {allConflictsResolved ? (
b69ab31559 <T>All Merge Conflicts Resolved</T>
b69ab31560 ) : (
b69ab31561 <T>Unresolved Merge Conflicts</T>
b69ab31562 )}
b69ab31563 </strong>
b69ab31564 {conflicts.state === 'loading' ? (
b69ab31565 <div data-testid="merge-conflicts-spinner">
b69ab31566 <Icon icon="loading" />
b69ab31567 </div>
b69ab31568 ) : null}
b69ab31569 {allConflictsResolved ? null : (
b69ab31570 <T replace={{$cmd: conflicts.command}}>Resolve conflicts to continue $cmd</T>
b69ab31571 )}
b69ab31572 </div>
b69ab31573 ) : null}
b69ab31574 <div className="button-row">
b69ab31575 {conflicts != null ? (
b69ab31576 <MergeConflictButtons allConflictsResolved={allConflictsResolved} conflicts={conflicts} />
b69ab31577 ) : (
b69ab31578 <>
b69ab31579 <ChangedFileDisplayTypePicker />
b69ab31580 <OpenComparisonViewButton
b69ab31581 comparison={{
b69ab31582 type:
b69ab31583 place === 'amend sidebar'
b69ab31584 ? ComparisonType.HeadChanges
b69ab31585 : ComparisonType.UncommittedChanges,
b69ab31586 }}
b69ab31587 />
b69ab31588 <Button
b69ab31589 icon
b69ab31590 key="select-all"
b69ab31591 disabled={allFilesSelected}
b69ab31592 onClick={() => {
b69ab31593 selection.selectAll();
b69ab31594 }}>
b69ab31595 <Icon slot="start" icon="check-all" />
b69ab31596 <T>Select All</T>
b69ab31597 </Button>
b69ab31598 <Button
b69ab31599 icon
b69ab31600 key="deselect-all"
b69ab31601 data-testid="deselect-all-button"
b69ab31602 disabled={noFilesSelected}
b69ab31603 onClick={() => {
b69ab31604 selection.deselectAll();
b69ab31605 }}>
b69ab31606 <Icon slot="start" icon="close-all" />
b69ab31607 <T>Deselect All</T>
b69ab31608 </Button>
b69ab31609 {addremoveButton}
b69ab31610 <Tooltip
b69ab31611 delayMs={DOCUMENTATION_DELAY}
b69ab31612 title={t(
b69ab31613 'Discard selected uncommitted changes, including untracked files.\n\nNote: Changes will be irreversibly lost.',
b69ab31614 )}>
b69ab31615 <Button
b69ab31616 icon
b69ab31617 disabled={noFilesSelected}
b69ab31618 data-testid={'discard-all-selected-button'}
b69ab31619 onClick={async () => {
b69ab31620 if (
b69ab31621 !(await confirmSuggestedEditsForFiles('discard', 'reject', selection.selection))
b69ab31622 ) {
b69ab31623 return;
b69ab31624 }
b69ab31625 if (!(await platform.confirm(t('confirmDiscardChanges')))) {
b69ab31626 return;
b69ab31627 }
b69ab31628 if (allFilesSelected) {
b69ab31629 // all changes selected -> use clean goto rather than reverting each file. This is generally faster.
b69ab31630
b69ab31631 // to "discard", we need to both remove uncommitted changes
b69ab31632 runOperation(new DiscardOperation());
b69ab31633 // ...and delete untracked files.
b69ab31634 // 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.
b69ab31635 runOperation(new PurgeOperation());
b69ab31636 } else if (selection.hasChunkSelection()) {
b69ab31637 // TODO(quark): Make PartialDiscardOperation replace the above and below cases.
b69ab31638 const allFiles = uncommittedChanges.map(file => file.path);
b69ab31639 const operation = new PartialDiscardOperation(selection.selection, allFiles);
b69ab31640 selection.discardPartialSelections();
b69ab31641 runOperation(operation);
b69ab31642 } else {
b69ab31643 const selectedFiles = uncommittedChanges.filter(file =>
b69ab31644 selection.isFullyOrPartiallySelected(file.path),
b69ab31645 );
b69ab31646 const [selectedTrackedFiles, selectedUntrackedFiles] = partition(
b69ab31647 selectedFiles,
b69ab31648 file => file.status !== '?', // only untracked, not missing
b69ab31649 );
b69ab31650 // Added files should be first reverted, then purged, so they are not tracked and also deleted.
b69ab31651 // This way, the partial selection discard matches the non-partial discard.
b69ab31652 const addedFilesToAlsoPurge = selectedFiles.filter(file => file.status === 'A');
b69ab31653 if (selectedTrackedFiles.length > 0) {
b69ab31654 // only a subset of files selected -> we need to revert selected tracked files individually
b69ab31655 runOperation(new RevertOperation(selectedTrackedFiles.map(f => f.path)));
b69ab31656 }
b69ab31657 if (selectedUntrackedFiles.length > 0 || addedFilesToAlsoPurge.length > 0) {
b69ab31658 // untracked files must be purged separately to delete from disk.
b69ab31659 runOperation(
b69ab31660 new PurgeOperation(
b69ab31661 [...selectedUntrackedFiles, ...addedFilesToAlsoPurge].map(f => f.path),
b69ab31662 ),
b69ab31663 );
b69ab31664 }
b69ab31665 }
b69ab31666 }}>
b69ab31667 <Icon slot="start" icon="trashcan" />
b69ab31668 <T>Discard</T>
b69ab31669 </Button>
b69ab31670 </Tooltip>
b69ab31671 <AbsorbButton />
b69ab31672 {useV2SmartActions && <SmartActionsDropdown key="smartActions" />}
b69ab31673 </>
b69ab31674 )}
b69ab31675 </div>
b69ab31676 {conflicts != null ? (
b69ab31677 <ChangedFiles
b69ab31678 filesSubset={conflicts.files ?? []}
b69ab31679 totalFiles={conflicts.files?.length ?? 0}
b69ab31680 place={place}
b69ab31681 comparison={{
b69ab31682 type: ComparisonType.UncommittedChanges,
b69ab31683 }}
b69ab31684 />
b69ab31685 ) : (
b69ab31686 <ChangedFiles
b69ab31687 filesSubset={uncommittedChanges}
b69ab31688 totalFiles={uncommittedChanges.length}
b69ab31689 place={place}
b69ab31690 selection={selection}
b69ab31691 comparison={{
b69ab31692 type: ComparisonType.UncommittedChanges,
b69ab31693 }}
b69ab31694 />
b69ab31695 )}
b69ab31696 <UnsavedFilesCount />
b69ab31697 {conflicts != null || place !== 'main' ? null : (
b69ab31698 <div className="button-rows">
b69ab31699 <div className="button-row">
b69ab31700 <PendingDiffStats />
b69ab31701 </div>
b69ab31702 <div className="button-row">
b69ab31703 <span className="quick-commit-inputs">
b69ab31704 <Button
b69ab31705 icon
b69ab31706 disabled={noFilesSelected}
b69ab31707 data-testid="quick-commit-button"
b69ab31708 onClick={onConfirmQuickCommit}>
b69ab31709 <Icon slot="start" icon="plus" />
b69ab31710 <T>Commit</T>
b69ab31711 </Button>
b69ab31712 <HorizontallyGrowingTextField
b69ab31713 data-testid="quick-commit-title"
b69ab31714 placeholder="Title"
b69ab31715 ref={commitTitleRef}
b69ab31716 onKeyPress={e => {
b69ab31717 if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) {
b69ab31718 onConfirmQuickCommit();
b69ab31719 }
b69ab31720 }}
b69ab31721 />
b69ab31722 </span>
b69ab31723 <Button
b69ab31724 icon
b69ab31725 className="show-on-hover"
b69ab31726 onClick={() => {
b69ab31727 openCommitForm('commit');
b69ab31728 }}>
b69ab31729 <Icon slot="start" icon="edit" />
b69ab31730 <T>Commit as...</T>
b69ab31731 </Button>
b69ab31732 <Tooltip
b69ab31733 title={t(
b69ab31734 'Save selected uncommitted changes for later unshelving. Removes these changes from the working copy.',
b69ab31735 )}>
b69ab31736 <Button
b69ab31737 disabled={noFilesSelected || hasChunkSelection}
b69ab31738 icon
b69ab31739 className="show-on-hover"
b69ab31740 onClick={onShelve}>
b69ab31741 <Icon slot="start" icon="archive" />
b69ab31742 <T>Shelve</T>
b69ab31743 </Button>
b69ab31744 </Tooltip>
b69ab31745 <span className="show-on-hover">
b69ab31746 {!useV2SmartActions && <SmartActionsMenu key="smartActions" />}
b69ab31747 </span>
b69ab31748 </div>
b69ab31749 {canAmend && (
b69ab31750 <div className="button-row">
b69ab31751 <Button
b69ab31752 icon
b69ab31753 disabled={noFilesSelected || !headCommit}
b69ab31754 data-testid="uncommitted-changes-quick-amend-button"
b69ab31755 onClick={async () => {
b69ab31756 const shouldContinue = await confirmUnsavedFiles();
b69ab31757 if (!shouldContinue) {
b69ab31758 return;
b69ab31759 }
b69ab31760
b69ab31761 if (
b69ab31762 !(await confirmSuggestedEditsForFiles(
b69ab31763 'quick-amend',
b69ab31764 'accept',
b69ab31765 selection.selection,
b69ab31766 ))
b69ab31767 ) {
b69ab31768 return;
b69ab31769 }
b69ab31770
b69ab31771 const allFiles = uncommittedChanges.map(file => file.path);
b69ab31772 const operation = getAmendOperation(
b69ab31773 undefined,
b69ab31774 headCommit,
b69ab31775 selection.selection,
b69ab31776 allFiles,
b69ab31777 );
b69ab31778 selection.discardPartialSelections();
b69ab31779 runOperation(operation);
b69ab31780 }}>
b69ab31781 <Icon slot="start" icon="debug-step-into" />
b69ab31782 <T>Amend</T>
b69ab31783 </Button>
b69ab31784 <Button
b69ab31785 icon
b69ab31786 className="show-on-hover"
b69ab31787 onClick={() => {
b69ab31788 openCommitForm('amend');
b69ab31789 }}>
b69ab31790 <Icon slot="start" icon="edit" />
b69ab31791 <T>Amend as...</T>
b69ab31792 </Button>
b69ab31793 </div>
b69ab31794 )}
b69ab31795 </div>
b69ab31796 )}
b69ab31797 {place === 'main' && <ConflictingIncomingCommit />}
b69ab31798 </div>
b69ab31799 );
b69ab31800}
b69ab31801
b69ab31802const styles = stylex.create({
b69ab31803 conflictingIncomingContainer: {
b69ab31804 gap: 'var(--halfpad)',
b69ab31805 position: 'relative',
b69ab31806 paddingLeft: '20px',
b69ab31807 paddingTop: '5px',
b69ab31808 marginBottom: '-5px',
b69ab31809 color: 'var(--scm-added-foreground)',
b69ab31810 },
b69ab31811 downwardArrow: {
b69ab31812 position: 'absolute',
b69ab31813 top: '20px',
b69ab31814 left: '5px',
b69ab31815 },
b69ab31816});
b69ab31817
b69ab31818function ConflictingIncomingCommit() {
b69ab31819 const conflicts = useAtomValue(optimisticMergeConflicts);
b69ab31820 // "other" is the incoming / source / your commit
b69ab31821 const commit = useAtomGet(dagWithPreviews, conflicts?.hashes?.other);
b69ab31822 if (commit == null) {
b69ab31823 return null;
b69ab31824 }
b69ab31825 return (
b69ab31826 <Row xstyle={styles.conflictingIncomingContainer}>
b69ab31827 <DownwardArrow {...stylex.props(styles.downwardArrow)} />
b69ab31828 <Avatar username={commit.author} />
b69ab31829 <Commit
b69ab31830 commit={commit}
b69ab31831 hasChildren={false}
b69ab31832 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
b69ab31833 />
b69ab31834 </Row>
b69ab31835 );
b69ab31836}
b69ab31837
b69ab31838function MergeConflictButtons({
b69ab31839 conflicts,
b69ab31840 allConflictsResolved,
b69ab31841}: {
b69ab31842 conflicts: MergeConflicts;
b69ab31843 allConflictsResolved: boolean;
b69ab31844}) {
b69ab31845 const runOperation = useRunOperation();
b69ab31846 // usually we only care if the operation is queued or actively running,
b69ab31847 // but since we don't use optimistic state for continue/abort,
b69ab31848 // we also need to consider recently run commands to disable the buttons.
b69ab31849 // But only if the abort/continue command succeeded.
b69ab31850 // TODO: is this reliable? Is it possible to get stuck with buttons disabled because
b69ab31851 // we think it's still running?
b69ab31852 const lastRunOperation = useAtomValue(operationList).currentOperation;
b69ab31853 const justFinishedContinue =
b69ab31854 lastRunOperation?.operation instanceof ContinueOperation && lastRunOperation.exitCode === 0;
b69ab31855 const justFinishedAbort =
b69ab31856 lastRunOperation?.operation instanceof AbortMergeOperation && lastRunOperation.exitCode === 0;
b69ab31857 const isRunningContinue = !!useIsOperationRunningOrQueued(ContinueOperation);
b69ab31858 const isRunningAbort = !!useIsOperationRunningOrQueued(AbortMergeOperation);
b69ab31859 const isRunningResolveExternal = !!useIsOperationRunningOrQueued(
b69ab31860 ResolveInExternalMergeToolOperation,
b69ab31861 );
b69ab31862 const shouldDisableButtons =
b69ab31863 isRunningContinue ||
b69ab31864 isRunningAbort ||
b69ab31865 isRunningResolveExternal ||
b69ab31866 justFinishedContinue ||
b69ab31867 justFinishedAbort;
b69ab31868
b69ab31869 const externalMergeTool = useAtomValue(externalMergeToolAtom);
b69ab31870
b69ab31871 const useV2SmartActions = useFeatureFlagSync(Internal.featureFlags?.SmartActionsRedesign);
b69ab31872 const branchMerge = Internal.getSubtreeContinueOperation?.(dagWithPreviews, conflicts);
b69ab31873
b69ab31874 return (
b69ab31875 <Row style={{flexWrap: 'wrap', marginBottom: 'var(--pad)'}}>
b69ab31876 <Button
b69ab31877 primary
b69ab31878 key="continue"
b69ab31879 disabled={!allConflictsResolved || shouldDisableButtons}
b69ab31880 data-testid="conflict-continue-button"
b69ab31881 onClick={async () => {
b69ab31882 const conflictFiles =
b69ab31883 conflicts.state === 'loaded' ? conflicts.files.map(f => f.path) : [];
b69ab31884 if (!(await confirmSuggestedEditsForFiles('merge-continue', 'accept', conflictFiles))) {
b69ab31885 return;
b69ab31886 }
b69ab31887
b69ab31888 if (readAtom(shouldAutoResolveAllBeforeContinue)) {
b69ab31889 runOperation(new RunMergeDriversOperation());
b69ab31890 }
b69ab31891 if (branchMerge) {
b69ab31892 runOperation(branchMerge);
b69ab31893 } else {
b69ab31894 runOperation(new ContinueOperation());
b69ab31895 }
b69ab31896 }}>
b69ab31897 <Icon slot="start" icon={isRunningContinue ? 'loading' : 'debug-continue'} />
b69ab31898 <T>Continue</T>
b69ab31899 </Button>
b69ab31900 <Button
b69ab31901 key="abort"
b69ab31902 disabled={shouldDisableButtons}
b69ab31903 onClick={() => {
b69ab31904 const partialAbortAvailable = conflicts?.command === 'rebase';
b69ab31905 const isPartialAbort = partialAbortAvailable && readAtom(shouldPartialAbort);
b69ab31906 runOperation(new AbortMergeOperation(conflicts, isPartialAbort));
b69ab31907 }}>
b69ab31908 <Icon slot="start" icon={isRunningAbort ? 'loading' : 'circle-slash'} />
b69ab31909 <T>Abort</T>
b69ab31910 </Button>
b69ab31911 {useV2SmartActions ? (
b69ab31912 <SmartActionsDropdown />
b69ab31913 ) : (
b69ab31914 Internal.ResolveMergeConflictsWithAIButton && (
b69ab31915 <Internal.ResolveMergeConflictsWithAIButton
b69ab31916 conflicts={conflicts}
b69ab31917 disabled={allConflictsResolved || shouldDisableButtons}
b69ab31918 />
b69ab31919 )
b69ab31920 )}
b69ab31921 {externalMergeTool == null ? (
b69ab31922 platform.upsellExternalMergeTool ? (
b69ab31923 <Tooltip
b69ab31924 title={
b69ab31925 <div>
b69ab31926 <T replace={{$tool: <code>{externalMergeTool}</code>, $br: <br />}}>
b69ab31927 You can configure an external merge tool to use for resolving conflicts.$br
b69ab31928 </T>
b69ab31929 </div>
b69ab31930 }>
b69ab31931 <Button
b69ab31932 icon
b69ab31933 disabled={allConflictsResolved || shouldDisableButtons}
b69ab31934 onClick={() => {
b69ab31935 tracker.track('ClickedConfigureExternalMergeTool');
b69ab31936 const link = Internal.externalMergeToolDocsLink;
b69ab31937 if (link) {
b69ab31938 platform.openExternalLink(link);
b69ab31939 return;
b69ab31940 }
b69ab31941 platform.confirm(
b69ab31942 t('Configuring External Merge Tools'),
b69ab31943 t(
b69ab31944 'You can configure ISL to use an external merge tool for resolving conflicts.\n' +
b69ab31945 'Set both `ui.merge = mymergetool` and `merge-tool.mymergetool`.\n' +
b69ab31946 'See `sl help config.merge-tools` for more information about setting up merge tools.\n',
b69ab31947 ),
b69ab31948 );
b69ab31949 }}>
b69ab31950 <Icon icon="gear" />
b69ab31951 <T>Configure External Merge Tool</T>
b69ab31952 </Button>
b69ab31953 </Tooltip>
b69ab31954 ) : null
b69ab31955 ) : (
b69ab31956 <Tooltip
b69ab31957 title={
b69ab31958 <div>
b69ab31959 <T replace={{$tool: <code>{externalMergeTool}</code>, $br: <br />}}>
b69ab31960 Open your configured external merge tool $tool to resolve all the conflicts.$br
b69ab31961 Waits for the merge tool to exit before continuing.
b69ab31962 </T>
b69ab31963 {allConflictsResolved ? (
b69ab31964 <>
b69ab31965 <br />
b69ab31966 <T>Disabled since all conflicts have been resolved.</T>
b69ab31967 </>
b69ab31968 ) : null}
b69ab31969 </div>
b69ab31970 }>
b69ab31971 <Button
b69ab31972 icon
b69ab31973 disabled={allConflictsResolved || shouldDisableButtons}
b69ab31974 onClick={() => {
b69ab31975 runOperation(new ResolveInExternalMergeToolOperation(externalMergeTool));
b69ab31976 }}>
b69ab31977 <Icon icon="link-external" />
b69ab31978 <T>Open External Merge Tool</T>
b69ab31979 </Button>
b69ab31980 </Tooltip>
b69ab31981 )}
b69ab31982 {Internal.showInlineAutoRunMergeDriversOption === true && (
b69ab31983 <AutoResolveSettingCheckbox subtle />
b69ab31984 )}
b69ab31985 {conflicts?.command === 'rebase' && <PartialAbortSettingCheckbox subtle />}
b69ab31986 </Row>
b69ab31987 );
b69ab31988}