15.0 KB456 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 {TypeaheadResult} from 'isl-components/Types';
9import type {ReactNode} from 'react';
10import type {BookmarkKind} from './Bookmark';
11import type {Result, StableInfo} from './types';
12
13import * as stylex from '@stylexjs/stylex';
14import {Banner, BannerKind} from 'isl-components/Banner';
15import {Button} from 'isl-components/Button';
16import {Checkbox} from 'isl-components/Checkbox';
17import {Dropdown} from 'isl-components/Dropdown';
18import {InlineErrorBadge} from 'isl-components/ErrorNotice';
19import {Icon} from 'isl-components/Icon';
20import {Kbd} from 'isl-components/Kbd';
21import {KeyCode, Modifier} from 'isl-components/KeyboardShortcuts';
22import {Subtle} from 'isl-components/Subtle';
23import {extractTokens} from 'isl-components/Tokens';
24import {Tooltip} from 'isl-components/Tooltip';
25import {Typeahead} from 'isl-components/Typeahead';
26import {atom, useAtom, useAtomValue} from 'jotai';
27import React, {useState} from 'react';
28import {firstLine, notEmpty} from 'shared/utils';
29import {spacing} from '../../components/theme/tokens.stylex';
30import {Bookmark, getBookmarkAddons} from './Bookmark';
31import {
32 addManualStable,
33 bookmarksDataStorage,
34 fetchedStablesAtom,
35 type MasterBookmarkVisibility,
36 recommendedBookmarksAtom,
37 recommendedBookmarksAvailableAtom,
38 REMOTE_MASTER_BOOKMARK,
39 remoteBookmarks,
40 removeManualStable,
41} from './BookmarksData';
42import serverAPI from './ClientToServerAPI';
43import {Column, Row, ScrollY} from './ComponentUtils';
44import {DropdownFields} from './DropdownFields';
45import {hiddenMasterFeatureAvailableAtom, shouldHideMasterAtom} from './HiddenMasterData';
46import {useCommandEvent} from './ISLShortcuts';
47import {Internal} from './Internal';
48import {T, t} from './i18n';
49import {readAtom} from './jotaiUtils';
50import {latestDag} from './serverAPIState';
51
52const styles = stylex.create({
53 container: {
54 alignItems: 'flex-start',
55 gap: spacing.double,
56 width: 500,
57 maxWidth: 500,
58 },
59 bookmarkGroup: {
60 alignItems: 'flex-start',
61 marginInline: spacing.half,
62 gap: spacing.half,
63 },
64 description: {
65 marginBottom: spacing.half,
66 },
67 masterBookmarkRow: {
68 alignItems: 'center',
69 gap: '8px',
70 },
71 masterBookmarkDropdown: {
72 fontSize: '11px',
73 padding: '1px 2px',
74 height: '20px',
75 },
76});
77
78export function BookmarksManagerMenu() {
79 const additionalToggles = useCommandEvent('ToggleBookmarksManagerDropdown');
80 const bookmarks = useAtomValue(remoteBookmarks);
81
82 if (bookmarks.length < 2) {
83 // No use showing bookmarks menu if there's only one remote bookmark
84 return null;
85 }
86
87 const menuButton = (
88 <Tooltip
89 component={dismiss => <BookmarksManager dismiss={dismiss} />}
90 trigger="click"
91 placement="bottom"
92 group="topbar"
93 title={
94 <T replace={{$shortcut: <Kbd keycode={KeyCode.M} modifiers={[Modifier.ALT]} />}}>
95 Bookmarks Manager ($shortcut)
96 </T>
97 }
98 additionalToggles={additionalToggles.asEventTarget()}>
99 <Button icon data-testid="bookmarks-manager-button">
100 <Icon icon="bookmark" />
101 </Button>
102 </Tooltip>
103 );
104
105 const Reminder = Internal.RecommendedBookmarkPrompt;
106 return Reminder ? <Reminder>{menuButton}</Reminder> : menuButton;
107}
108
109function BookmarksManager(_props: {dismiss: () => void}) {
110 const bookmarks = useAtomValue(remoteBookmarks);
111 const bookmarksData = useAtomValue(bookmarksDataStorage);
112 const recommendedBookmarks = useAtomValue(recommendedBookmarksAtom);
113 const recommendedBookmarksAvailable = useAtomValue(recommendedBookmarksAvailableAtom);
114 const enableRecommended = bookmarksData.useRecommendedBookmark && recommendedBookmarksAvailable;
115
116 // Place recommended bookmarks (and remote/master) first if enabled, then the rest
117 const priority = new Set(recommendedBookmarks).add(REMOTE_MASTER_BOOKMARK);
118 const orderedBookmarks =
119 enableRecommended && recommendedBookmarks.size > 0
120 ? [...bookmarks.filter(b => priority.has(b)), ...bookmarks.filter(b => !priority.has(b))]
121 : bookmarks;
122
123 return (
124 <DropdownFields
125 title={<T>Bookmarks Manager</T>}
126 icon="bookmark"
127 data-testid="bookmarks-manager-dropdown">
128 <Column xstyle={styles.container}>
129 {Internal.RecommendedBookmarkSection?.()}
130 <Section
131 title={<T>Remote Bookmarks</T>}
132 description={<T>Uncheck remote bookmarks you don't use to hide them</T>}>
133 <BookmarksList bookmarks={orderedBookmarks} kind="remote" />
134 </Section>
135 <StableLocationsSection />
136 </Column>
137 </DropdownFields>
138 );
139}
140
141const latestPublicCommitAtom = atom(get => {
142 const dag = get(latestDag);
143 const latestHash = dag.heads(dag.public_()).toArray()[0];
144 return latestHash ? dag.get(latestHash) : undefined;
145});
146
147function stableIsNewerThanMainWarning(latestPublicDate?: Date, info?: Result<StableInfo>) {
148 const isNewerThanLatest = info?.value && latestPublicDate && info.value.date > latestPublicDate;
149 return isNewerThanLatest ? (
150 <Banner kind={BannerKind.warning}>
151 <T>Stable is newer than latest pulled commit. Pull to fetch latest.</T>
152 </Banner>
153 ) : undefined;
154}
155
156function StableLocationsSection() {
157 const stableLocations = useAtomValue(fetchedStablesAtom);
158 const latestPublic = useAtomValue(latestPublicCommitAtom);
159
160 return (
161 <Section
162 title={<T>Stable Locations</T>}
163 description={
164 <T>
165 Commits that have had successful builds and warmed up caches for a particular build target
166 </T>
167 }>
168 <BookmarksList
169 bookmarks={
170 stableLocations?.special
171 ?.map(info => {
172 if (info.value == null) {
173 return undefined;
174 }
175 return {
176 ...info.value,
177 extra: stableIsNewerThanMainWarning(latestPublic?.date, info),
178 };
179 })
180 .filter(notEmpty) ?? []
181 }
182 kind="stable"
183 />
184 {stableLocations?.manual && (
185 <BookmarksList
186 bookmarks={Object.entries(stableLocations.manual)?.map(([name, info]) => {
187 const deleteButton = (
188 <Tooltip title={t('Remove this stable location')}>
189 <Button
190 icon
191 onClick={e => {
192 removeManualStable(name);
193 e.stopPropagation();
194 }}>
195 <Icon icon="trash" />
196 </Button>
197 </Tooltip>
198 );
199 if (info == null) {
200 return {
201 kind: 'custom',
202 custom: (
203 <Row>
204 {name}: <Icon icon="loading" />
205 </Row>
206 ),
207 };
208 }
209 if (info.error) {
210 return {
211 kind: 'custom',
212 custom: (
213 <Row>
214 {name}:{' '}
215 <InlineErrorBadge error={info.error}>
216 {firstLine(info.error.toString())}
217 </InlineErrorBadge>
218 {deleteButton}
219 </Row>
220 ),
221 };
222 }
223 return {
224 ...info.value,
225 extra: (
226 <Row>
227 {deleteButton}
228 {stableIsNewerThanMainWarning(latestPublic?.date, info)}
229 </Row>
230 ),
231 };
232 })}
233 kind="stable"
234 />
235 )}
236 {stableLocations?.repoSupportsCustomStables === true && <AddStableLocation />}
237 </Section>
238 );
239}
240
241let typeaheadOptionsPromise: Promise<Result<Array<TypeaheadResult>>> | undefined;
242const getStableLocationsTypeaheadOptions = () => {
243 if (typeaheadOptionsPromise != null) {
244 return typeaheadOptionsPromise;
245 }
246 typeaheadOptionsPromise = (async () => {
247 serverAPI.postMessage({type: 'fetchStableLocationAutocompleteOptions'});
248 const result = await serverAPI.nextMessageMatching(
249 'fetchedStableLocationAutocompleteOptions',
250 () => true,
251 );
252 return result.result;
253 })();
254 return typeaheadOptionsPromise;
255};
256
257const stableLocationsTypeaheadOptions = atom(getStableLocationsTypeaheadOptions);
258
259function AddStableLocation() {
260 const [showingInput, setShowingInput] = useState(false);
261 const [query, setQuery] = useState('');
262 const addRef = React.useRef<HTMLButtonElement>(null);
263 return (
264 <div style={{paddingTop: 'var(--pad)'}}>
265 {showingInput ? (
266 <div>
267 <Subtle>{Internal.StableLocationAddInformation?.()}</Subtle>
268 <Row>
269 <Typeahead
270 tokenString={query}
271 setTokenString={setQuery}
272 fetchTokens={async (query: string) => {
273 const fetchStartTimestamp = Date.now();
274 const options = await readAtom(stableLocationsTypeaheadOptions);
275 const normalized = query.toLowerCase();
276 return {
277 fetchStartTimestamp,
278 values:
279 options.value?.filter(
280 opt =>
281 opt.value.toLowerCase().includes(normalized) ||
282 opt.label.toLowerCase().includes(normalized),
283 ) ?? [],
284 };
285 }}
286 onSaveNewToken={() => {
287 addRef?.current?.focus();
288 }}
289 autoFocus
290 maxTokens={1}
291 />
292 <Button
293 ref={addRef}
294 primary
295 onClick={e => {
296 // only expect one token
297 const [[token]] = extractTokens(query);
298 const stable = token.trim();
299 if (stable) {
300 addManualStable(stable);
301 setQuery('');
302 setShowingInput(false);
303 }
304 e.stopPropagation();
305 }}>
306 <T>Add</T>
307 </Button>
308 </Row>
309 </div>
310 ) : (
311 <Button
312 icon
313 onClick={e => {
314 e.stopPropagation();
315 setShowingInput(true);
316
317 // Start fetching options as soon as we show the typeahead
318 getStableLocationsTypeaheadOptions();
319 }}>
320 <Icon icon="plus" />
321 <T>Add Stable Location</T>
322 </Button>
323 )}
324 </div>
325 );
326}
327
328export function Section({
329 title,
330 description,
331 children,
332}: {
333 title: ReactNode;
334 description?: ReactNode;
335 children: ReactNode;
336}) {
337 return (
338 <Column xstyle={styles.bookmarkGroup}>
339 <strong>{title}</strong>
340 {description && <Subtle {...stylex.props(styles.description)}>{description}</Subtle>}
341 {children}
342 </Column>
343 );
344}
345
346function BookmarksList({
347 bookmarks,
348 kind,
349}: {
350 bookmarks: Array<
351 | string
352 | (StableInfo & {extra?: ReactNode; kind?: undefined})
353 | {kind: 'custom'; custom: ReactNode}
354 >;
355 kind: BookmarkKind;
356}) {
357 const [bookmarksData, setBookmarksData] = useAtom(bookmarksDataStorage);
358 const recommendedBookmarks = useAtomValue(recommendedBookmarksAtom);
359 const recommendedBookmarksAvailable = useAtomValue(recommendedBookmarksAvailableAtom);
360 const showWarningOnMaster = Internal.shouldCheckRebase?.() ?? false;
361 const hiddenMasterFeatureAvailable = useAtomValue(hiddenMasterFeatureAvailableAtom);
362 const shouldAutoHideMaster = useAtomValue(shouldHideMasterAtom);
363
364 if (bookmarks.length == 0) {
365 return null;
366 }
367 return (
368 <ScrollY maxSize={300}>
369 <Column xstyle={styles.bookmarkGroup}>
370 {bookmarks.map(bookmark => {
371 if (typeof bookmark !== 'string' && bookmark.kind === 'custom') {
372 return bookmark.custom;
373 }
374 const name = typeof bookmark === 'string' ? bookmark : bookmark.name;
375 const extra = typeof bookmark === 'string' ? undefined : bookmark.extra;
376 const enableRecommended =
377 bookmarksData.useRecommendedBookmark && recommendedBookmarksAvailable;
378 const isRecommended = recommendedBookmarks.has(name);
379 const tooltipOverride = typeof bookmark === 'string' ? undefined : bookmark.info;
380 const {icon, tooltip} = getBookmarkAddons(
381 name,
382 isRecommended,
383 showWarningOnMaster,
384 tooltipOverride,
385 );
386
387 const disabled =
388 kind === 'remote' &&
389 enableRecommended &&
390 !isRecommended &&
391 name !== REMOTE_MASTER_BOOKMARK;
392
393 // For remote/master when hidden master feature is available, show 3-state dropdown
394 if (name === REMOTE_MASTER_BOOKMARK && hiddenMasterFeatureAvailable) {
395 const currentVisibility = bookmarksData.masterBookmarkVisibility ?? 'auto';
396 // Determine the label for "Auto" based on whether this repo would be auto-hidden
397 const autoLabel = shouldAutoHideMaster ? t('Auto (hide)') : t('Auto (show)');
398 return (
399 <Row key={name} xstyle={styles.masterBookmarkRow}>
400 <Bookmark fullLength kind={kind} tooltip={tooltip} icon={icon}>
401 {name}
402 </Bookmark>
403 <Tooltip
404 title={t(
405 'Control master branch visibility. "Auto" derives from the current repo checkout whether it should be shown or hidden.',
406 )}>
407 <Dropdown<{value: MasterBookmarkVisibility; name: string}>
408 value={currentVisibility}
409 xstyle={styles.masterBookmarkDropdown}
410 options={[
411 {value: 'auto', name: autoLabel},
412 {value: 'show', name: t('Show')},
413 {value: 'hide', name: t('Hide')},
414 ]}
415 onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
416 const newVisibility = e.target.value as MasterBookmarkVisibility;
417 setBookmarksData({
418 ...bookmarksData,
419 masterBookmarkVisibility: newVisibility,
420 });
421 }}
422 />
423 </Tooltip>
424 {extra}
425 </Row>
426 );
427 }
428
429 return (
430 <Checkbox
431 key={name}
432 checked={!bookmarksData.hiddenRemoteBookmarks.includes(name)}
433 disabled={disabled}
434 onChange={checked => {
435 let hiddenRemoteBookmarks = bookmarksData.hiddenRemoteBookmarks;
436
437 if (!checked) {
438 hiddenRemoteBookmarks = [...hiddenRemoteBookmarks, name];
439 } else {
440 hiddenRemoteBookmarks = hiddenRemoteBookmarks.filter(b => b !== name);
441 }
442
443 setBookmarksData({...bookmarksData, hiddenRemoteBookmarks});
444 }}>
445 <Bookmark fullLength key={name} kind={kind} tooltip={tooltip} icon={icon}>
446 {name}
447 </Bookmark>
448 {extra}
449 </Checkbox>
450 );
451 })}
452 </Column>
453 </ScrollY>
454 );
455}
456