| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import type {Comparison} from 'shared/Comparison'; |
| 9 | import type {CommitMessageFields} from './CommitInfoView/types'; |
| 10 | import type {UseUncommittedSelection} from './partialSelection'; |
| 11 | import type { |
| 12 | ChangedFile, |
| 13 | ChangedFileMode, |
| 14 | ChangedFileStatus, |
| 15 | MergeConflicts, |
| 16 | RepoRelativePath, |
| 17 | } from './types'; |
| 18 | |
| 19 | import * as stylex from '@stylexjs/stylex'; |
| 20 | import {Badge} from 'isl-components/Badge'; |
| 21 | import {Banner, BannerKind} from 'isl-components/Banner'; |
| 22 | import {Button} from 'isl-components/Button'; |
| 23 | import {ErrorNotice} from 'isl-components/ErrorNotice'; |
| 24 | import {HorizontallyGrowingTextField} from 'isl-components/HorizontallyGrowingTextField'; |
| 25 | import {Icon} from 'isl-components/Icon'; |
| 26 | import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip'; |
| 27 | import {useAtom, useAtomValue} from 'jotai'; |
| 28 | import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; |
| 29 | import {ComparisonType} from 'shared/Comparison'; |
| 30 | import {useDeepMemo} from 'shared/hooks'; |
| 31 | import {group, partition} from 'shared/utils'; |
| 32 | import {Avatar} from './Avatar'; |
| 33 | import {File} from './ChangedFile'; |
| 34 | import { |
| 35 | ChangedFileDisplayTypePicker, |
| 36 | type ChangedFilesDisplayType, |
| 37 | changedFilesDisplayType, |
| 38 | } from './ChangedFileDisplayTypePicker'; |
| 39 | import {Collapsable} from './Collapsable'; |
| 40 | import {Commit} from './Commit'; |
| 41 | import { |
| 42 | commitMessageTemplate, |
| 43 | commitMode, |
| 44 | editedCommitMessages, |
| 45 | forceNextCommitToEditAllFields, |
| 46 | } from './CommitInfoView/CommitInfoState'; |
| 47 | import { |
| 48 | commitMessageFieldsSchema, |
| 49 | commitMessageFieldsToString, |
| 50 | } from './CommitInfoView/CommitMessageFields'; |
| 51 | import {PendingDiffStats} from './CommitInfoView/DiffStats'; |
| 52 | import {temporaryCommitTitle} from './CommitTitle'; |
| 53 | import {OpenComparisonViewButton} from './ComparisonView/OpenComparisonViewButton'; |
| 54 | import {Row} from './ComponentUtils'; |
| 55 | import {EmptyState} from './EmptyState'; |
| 56 | import {FileTree, FileTreeFolderHeader} from './FileTree'; |
| 57 | import {useGeneratedFileStatuses} from './GeneratedFile'; |
| 58 | import {Internal} from './Internal'; |
| 59 | import {AbsorbButton} from './StackActions'; |
| 60 | import {confirmSuggestedEditsForFiles} from './SuggestedEdits'; |
| 61 | import {processChangedFiles} from './UncommittedChangesUtils'; |
| 62 | import {UnsavedFilesCount, confirmUnsavedFiles} from './UnsavedFiles'; |
| 63 | import {tracker} from './analytics'; |
| 64 | import {latestCommitMessageFields} from './codeReview/CodeReviewInfo'; |
| 65 | import {islDrawerState} from './drawerState'; |
| 66 | import {externalMergeToolAtom} from './externalMergeTool'; |
| 67 | import {useFeatureFlagSync} from './featureFlags'; |
| 68 | import {T, t} from './i18n'; |
| 69 | import {DownwardArrow} from './icons/DownwardIcon'; |
| 70 | import {localStorageBackedAtom, readAtom, useAtomGet, writeAtom} from './jotaiUtils'; |
| 71 | import { |
| 72 | AutoResolveSettingCheckbox, |
| 73 | PartialAbortSettingCheckbox, |
| 74 | shouldAutoResolveAllBeforeContinue, |
| 75 | shouldPartialAbort, |
| 76 | } from './mergeConflicts/state'; |
| 77 | import {AbortMergeOperation} from './operations/AbortMergeOperation'; |
| 78 | import {AddRemoveOperation} from './operations/AddRemoveOperation'; |
| 79 | import {getAmendOperation} from './operations/AmendOperation'; |
| 80 | import {getCommitOperation} from './operations/CommitOperation'; |
| 81 | import {ContinueOperation} from './operations/ContinueMergeOperation'; |
| 82 | import {DiscardOperation, PartialDiscardOperation} from './operations/DiscardOperation'; |
| 83 | import {PurgeOperation} from './operations/PurgeOperation'; |
| 84 | import {ResolveInExternalMergeToolOperation} from './operations/ResolveInExternalMergeToolOperation'; |
| 85 | import {RevertOperation} from './operations/RevertOperation'; |
| 86 | import {RunMergeDriversOperation} from './operations/RunMergeDriversOperation'; |
| 87 | import {getShelveOperation} from './operations/ShelveOperation'; |
| 88 | import {operationList, useRunOperation} from './operationsState'; |
| 89 | import {useUncommittedSelection} from './partialSelection'; |
| 90 | import platform from './platform'; |
| 91 | import { |
| 92 | CommitPreview, |
| 93 | dagWithPreviews, |
| 94 | optimisticMergeConflicts, |
| 95 | uncommittedChangesWithPreviews, |
| 96 | useIsOperationRunningOrQueued, |
| 97 | } from './previews'; |
| 98 | import {repoRootAtom} from './repositoryData'; |
| 99 | import {selectedCommits} from './selection'; |
| 100 | import { |
| 101 | latestHeadCommit, |
| 102 | submodulePathsByRoot, |
| 103 | uncommittedChangesFetchError, |
| 104 | } from './serverAPIState'; |
| 105 | import {SmartActionsDropdown} from './smartActions/SmartActionsDropdown'; |
| 106 | import {SmartActionsMenu} from './smartActions/SmartActionsMenu'; |
| 107 | import {GeneratedStatus} from './types'; |
| 108 | |
| 109 | import './UncommittedChanges.css'; |
| 110 | |
| 111 | export 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 | |
| 125 | export type VisualChangedFileStatus = ChangedFileStatus | 'Renamed' | 'Copied'; |
| 126 | |
| 127 | type SectionProps = Omit<React.ComponentProps<typeof LinearFileList>, 'files'> & { |
| 128 | filesByPrefix: Map<string, Array<UIChangedFile>>; |
| 129 | }; |
| 130 | |
| 131 | function 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 | */ |
| 191 | export 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 | |
| 354 | const generatedFilesInitiallyExpanded = localStorageBackedAtom<boolean>( |
| 355 | 'isl.expand-generated-files', |
| 356 | false, |
| 357 | ); |
| 358 | |
| 359 | export const __TEST__ = { |
| 360 | generatedFilesInitiallyExpanded, |
| 361 | }; |
| 362 | |
| 363 | function 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 | |
| 420 | export type Place = 'main' | 'amend sidebar' | 'commit sidebar'; |
| 421 | |
| 422 | export 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 | |
| 802 | const 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 | |
| 818 | function 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 | |
| 838 | function 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 | |