19.2 KB581 lines
Blame
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
8import type {ReactNode} from 'react';
9import type {Comparison} from 'shared/Comparison';
10import type {Place, UIChangedFile, VisualChangedFileStatus} from './UncommittedChanges';
11import type {UseUncommittedSelection} from './partialSelection';
12import type {ChangedFileStatus, GeneratedStatus} from './types';
13
14import {Button} from 'isl-components/Button';
15import {Checkbox} from 'isl-components/Checkbox';
16import {Icon} from 'isl-components/Icon';
17import {isMac} from 'isl-components/OperatingSystem';
18import {Subtle} from 'isl-components/Subtle';
19import {Tooltip} from 'isl-components/Tooltip';
20import {useAtomValue} from 'jotai';
21import React from 'react';
22import {ComparisonType, labelForComparison, revsetForComparison} from 'shared/Comparison';
23import {useContextMenu} from 'shared/ContextMenu';
24import {basename, notEmpty} from 'shared/utils';
25import {copyUrlForFile, supportsBrowseUrlForHash} from './BrowseRepo';
26import {type ChangedFilesDisplayType} from './ChangedFileDisplayTypePicker';
27import {generatedStatusDescription, generatedStatusToLabel} from './GeneratedFile';
28import {PartialFileSelectionWithMode} from './PartialFileSelection';
29import {confirmSuggestedEditsForFiles} from './SuggestedEdits';
30import {SuspenseBoundary} from './SuspenseBoundary';
31import {holdingAltAtom, holdingCtrlAtom} from './atoms/keyboardAtoms';
32import {externalMergeToolAtom} from './externalMergeTool';
33import {T, t} from './i18n';
34import {readAtom} from './jotaiUtils';
35import {CONFLICT_SIDE_LABELS} from './mergeConflicts/consts';
36import {AddOperation} from './operations/AddOperation';
37import {ForgetOperation} from './operations/ForgetOperation';
38import {PurgeOperation} from './operations/PurgeOperation';
39import {ResolveInExternalMergeToolOperation} from './operations/ResolveInExternalMergeToolOperation';
40import {ResolveOperation, ResolveTool} from './operations/ResolveOperation';
41import {RevertOperation} from './operations/RevertOperation';
42import {RmOperation} from './operations/RmOperation';
43import {useRunOperation} from './operationsState';
44import {useUncommittedSelection} from './partialSelection';
45import platform from './platform';
46import {optimisticMergeConflicts} from './previews';
47import {copyAndShowToast} from './toast';
48import {ChangedFileMode, ConflictType, succeedableRevset} from './types';
49import {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 */
55const holdingModifiedKeyAtom = isMac ? holdingAltAtom : holdingCtrlAtom;
56
57export 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
194const revertableStatues = new Set(['M', 'R', '!']);
195const conflictStatuses = new Set<ChangedFileStatus>(['U', 'Resolved']);
196function 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
443function 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 */
463function escapeForRTL(s: string): ReactNode {
464 return '\u200E' + s + '\u200E';
465}
466
467function 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
504function 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.
536const leftMarginStyle: React.CSSProperties = {marginLeft: 'calc(2.5 * var(--pad))'};
537
538function 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
551function 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 */
570const 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