9.3 KB312 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 {ContextMenuItem} from 'shared/ContextMenu';
9import type {InternalTypes} from './InternalTypes';
10import type {CommitInfo} from './types';
11
12import * as stylex from '@stylexjs/stylex';
13import {Button} from 'isl-components/Button';
14import {Column} from 'isl-components/Flex';
15import {Icon} from 'isl-components/Icon';
16import {Tag} from 'isl-components/Tag';
17import {TextField} from 'isl-components/TextField';
18import {Tooltip} from 'isl-components/Tooltip';
19import {useAtomValue} from 'jotai';
20import {useState} from 'react';
21import {useContextMenu} from 'shared/ContextMenu';
22import {spacing} from '../../components/theme/tokens.stylex';
23import {tracker} from './analytics';
24import {
25 bookmarksDataStorage,
26 recommendedBookmarksAtom,
27 REMOTE_MASTER_BOOKMARK,
28} from './BookmarksData';
29import {Row} from './ComponentUtils';
30import {hiddenMasterFeatureAvailableAtom, shouldHideMasterAtom} from './HiddenMasterData';
31import {T, t} from './i18n';
32import {Internal} from './Internal';
33import {BookmarkCreateOperation} from './operations/BookmarkCreateOperation';
34import {BookmarkDeleteOperation} from './operations/BookmarkDeleteOperation';
35import {useRunOperation} from './operationsState';
36import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
37import {showModal} from './useModal';
38
39const styles = stylex.create({
40 stable: {
41 backgroundColor: 'var(--list-hover-background)',
42 color: 'var(--list-hover-foreground)',
43 },
44 fullLength: {
45 maxWidth: 'unset',
46 },
47 bookmarkTag: {
48 maxWidth: '300px',
49 display: 'flex',
50 alignItems: 'center',
51 gap: spacing.quarter,
52 },
53 modalButtonBar: {
54 justifyContent: 'flex-end',
55 },
56});
57
58export type BookmarkKind = 'remote' | 'local' | 'stable';
59
60function useShouldShowBookmark(): (bookmarkValue: string) => boolean {
61 const bookmarksData = useAtomValue(bookmarksDataStorage);
62 const shouldHideMaster = useAtomValue(shouldHideMasterAtom);
63 const hiddenMasterFeatureAvailable = useAtomValue(hiddenMasterFeatureAvailableAtom);
64 return (bookmarkValue: string): boolean => {
65 if (bookmarkValue === REMOTE_MASTER_BOOKMARK && hiddenMasterFeatureAvailable) {
66 const visibility = bookmarksData.masterBookmarkVisibility;
67 if (visibility === 'show') {
68 return true;
69 }
70 if (visibility === 'hide') {
71 return false;
72 }
73 // visibility === 'auto' or undefined - use sitevar config
74 return !shouldHideMaster;
75 }
76 return !bookmarksData.hiddenRemoteBookmarks.includes(bookmarkValue);
77 };
78}
79
80const logged = new Set<string>();
81function logExposureOncePerSession(location: string) {
82 if (logged.has(location)) {
83 return;
84 }
85 tracker.track('SawStableLocation', {extras: {location}});
86 logged.add(location);
87}
88
89export function getBookmarkAddons(
90 name: string,
91 showRecommendedIcon: boolean,
92 showWarningOnMaster: boolean,
93 tooltipOverride?: string,
94): {icon: string | undefined; tooltip: React.ReactNode | undefined} {
95 if (showRecommendedIcon) {
96 return {icon: 'star-full', tooltip: tooltipOverride ?? Internal.RecommendedBookmarkInfo?.()};
97 }
98 if (showWarningOnMaster && name === REMOTE_MASTER_BOOKMARK) {
99 return {icon: 'warning', tooltip: tooltipOverride ?? Internal.MasterBookmarkInfo?.()};
100 }
101 return {icon: undefined, tooltip: tooltipOverride};
102}
103
104export function Bookmark({
105 children,
106 kind,
107 fullLength,
108 tooltip,
109 icon,
110}: {
111 children: string;
112 kind: BookmarkKind;
113 fullLength?: boolean;
114 tooltip?: string | React.ReactNode;
115 icon?: string;
116}) {
117 const bookmark = children;
118 const contextMenu = useContextMenu(makeBookmarkContextMenuOptions);
119 const runOperation = useRunOperation();
120
121 function makeBookmarkContextMenuOptions() {
122 const items: Array<ContextMenuItem> = [];
123 if (kind === 'local') {
124 items.push({
125 label: <T replace={{$book: bookmark}}>Delete Bookmark "$book"</T>,
126 onClick: () => {
127 runOperation(new BookmarkDeleteOperation(bookmark));
128 },
129 });
130 }
131 return items;
132 }
133
134 if (kind === 'stable') {
135 logExposureOncePerSession(bookmark);
136 }
137
138 const inner = (
139 <Tag
140 onContextMenu={contextMenu}
141 xstyle={[
142 kind === 'stable' && styles.stable,
143 styles.bookmarkTag,
144 fullLength === true && styles.fullLength,
145 ]}>
146 {icon && <Icon icon={icon} size="XS" style={{display: 'flex', height: '12px'}} />}
147 {bookmark}
148 </Tag>
149 );
150 return tooltip ? <Tooltip title={tooltip}>{inner}</Tooltip> : inner;
151}
152
153export function AllBookmarksTruncated({
154 stable,
155 remote,
156 local,
157 fullRepoBranch,
158}: {
159 stable: ReadonlyArray<string | {value: string; description: string; isRecommended?: boolean}>;
160 remote: ReadonlyArray<string>;
161 local: ReadonlyArray<string>;
162 fullRepoBranch?: InternalTypes['FullRepoBranch'] | undefined;
163}) {
164 const recommendedBookmarks = useAtomValue(recommendedBookmarksAtom);
165 const showWarningOnMaster = Internal.shouldCheckRebase?.() ?? false;
166 const shouldShowBookmark = useShouldShowBookmark();
167
168 const FullRepoBranchBookmark = Internal.FullRepoBranchBookmark;
169 const compareFullRepoBranch = Internal.compareFullRepoBranch;
170
171 const finalBookmarks = (
172 [
173 ['local', local],
174 ['remote', remote],
175 ['stable', stable],
176 ] as const
177 )
178 .map(([kind, bookmarks]) =>
179 bookmarks
180 .filter(bookmark =>
181 shouldShowBookmark(typeof bookmark === 'string' ? bookmark : bookmark.value),
182 )
183 .filter(bookmark =>
184 compareFullRepoBranch ? compareFullRepoBranch(fullRepoBranch, bookmark) : true,
185 )
186 .map(bookmark => {
187 const value = typeof bookmark === 'string' ? bookmark : bookmark.value;
188 const isRecommended =
189 recommendedBookmarks.has(value) ||
190 (typeof bookmark === 'object' && bookmark.isRecommended === true);
191 const tooltipOverride = typeof bookmark === 'string' ? undefined : bookmark.description;
192 const {icon, tooltip} = getBookmarkAddons(
193 value,
194 isRecommended,
195 showWarningOnMaster,
196 tooltipOverride,
197 );
198
199 return {value, kind, tooltip, icon};
200 }),
201 )
202 .flat();
203 const NUM_TO_SHOW = fullRepoBranch == null ? 3 : 2;
204 const shownBookmarks = finalBookmarks.slice(0, NUM_TO_SHOW);
205 const hiddenBookmarks = finalBookmarks.slice(NUM_TO_SHOW);
206 const numTruncated = hiddenBookmarks.length;
207
208 return (
209 <>
210 {fullRepoBranch && FullRepoBranchBookmark && (
211 <FullRepoBranchBookmark branch={fullRepoBranch} />
212 )}
213 {shownBookmarks.map(({value, kind, tooltip, icon}) => (
214 <Bookmark key={value} kind={kind} tooltip={tooltip} icon={icon}>
215 {value}
216 </Bookmark>
217 ))}
218 {numTruncated > 0 && (
219 <Tooltip
220 component={() => (
221 <Column alignStart>
222 {hiddenBookmarks.map(({value, kind, tooltip, icon}) => (
223 <Bookmark key={value} kind={kind} tooltip={tooltip} icon={icon} fullLength>
224 {value}
225 </Bookmark>
226 ))}
227 </Column>
228 )}>
229 <Tag>
230 <T replace={{$n: numTruncated}}>+$n more</T>
231 </Tag>
232 </Tooltip>
233 )}
234 </>
235 );
236}
237
238export function Bookmarks({
239 bookmarks,
240 kind,
241}: {
242 bookmarks: ReadonlyArray<string | {value: string; description: string}>;
243 kind: BookmarkKind;
244}) {
245 const shouldShowBookmark = useShouldShowBookmark();
246
247 return (
248 <>
249 {bookmarks
250 .filter(bookmark =>
251 shouldShowBookmark(typeof bookmark === 'string' ? bookmark : bookmark.value),
252 )
253 .map(bookmark => {
254 const value = typeof bookmark === 'string' ? bookmark : bookmark.value;
255 const tooltip = typeof bookmark === 'string' ? undefined : bookmark.description;
256 return (
257 <Bookmark key={value} kind={kind} tooltip={tooltip}>
258 {value}
259 </Bookmark>
260 );
261 })}
262 </>
263 );
264}
265
266export async function createBookmarkAtCommit(commit: CommitInfo) {
267 await showModal({
268 type: 'custom',
269 title: <T>Create Bookmark</T>,
270 component: ({returnResultAndDismiss}: {returnResultAndDismiss: (data?: undefined) => void}) => (
271 <CreateBookmarkAtCommitModal commit={commit} dismiss={returnResultAndDismiss} />
272 ),
273 });
274}
275
276function CreateBookmarkAtCommitModal({commit, dismiss}: {commit: CommitInfo; dismiss: () => void}) {
277 const runOperation = useRunOperation();
278 const [bookmark, setBookmark] = useState('');
279 return (
280 <>
281 <TextField
282 autoFocus
283 value={bookmark}
284 onChange={e => setBookmark(e.currentTarget.value)}
285 aria-label={t('Bookmark Name')}
286 />
287 <Row {...stylex.props(styles.modalButtonBar)}>
288 <Button
289 onClick={() => {
290 dismiss();
291 }}>
292 <T>Cancel</T>
293 </Button>
294 <Button
295 primary
296 onClick={() => {
297 runOperation(
298 new BookmarkCreateOperation(
299 latestSuccessorUnlessExplicitlyObsolete(commit),
300 bookmark,
301 ),
302 );
303 dismiss();
304 }}
305 disabled={bookmark.trim().length === 0}>
306 <T>Create</T>
307 </Button>
308 </Row>
309 </>
310 );
311}
312