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