5.4 KB175 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 {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether';
9import type {DiffType} from 'shared/patch/types';
10import type {RepoPath} from 'shared/types/common';
11
12import {Button} from 'isl-components/Button';
13import {Icon} from 'isl-components/Icon';
14import {Tooltip} from 'isl-components/Tooltip';
15import React from 'react';
16import {t} from '../../i18n';
17import platform from '../../platform';
18
19import './SplitDiffHunk.css';
20
21/**
22 * Decides the icon of the file header.
23 * Subset of `DiffType` - no Copied for Removed.
24 */
25export enum IconType {
26 Modified = 'Modified',
27 Added = 'Added',
28 Removed = 'Removed',
29}
30
31export function diffTypeToIconType(diffType?: DiffType): IconType {
32 if (diffType != null) {
33 const diffTypeStr = diffType as string;
34 if (diffTypeStr === 'Added' || diffTypeStr === 'Removed' || diffTypeStr === 'Modified') {
35 return diffTypeStr as IconType;
36 }
37 }
38 // "Copied" and "Renamed" should only apply to new files.
39 return IconType.Added;
40}
41
42export function FileHeader({
43 path,
44 copyFrom,
45 iconType,
46 open,
47 onChangeOpen,
48 fileActions,
49}: {
50 path: RepoPath;
51 copyFrom?: RepoPath;
52 iconType?: IconType;
53 fileActions?: JSX.Element;
54} & EnsureAssignedTogether<{
55 open: boolean;
56 onChangeOpen: (open: boolean) => void;
57}>) {
58 // Even though the enclosing <SplitDiffView> will have border-radius set, we
59 // have to define it again here or things don't look right.
60 const color =
61 iconType === undefined ? 'var(--scm-modified-foreground)' : iconTypeToColor[iconType];
62
63 const pathSeparator = '/';
64 const pathParts = path.split(pathSeparator);
65
66 // show: dir1 / dir2 / ... / dir9 / file
67 // ^^^^ ^^^^ ^^^^
68 // copy: full path "dir9/file" "file"
69
70 // with copyFrom: "dir1/dir2/dir3/foo" renamed to "dir1/dir2/dir4/bar"
71 // show: dir1 / dir2 / { dir3 / foo -> dir4 / bar }
72 // commonPrefixLen = 2 # (dir1 / dir2)
73 // copyFromRest = "dir3/foo"
74 let commonPrefixLen = -1;
75 let copyFromRest = '';
76 if (copyFrom != null && copyFrom !== path) {
77 const copyFromParts = copyFrom.split(pathSeparator);
78 commonPrefixLen = commonPrefixLength(pathParts, copyFromParts);
79 copyFromRest = copyFromParts.slice(commonPrefixLen).join(pathSeparator);
80 }
81
82 const copySpan = (s: string) => <span className="file-header-copyfrom-path">{s}</span>;
83 const filePathParts = pathParts.map((part, idx) => {
84 const pathSoFar = pathParts.slice(idx).join(pathSeparator);
85 let copyFromLeft = null;
86 let copyFromRight = null;
87 if (idx === commonPrefixLen && copyFromRest.length > 0) {
88 // Insert "{" (when commonPrefix is not empty), " copyFromRest ->".
89 const prefix = commonPrefixLen > 0 ? '{ ' : '';
90 copyFromLeft = (
91 <Tooltip
92 title={t('Renamed or copied from $path', {replace: {$path: copyFrom ?? ''}})}
93 delayMs={100}
94 placement="bottom">
95 {copySpan(`${prefix}${copyFromRest} →`)}
96 </Tooltip>
97 );
98 }
99 if (idx + 1 === pathParts.length && commonPrefixLen > 0 && copyFromRest.length > 0) {
100 // Append "}" (when commonPrefix is not empty)
101 copyFromRight = copySpan('}');
102 }
103 return (
104 <React.Fragment key={idx}>
105 {copyFromLeft}
106 <span className={'file-header-copyable-path'}>
107 <Tooltip
108 component={() => (
109 <span className="file-header-copyable-path-hover">
110 {t('Copy $path', {replace: {$path: pathSoFar}})}
111 </span>
112 )}
113 delayMs={100}
114 placement="bottom">
115 <span onClick={() => platform.clipboardCopy(pathSoFar)}>
116 {part}
117 {idx < pathParts.length - 1 ? pathSeparator : ''}
118 </span>
119 </Tooltip>
120 </span>
121 {copyFromRight}
122 </React.Fragment>
123 );
124 });
125
126 return (
127 <div
128 className={`split-diff-view-file-header file-header-${open ? 'open' : 'collapsed'}`}
129 style={{color}}>
130 {onChangeOpen && (
131 <Button
132 icon
133 className="split-diff-view-file-header-open-button"
134 data-testid={`split-diff-view-file-header-${open ? 'collapse' : 'expand'}-button`}
135 onClick={() => onChangeOpen(!open)}>
136 <Icon icon={open ? 'chevron-down' : 'chevron-right'} />
137 </Button>
138 )}
139 {iconType !== undefined && (
140 <Tooltip title={iconTypeToTooltip[iconType]}>
141 <Icon icon={iconTypeToIcon[iconType]} />
142 </Tooltip>
143 )}
144 <div className="split-diff-view-file-path-parts">{filePathParts}</div>
145 {fileActions}
146 </div>
147 );
148}
149
150const iconTypeToColor: Record<keyof typeof IconType, string> = {
151 Modified: 'var(--scm-modified-foreground)',
152 Added: 'var(--scm-added-foreground)',
153 Removed: 'var(--scm-removed-foreground)',
154};
155
156const iconTypeToIcon: Record<keyof typeof IconType, string> = {
157 Modified: 'diff-modified',
158 Added: 'diff-added',
159 Removed: 'diff-removed',
160};
161
162const iconTypeToTooltip: Record<keyof typeof IconType, string> = {
163 Modified: t('This file was modified.'),
164 Added: t('This file was added.'),
165 Removed: t('This file was removed.'),
166};
167
168function commonPrefixLength<T>(a: Array<T>, b: Array<T>): number {
169 let i = 0;
170 while (i < a.length && i < b.length && a[i] === b[i]) {
171 i++;
172 }
173 return i;
174}
175