| 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 {ReactNode} from 'react'; |
| 9 | import type {Comparison} from 'shared/Comparison'; |
| 10 | import type {Place, UIChangedFile, VisualChangedFileStatus} from './UncommittedChanges'; |
| 11 | import type {UseUncommittedSelection} from './partialSelection'; |
| 12 | import type {ChangedFileStatus, GeneratedStatus} from './types'; |
| 13 | |
| 14 | import {Button} from 'isl-components/Button'; |
| 15 | import {Checkbox} from 'isl-components/Checkbox'; |
| 16 | import {Icon} from 'isl-components/Icon'; |
| 17 | import {isMac} from 'isl-components/OperatingSystem'; |
| 18 | import {Subtle} from 'isl-components/Subtle'; |
| 19 | import {Tooltip} from 'isl-components/Tooltip'; |
| 20 | import {useAtomValue} from 'jotai'; |
| 21 | import React from 'react'; |
| 22 | import {ComparisonType, labelForComparison, revsetForComparison} from 'shared/Comparison'; |
| 23 | import {useContextMenu} from 'shared/ContextMenu'; |
| 24 | import {basename, notEmpty} from 'shared/utils'; |
| 25 | import {copyUrlForFile, supportsBrowseUrlForHash} from './BrowseRepo'; |
| 26 | import {type ChangedFilesDisplayType} from './ChangedFileDisplayTypePicker'; |
| 27 | import {generatedStatusDescription, generatedStatusToLabel} from './GeneratedFile'; |
| 28 | import {PartialFileSelectionWithMode} from './PartialFileSelection'; |
| 29 | import {confirmSuggestedEditsForFiles} from './SuggestedEdits'; |
| 30 | import {SuspenseBoundary} from './SuspenseBoundary'; |
| 31 | import {holdingAltAtom, holdingCtrlAtom} from './atoms/keyboardAtoms'; |
| 32 | import {externalMergeToolAtom} from './externalMergeTool'; |
| 33 | import {T, t} from './i18n'; |
| 34 | import {readAtom} from './jotaiUtils'; |
| 35 | import {CONFLICT_SIDE_LABELS} from './mergeConflicts/consts'; |
| 36 | import {AddOperation} from './operations/AddOperation'; |
| 37 | import {ForgetOperation} from './operations/ForgetOperation'; |
| 38 | import {PurgeOperation} from './operations/PurgeOperation'; |
| 39 | import {ResolveInExternalMergeToolOperation} from './operations/ResolveInExternalMergeToolOperation'; |
| 40 | import {ResolveOperation, ResolveTool} from './operations/ResolveOperation'; |
| 41 | import {RevertOperation} from './operations/RevertOperation'; |
| 42 | import {RmOperation} from './operations/RmOperation'; |
| 43 | import {useRunOperation} from './operationsState'; |
| 44 | import {useUncommittedSelection} from './partialSelection'; |
| 45 | import platform from './platform'; |
| 46 | import {optimisticMergeConflicts} from './previews'; |
| 47 | import {copyAndShowToast} from './toast'; |
| 48 | import {ChangedFileMode, ConflictType, succeedableRevset} from './types'; |
| 49 | import {usePromise} from './usePromise'; |
| 50 | |
| 51 | /** |
| 52 | * Is the alt key currently held down, used to show full file paths. |
| 53 | * On windows, this actually uses the ctrl key instead to avoid conflicting with OS focus behaviors. |
| 54 | */ |
| 55 | const holdingModifiedKeyAtom = isMac ? holdingAltAtom : holdingCtrlAtom; |
| 56 | |
| 57 | export function File({ |
| 58 | file, |
| 59 | displayType, |
| 60 | comparison, |
| 61 | selection, |
| 62 | place, |
| 63 | generatedStatus, |
| 64 | }: { |
| 65 | file: UIChangedFile; |
| 66 | displayType: ChangedFilesDisplayType; |
| 67 | comparison?: Comparison; |
| 68 | selection?: UseUncommittedSelection; |
| 69 | place?: Place; |
| 70 | generatedStatus?: GeneratedStatus; |
| 71 | }) { |
| 72 | const clipboardCopy = (text: string) => copyAndShowToast(text); |
| 73 | |
| 74 | // Renamed files are files which have a copy field, where that path was also removed. |
| 75 | |
| 76 | // Visually show renamed files as if they were modified, even though sl treats them as added. |
| 77 | const [statusName, icon] = nameAndIconForFileStatus[file.visualStatus]; |
| 78 | |
| 79 | const generated = generatedStatusToLabel(generatedStatus); |
| 80 | |
| 81 | const contextMenu = useContextMenu(() => { |
| 82 | const options = [ |
| 83 | {label: t('Copy File Path'), onClick: () => clipboardCopy(file.path)}, |
| 84 | {label: t('Copy Filename'), onClick: () => clipboardCopy(basename(file.path))}, |
| 85 | {label: t('Open File'), onClick: () => platform.openFile(file.path)}, |
| 86 | ]; |
| 87 | |
| 88 | if (platform.openContainingFolder != null) { |
| 89 | options.push({ |
| 90 | label: t('Open Containing Folder'), |
| 91 | onClick: () => platform.openContainingFolder?.(file.path), |
| 92 | }); |
| 93 | } |
| 94 | if (comparison != null && platform.openDiff != null) { |
| 95 | options.push({ |
| 96 | label: t('Open Diff View ($comparison)', { |
| 97 | replace: {$comparison: labelForComparison(comparison)}, |
| 98 | }), |
| 99 | onClick: () => platform.openDiff?.(file.path, comparison), |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | if (comparison != null && readAtom(supportsBrowseUrlForHash)) { |
| 104 | options.push({ |
| 105 | label: t('Copy file URL'), |
| 106 | onClick: () => { |
| 107 | copyUrlForFile(file.path, comparison); |
| 108 | }, |
| 109 | }); |
| 110 | } |
| 111 | return options; |
| 112 | }); |
| 113 | |
| 114 | const runOperation = useRunOperation(); |
| 115 | |
| 116 | // Hold "alt" key to show full file paths instead of short form. |
| 117 | // This is a quick way to see where a file comes from without |
| 118 | // needing to go through the menu to change the rendering type. |
| 119 | const isHoldingAlt = useAtomValue(holdingModifiedKeyAtom); |
| 120 | |
| 121 | const tooltip = [file.tooltip, generatedStatusDescription(generatedStatus)] |
| 122 | .filter(notEmpty) |
| 123 | .join('\n\n'); |
| 124 | |
| 125 | const openFile = () => { |
| 126 | if (file.mode === ChangedFileMode.Submodule) { |
| 127 | return; |
| 128 | } |
| 129 | if (file.visualStatus === 'U') { |
| 130 | const tool = readAtom(externalMergeToolAtom); |
| 131 | if (tool != null) { |
| 132 | runOperation(new ResolveInExternalMergeToolOperation(tool, file.path)); |
| 133 | return; |
| 134 | } |
| 135 | } |
| 136 | platform.openFile(file.path); |
| 137 | }; |
| 138 | |
| 139 | return ( |
| 140 | <> |
| 141 | <div |
| 142 | className={`changed-file file-${statusName} file-${generated}`} |
| 143 | data-testid={`changed-file-${file.path}`} |
| 144 | onContextMenu={contextMenu} |
| 145 | key={file.path} |
| 146 | tabIndex={0} |
| 147 | onKeyUp={e => { |
| 148 | if (e.key === 'Enter') { |
| 149 | openFile(); |
| 150 | } |
| 151 | }}> |
| 152 | <FileSelectionCheckbox file={file} selection={selection} /> |
| 153 | <span className="changed-file-path" onClick={openFile}> |
| 154 | <Icon icon={icon} /> |
| 155 | <Tooltip title={tooltip} delayMs={2_000} placement="right"> |
| 156 | <span |
| 157 | className="changed-file-path-text" |
| 158 | onCopy={e => { |
| 159 | const selection = document.getSelection(); |
| 160 | if (selection) { |
| 161 | // we inserted LTR markers, remove them again on copy |
| 162 | e.clipboardData.setData( |
| 163 | 'text/plain', |
| 164 | selection.toString().replace(/\u200E/g, ''), |
| 165 | ); |
| 166 | e.preventDefault(); |
| 167 | } |
| 168 | }}> |
| 169 | {escapeForRTL( |
| 170 | displayType === 'tree' |
| 171 | ? file.path.slice(file.path.lastIndexOf('/') + 1) |
| 172 | : // Holding alt takes precedence over fish/short styles, but not tree. |
| 173 | displayType === 'fullPaths' || isHoldingAlt |
| 174 | ? file.path |
| 175 | : displayType === 'fish' |
| 176 | ? file.path |
| 177 | .split('/') |
| 178 | .map((a, i, arr) => (i === arr.length - 1 ? a : a[0])) |
| 179 | .join('/') |
| 180 | : file.label, |
| 181 | )} |
| 182 | </span> |
| 183 | </Tooltip> |
| 184 | </span> |
| 185 | {comparison != null && <FileActions file={file} comparison={comparison} place={place} />} |
| 186 | </div> |
| 187 | {place === 'main' && |
| 188 | selection?.isExpanded(file.path) && |
| 189 | file.mode !== ChangedFileMode.Submodule && <MaybePartialSelection file={file} />} |
| 190 | </> |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | const revertableStatues = new Set(['M', 'R', '!']); |
| 195 | const conflictStatuses = new Set<ChangedFileStatus>(['U', 'Resolved']); |
| 196 | function FileActions({ |
| 197 | comparison, |
| 198 | file, |
| 199 | place, |
| 200 | }: { |
| 201 | comparison: Comparison; |
| 202 | file: UIChangedFile; |
| 203 | place?: Place; |
| 204 | }) { |
| 205 | const runOperation = useRunOperation(); |
| 206 | const conflicts = useAtomValue(optimisticMergeConflicts); |
| 207 | |
| 208 | const conflictData = conflicts?.files?.find(f => f.path === file.path); |
| 209 | const label = labelForConflictType(conflictData?.conflictType); |
| 210 | let conflictLabel = null; |
| 211 | if (label) { |
| 212 | conflictLabel = <Subtle>{label}</Subtle>; |
| 213 | } |
| 214 | |
| 215 | const actions: Array<React.ReactNode> = []; |
| 216 | |
| 217 | if (platform.openDiff != null && !conflictStatuses.has(file.status)) { |
| 218 | actions.push( |
| 219 | <Tooltip title={t('Open diff view')} key="open-diff-view" delayMs={1000}> |
| 220 | <Button |
| 221 | className="file-show-on-hover" |
| 222 | icon |
| 223 | data-testid="file-open-diff-button" |
| 224 | onClick={() => { |
| 225 | platform.openDiff?.(file.path, comparison); |
| 226 | }}> |
| 227 | <Icon icon="request-changes" /> |
| 228 | </Button> |
| 229 | </Tooltip>, |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | if ( |
| 234 | (revertableStatues.has(file.status) && comparison.type !== ComparisonType.Committed) || |
| 235 | // special case: reverting does actually work for added files in the head commit |
| 236 | (comparison.type === ComparisonType.HeadChanges && file.status === 'A') |
| 237 | ) { |
| 238 | actions.push( |
| 239 | <Tooltip |
| 240 | title={ |
| 241 | comparison.type === ComparisonType.UncommittedChanges |
| 242 | ? t('Revert back to last commit') |
| 243 | : t('Revert changes made by this commit') |
| 244 | } |
| 245 | key="revert" |
| 246 | delayMs={1000}> |
| 247 | <Button |
| 248 | className="file-show-on-hover" |
| 249 | key={file.path} |
| 250 | icon |
| 251 | data-testid="file-revert-button" |
| 252 | onClick={async () => { |
| 253 | if (!(await confirmSuggestedEditsForFiles('revert', 'reject', [file.path]))) { |
| 254 | return; |
| 255 | } |
| 256 | |
| 257 | const ok = await platform.confirm( |
| 258 | comparison.type === ComparisonType.UncommittedChanges |
| 259 | ? t('Are you sure you want to revert $file?', {replace: {$file: file.path}}) |
| 260 | : t( |
| 261 | 'Are you sure you want to revert $file back to how it was just before the last commit? Uncommitted changes to this file will be lost.', |
| 262 | {replace: {$file: file.path}}, |
| 263 | ), |
| 264 | ); |
| 265 | if (!ok) { |
| 266 | return; |
| 267 | } |
| 268 | runOperation( |
| 269 | new RevertOperation( |
| 270 | [file.path], |
| 271 | comparison.type === ComparisonType.UncommittedChanges |
| 272 | ? undefined |
| 273 | : succeedableRevset(revsetForComparison(comparison)), |
| 274 | ), |
| 275 | ); |
| 276 | }}> |
| 277 | <Icon icon="discard" /> |
| 278 | </Button> |
| 279 | </Tooltip>, |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | if (comparison.type === ComparisonType.UncommittedChanges) { |
| 284 | if (file.status === 'A') { |
| 285 | actions.push( |
| 286 | <Tooltip |
| 287 | title={t('Stop tracking this file, without removing from the filesystem')} |
| 288 | key="forget" |
| 289 | delayMs={1000}> |
| 290 | <Button |
| 291 | className="file-show-on-hover" |
| 292 | key={file.path} |
| 293 | icon |
| 294 | onClick={() => { |
| 295 | runOperation(new ForgetOperation(file.path)); |
| 296 | }}> |
| 297 | <Icon icon="circle-slash" /> |
| 298 | </Button> |
| 299 | </Tooltip>, |
| 300 | ); |
| 301 | } else if (file.status === '?') { |
| 302 | const removeHint = |
| 303 | file.mode === ChangedFileMode.Submodule |
| 304 | ? t( |
| 305 | 'Please manually remove the submodule and clean up properly to avoid leaving unwanted side effects.', |
| 306 | ) |
| 307 | : t('Remove file'); |
| 308 | actions.push( |
| 309 | <Tooltip title={t('Start tracking this file')} key="add" delayMs={1000}> |
| 310 | <Button |
| 311 | className="file-show-on-hover" |
| 312 | key={file.path} |
| 313 | icon |
| 314 | onClick={() => runOperation(new AddOperation(file.path))}> |
| 315 | <Icon icon="add" /> |
| 316 | </Button> |
| 317 | </Tooltip>, |
| 318 | <Tooltip title={removeHint} key="remove" delayMs={1000}> |
| 319 | <Button |
| 320 | className="file-show-on-hover" |
| 321 | disabled={file.mode === ChangedFileMode.Submodule} |
| 322 | key={file.path} |
| 323 | icon |
| 324 | data-testid="file-action-delete" |
| 325 | onClick={async () => { |
| 326 | const ok = await platform.confirm( |
| 327 | t('Are you sure you want to delete $file?', {replace: {$file: file.path}}), |
| 328 | ); |
| 329 | if (!ok) { |
| 330 | return; |
| 331 | } |
| 332 | runOperation(new PurgeOperation([file.path])); |
| 333 | }}> |
| 334 | <Icon icon="trash" /> |
| 335 | </Button> |
| 336 | </Tooltip>, |
| 337 | ); |
| 338 | } else if (file.status === 'Resolved') { |
| 339 | actions.push( |
| 340 | <Tooltip title={t('Mark as unresolved')} key="unresolve-mark"> |
| 341 | <Button |
| 342 | key={file.path} |
| 343 | icon |
| 344 | onClick={() => runOperation(new ResolveOperation(file.path, ResolveTool.unmark))}> |
| 345 | <Icon icon="circle-slash" /> |
| 346 | </Button> |
| 347 | </Tooltip>, |
| 348 | ); |
| 349 | } else if (file.status === 'U') { |
| 350 | actions.push( |
| 351 | <Tooltip title={t('Mark as resolved')} key="resolve-mark"> |
| 352 | <Button |
| 353 | className="file-show-on-hover" |
| 354 | data-testid="file-action-resolve" |
| 355 | key={file.path} |
| 356 | icon |
| 357 | onClick={() => runOperation(new ResolveOperation(file.path, ResolveTool.mark))}> |
| 358 | <Icon icon="check" /> |
| 359 | </Button> |
| 360 | </Tooltip>, |
| 361 | ); |
| 362 | if ( |
| 363 | conflictData?.conflictType && |
| 364 | [ConflictType.DeletedInSource, ConflictType.DeletedInDest].includes( |
| 365 | conflictData.conflictType, |
| 366 | ) |
| 367 | ) { |
| 368 | actions.push( |
| 369 | <Tooltip title={t('Delete file')} key="resolve-delete"> |
| 370 | <Button |
| 371 | className="file-show-on-hover" |
| 372 | data-testid="file-action-resolve-delete" |
| 373 | icon |
| 374 | onClick={() => { |
| 375 | runOperation(new RmOperation(file.path, /* force */ true)); |
| 376 | // then explicitly mark the file as resolved |
| 377 | runOperation(new ResolveOperation(file.path, ResolveTool.mark)); |
| 378 | }}> |
| 379 | <Icon icon="trash" /> |
| 380 | </Button> |
| 381 | </Tooltip>, |
| 382 | ); |
| 383 | } else { |
| 384 | actions.push( |
| 385 | <Tooltip |
| 386 | title={t('Take $local', { |
| 387 | replace: {$local: CONFLICT_SIDE_LABELS.local}, |
| 388 | })} |
| 389 | key="resolve-local"> |
| 390 | <Button |
| 391 | className="file-show-on-hover" |
| 392 | key={file.path} |
| 393 | icon |
| 394 | onClick={() => runOperation(new ResolveOperation(file.path, ResolveTool.local))}> |
| 395 | <Icon icon="fold-up" /> |
| 396 | </Button> |
| 397 | </Tooltip>, |
| 398 | <Tooltip |
| 399 | title={t('Take $incoming', { |
| 400 | replace: {$incoming: CONFLICT_SIDE_LABELS.incoming}, |
| 401 | })} |
| 402 | key="resolve-other"> |
| 403 | <Button |
| 404 | className="file-show-on-hover" |
| 405 | key={file.path} |
| 406 | icon |
| 407 | onClick={() => runOperation(new ResolveOperation(file.path, ResolveTool.other))}> |
| 408 | <Icon icon="fold-down" /> |
| 409 | </Button> |
| 410 | </Tooltip>, |
| 411 | <Tooltip |
| 412 | title={t('Combine both $incoming and $local', { |
| 413 | replace: { |
| 414 | $local: CONFLICT_SIDE_LABELS.local, |
| 415 | $incoming: CONFLICT_SIDE_LABELS.incoming, |
| 416 | }, |
| 417 | })} |
| 418 | key="resolve-both"> |
| 419 | <Button |
| 420 | className="file-show-on-hover" |
| 421 | key={file.path} |
| 422 | icon |
| 423 | onClick={() => runOperation(new ResolveOperation(file.path, ResolveTool.both))}> |
| 424 | <Icon icon="fold" /> |
| 425 | </Button> |
| 426 | </Tooltip>, |
| 427 | ); |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | if (place === 'main' && conflicts == null && file.mode !== ChangedFileMode.Submodule) { |
| 432 | actions.push(<PartialSelectionAction file={file} key="partial-selection" />); |
| 433 | } |
| 434 | } |
| 435 | return ( |
| 436 | <div className="file-actions" data-testid="file-actions"> |
| 437 | {conflictLabel} |
| 438 | {actions} |
| 439 | </div> |
| 440 | ); |
| 441 | } |
| 442 | |
| 443 | function labelForConflictType(type?: ConflictType) { |
| 444 | switch (type) { |
| 445 | case ConflictType.DeletedInSource: |
| 446 | return t('(Deleted in $incoming)', { |
| 447 | replace: {$incoming: CONFLICT_SIDE_LABELS.incoming}, |
| 448 | }); |
| 449 | |
| 450 | case ConflictType.DeletedInDest: |
| 451 | return t('(Deleted in $local)', {replace: {$local: CONFLICT_SIDE_LABELS.local}}); |
| 452 | default: |
| 453 | return null; |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /** |
| 458 | * We render file paths with CSS text-direction: rtl, |
| 459 | * which allows the ellipsis overflow to appear on the left. |
| 460 | * However, rtl can have weird effects, such as moving leading '.' to the end. |
| 461 | * To fix this, it's enough to add a left-to-right marker at the start of the path |
| 462 | */ |
| 463 | function escapeForRTL(s: string): ReactNode { |
| 464 | return '\u200E' + s + '\u200E'; |
| 465 | } |
| 466 | |
| 467 | function FileSelectionCheckbox({ |
| 468 | file, |
| 469 | selection, |
| 470 | }: { |
| 471 | file: UIChangedFile; |
| 472 | selection?: UseUncommittedSelection; |
| 473 | }) { |
| 474 | const checked = selection?.isFullyOrPartiallySelected(file.path) ?? false; |
| 475 | return selection == null ? null : ( |
| 476 | <Checkbox |
| 477 | aria-label={t('$label $file', { |
| 478 | replace: {$label: checked ? 'unselect' : 'select', $file: file.path}, |
| 479 | })} |
| 480 | checked={checked} |
| 481 | indeterminate={selection.isPartiallySelected(file.path)} |
| 482 | data-testid={'file-selection-checkbox'} |
| 483 | onChange={checked => { |
| 484 | if (checked) { |
| 485 | if (file.renamedFrom != null) { |
| 486 | // Selecting a renamed file also selects the original, so they are committed/amended together |
| 487 | // the UI merges them visually anyway. |
| 488 | selection.select(file.renamedFrom, file.path); |
| 489 | } else { |
| 490 | selection.select(file.path); |
| 491 | } |
| 492 | } else { |
| 493 | if (file.renamedFrom != null) { |
| 494 | selection.deselect(file.renamedFrom, file.path); |
| 495 | } else { |
| 496 | selection.deselect(file.path); |
| 497 | } |
| 498 | } |
| 499 | }} |
| 500 | /> |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | function PartialSelectionAction({file}: {file: UIChangedFile}) { |
| 505 | const selection = useUncommittedSelection(); |
| 506 | |
| 507 | const handleClick = () => { |
| 508 | selection.toggleExpand(file.path); |
| 509 | }; |
| 510 | |
| 511 | return ( |
| 512 | <Tooltip |
| 513 | component={() => ( |
| 514 | <div style={{maxWidth: '300px'}}> |
| 515 | <div> |
| 516 | <T>Toggle chunk selection</T> |
| 517 | </div> |
| 518 | <div> |
| 519 | <Subtle> |
| 520 | <T> |
| 521 | Shows changed files in your commit and lets you select individual chunks or lines to |
| 522 | include. |
| 523 | </T> |
| 524 | </Subtle> |
| 525 | </div> |
| 526 | </div> |
| 527 | )}> |
| 528 | <Button className="file-show-on-hover" icon onClick={handleClick}> |
| 529 | <Icon icon="diff" /> |
| 530 | </Button> |
| 531 | </Tooltip> |
| 532 | ); |
| 533 | } |
| 534 | |
| 535 | // Left margin to "indented" by roughly a checkbox width. |
| 536 | const leftMarginStyle: React.CSSProperties = {marginLeft: 'calc(2.5 * var(--pad))'}; |
| 537 | |
| 538 | function MaybePartialSelection({file}: {file: UIChangedFile}) { |
| 539 | const fallback = ( |
| 540 | <div style={leftMarginStyle}> |
| 541 | <Icon icon="loading" /> |
| 542 | </div> |
| 543 | ); |
| 544 | return ( |
| 545 | <SuspenseBoundary fallback={fallback}> |
| 546 | <PartialSelectionPanel file={file} /> |
| 547 | </SuspenseBoundary> |
| 548 | ); |
| 549 | } |
| 550 | |
| 551 | function PartialSelectionPanel({file}: {file: UIChangedFile}) { |
| 552 | const path = file.path; |
| 553 | const selection = useUncommittedSelection(); |
| 554 | const chunkSelect = usePromise(selection.getChunkSelect(path)); |
| 555 | |
| 556 | return ( |
| 557 | <div style={leftMarginStyle}> |
| 558 | <PartialFileSelectionWithMode |
| 559 | chunkSelection={chunkSelect} |
| 560 | setChunkSelection={state => selection.editChunkSelect(path, state)} |
| 561 | mode="unified" |
| 562 | /> |
| 563 | </div> |
| 564 | ); |
| 565 | } |
| 566 | |
| 567 | /** |
| 568 | * Map for changed files statuses into classNames (for color & styles) and icon names. |
| 569 | */ |
| 570 | const nameAndIconForFileStatus: Record<VisualChangedFileStatus, [string, string]> = { |
| 571 | A: ['added', 'diff-added'], |
| 572 | M: ['modified', 'diff-modified'], |
| 573 | R: ['removed', 'diff-removed'], |
| 574 | '?': ['ignored', 'question'], |
| 575 | '!': ['missing', 'warning'], |
| 576 | U: ['unresolved', 'diff-ignored'], |
| 577 | Resolved: ['resolved', 'pass'], |
| 578 | Renamed: ['modified', 'diff-renamed'], |
| 579 | Copied: ['added', 'diff-added'], |
| 580 | }; |
| 581 | |