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