addons/isl/src/BookmarksManager.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {TypeaheadResult} from 'isl-components/Types';
b69ab319import type {ReactNode} from 'react';
b69ab3110import type {BookmarkKind} from './Bookmark';
b69ab3111import type {Result, StableInfo} from './types';
b69ab3112
b69ab3113import * as stylex from '@stylexjs/stylex';
b69ab3114import {Banner, BannerKind} from 'isl-components/Banner';
b69ab3115import {Button} from 'isl-components/Button';
b69ab3116import {Checkbox} from 'isl-components/Checkbox';
b69ab3117import {Dropdown} from 'isl-components/Dropdown';
b69ab3118import {InlineErrorBadge} from 'isl-components/ErrorNotice';
b69ab3119import {Icon} from 'isl-components/Icon';
b69ab3120import {Kbd} from 'isl-components/Kbd';
b69ab3121import {KeyCode, Modifier} from 'isl-components/KeyboardShortcuts';
b69ab3122import {Subtle} from 'isl-components/Subtle';
b69ab3123import {extractTokens} from 'isl-components/Tokens';
b69ab3124import {Tooltip} from 'isl-components/Tooltip';
b69ab3125import {Typeahead} from 'isl-components/Typeahead';
b69ab3126import {atom, useAtom, useAtomValue} from 'jotai';
b69ab3127import React, {useState} from 'react';
b69ab3128import {firstLine, notEmpty} from 'shared/utils';
b69ab3129import {spacing} from '../../components/theme/tokens.stylex';
b69ab3130import {Bookmark, getBookmarkAddons} from './Bookmark';
b69ab3131import {
b69ab3132 addManualStable,
b69ab3133 bookmarksDataStorage,
b69ab3134 fetchedStablesAtom,
b69ab3135 type MasterBookmarkVisibility,
b69ab3136 recommendedBookmarksAtom,
b69ab3137 recommendedBookmarksAvailableAtom,
b69ab3138 REMOTE_MASTER_BOOKMARK,
b69ab3139 remoteBookmarks,
b69ab3140 removeManualStable,
b69ab3141} from './BookmarksData';
b69ab3142import serverAPI from './ClientToServerAPI';
b69ab3143import {Column, Row, ScrollY} from './ComponentUtils';
b69ab3144import {DropdownFields} from './DropdownFields';
b69ab3145import {hiddenMasterFeatureAvailableAtom, shouldHideMasterAtom} from './HiddenMasterData';
b69ab3146import {useCommandEvent} from './ISLShortcuts';
b69ab3147import {Internal} from './Internal';
b69ab3148import {T, t} from './i18n';
b69ab3149import {readAtom} from './jotaiUtils';
b69ab3150import {latestDag} from './serverAPIState';
b69ab3151
b69ab3152const styles = stylex.create({
b69ab3153 container: {
b69ab3154 alignItems: 'flex-start',
b69ab3155 gap: spacing.double,
b69ab3156 width: 500,
b69ab3157 maxWidth: 500,
b69ab3158 },
b69ab3159 bookmarkGroup: {
b69ab3160 alignItems: 'flex-start',
b69ab3161 marginInline: spacing.half,
b69ab3162 gap: spacing.half,
b69ab3163 },
b69ab3164 description: {
b69ab3165 marginBottom: spacing.half,
b69ab3166 },
b69ab3167 masterBookmarkRow: {
b69ab3168 alignItems: 'center',
b69ab3169 gap: '8px',
b69ab3170 },
b69ab3171 masterBookmarkDropdown: {
b69ab3172 fontSize: '11px',
b69ab3173 padding: '1px 2px',
b69ab3174 height: '20px',
b69ab3175 },
b69ab3176});
b69ab3177
b69ab3178export function BookmarksManagerMenu() {
b69ab3179 const additionalToggles = useCommandEvent('ToggleBookmarksManagerDropdown');
b69ab3180 const bookmarks = useAtomValue(remoteBookmarks);
b69ab3181
b69ab3182 if (bookmarks.length < 2) {
b69ab3183 // No use showing bookmarks menu if there's only one remote bookmark
b69ab3184 return null;
b69ab3185 }
b69ab3186
b69ab3187 const menuButton = (
b69ab3188 <Tooltip
b69ab3189 component={dismiss => <BookmarksManager dismiss={dismiss} />}
b69ab3190 trigger="click"
b69ab3191 placement="bottom"
b69ab3192 group="topbar"
b69ab3193 title={
b69ab3194 <T replace={{$shortcut: <Kbd keycode={KeyCode.M} modifiers={[Modifier.ALT]} />}}>
b69ab3195 Bookmarks Manager ($shortcut)
b69ab3196 </T>
b69ab3197 }
b69ab3198 additionalToggles={additionalToggles.asEventTarget()}>
b69ab3199 <Button icon data-testid="bookmarks-manager-button">
b69ab31100 <Icon icon="bookmark" />
b69ab31101 </Button>
b69ab31102 </Tooltip>
b69ab31103 );
b69ab31104
b69ab31105 const Reminder = Internal.RecommendedBookmarkPrompt;
b69ab31106 return Reminder ? <Reminder>{menuButton}</Reminder> : menuButton;
b69ab31107}
b69ab31108
b69ab31109function BookmarksManager(_props: {dismiss: () => void}) {
b69ab31110 const bookmarks = useAtomValue(remoteBookmarks);
b69ab31111 const bookmarksData = useAtomValue(bookmarksDataStorage);
b69ab31112 const recommendedBookmarks = useAtomValue(recommendedBookmarksAtom);
b69ab31113 const recommendedBookmarksAvailable = useAtomValue(recommendedBookmarksAvailableAtom);
b69ab31114 const enableRecommended = bookmarksData.useRecommendedBookmark && recommendedBookmarksAvailable;
b69ab31115
b69ab31116 // Place recommended bookmarks (and remote/master) first if enabled, then the rest
b69ab31117 const priority = new Set(recommendedBookmarks).add(REMOTE_MASTER_BOOKMARK);
b69ab31118 const orderedBookmarks =
b69ab31119 enableRecommended && recommendedBookmarks.size > 0
b69ab31120 ? [...bookmarks.filter(b => priority.has(b)), ...bookmarks.filter(b => !priority.has(b))]
b69ab31121 : bookmarks;
b69ab31122
b69ab31123 return (
b69ab31124 <DropdownFields
b69ab31125 title={<T>Bookmarks Manager</T>}
b69ab31126 icon="bookmark"
b69ab31127 data-testid="bookmarks-manager-dropdown">
b69ab31128 <Column xstyle={styles.container}>
b69ab31129 {Internal.RecommendedBookmarkSection?.()}
b69ab31130 <Section
b69ab31131 title={<T>Remote Bookmarks</T>}
b69ab31132 description={<T>Uncheck remote bookmarks you don't use to hide them</T>}>
b69ab31133 <BookmarksList bookmarks={orderedBookmarks} kind="remote" />
b69ab31134 </Section>
b69ab31135 <StableLocationsSection />
b69ab31136 </Column>
b69ab31137 </DropdownFields>
b69ab31138 );
b69ab31139}
b69ab31140
b69ab31141const latestPublicCommitAtom = atom(get => {
b69ab31142 const dag = get(latestDag);
b69ab31143 const latestHash = dag.heads(dag.public_()).toArray()[0];
b69ab31144 return latestHash ? dag.get(latestHash) : undefined;
b69ab31145});
b69ab31146
b69ab31147function stableIsNewerThanMainWarning(latestPublicDate?: Date, info?: Result<StableInfo>) {
b69ab31148 const isNewerThanLatest = info?.value && latestPublicDate && info.value.date > latestPublicDate;
b69ab31149 return isNewerThanLatest ? (
b69ab31150 <Banner kind={BannerKind.warning}>
b69ab31151 <T>Stable is newer than latest pulled commit. Pull to fetch latest.</T>
b69ab31152 </Banner>
b69ab31153 ) : undefined;
b69ab31154}
b69ab31155
b69ab31156function StableLocationsSection() {
b69ab31157 const stableLocations = useAtomValue(fetchedStablesAtom);
b69ab31158 const latestPublic = useAtomValue(latestPublicCommitAtom);
b69ab31159
b69ab31160 return (
b69ab31161 <Section
b69ab31162 title={<T>Stable Locations</T>}
b69ab31163 description={
b69ab31164 <T>
b69ab31165 Commits that have had successful builds and warmed up caches for a particular build target
b69ab31166 </T>
b69ab31167 }>
b69ab31168 <BookmarksList
b69ab31169 bookmarks={
b69ab31170 stableLocations?.special
b69ab31171 ?.map(info => {
b69ab31172 if (info.value == null) {
b69ab31173 return undefined;
b69ab31174 }
b69ab31175 return {
b69ab31176 ...info.value,
b69ab31177 extra: stableIsNewerThanMainWarning(latestPublic?.date, info),
b69ab31178 };
b69ab31179 })
b69ab31180 .filter(notEmpty) ?? []
b69ab31181 }
b69ab31182 kind="stable"
b69ab31183 />
b69ab31184 {stableLocations?.manual && (
b69ab31185 <BookmarksList
b69ab31186 bookmarks={Object.entries(stableLocations.manual)?.map(([name, info]) => {
b69ab31187 const deleteButton = (
b69ab31188 <Tooltip title={t('Remove this stable location')}>
b69ab31189 <Button
b69ab31190 icon
b69ab31191 onClick={e => {
b69ab31192 removeManualStable(name);
b69ab31193 e.stopPropagation();
b69ab31194 }}>
b69ab31195 <Icon icon="trash" />
b69ab31196 </Button>
b69ab31197 </Tooltip>
b69ab31198 );
b69ab31199 if (info == null) {
b69ab31200 return {
b69ab31201 kind: 'custom',
b69ab31202 custom: (
b69ab31203 <Row>
b69ab31204 {name}: <Icon icon="loading" />
b69ab31205 </Row>
b69ab31206 ),
b69ab31207 };
b69ab31208 }
b69ab31209 if (info.error) {
b69ab31210 return {
b69ab31211 kind: 'custom',
b69ab31212 custom: (
b69ab31213 <Row>
b69ab31214 {name}:{' '}
b69ab31215 <InlineErrorBadge error={info.error}>
b69ab31216 {firstLine(info.error.toString())}
b69ab31217 </InlineErrorBadge>
b69ab31218 {deleteButton}
b69ab31219 </Row>
b69ab31220 ),
b69ab31221 };
b69ab31222 }
b69ab31223 return {
b69ab31224 ...info.value,
b69ab31225 extra: (
b69ab31226 <Row>
b69ab31227 {deleteButton}
b69ab31228 {stableIsNewerThanMainWarning(latestPublic?.date, info)}
b69ab31229 </Row>
b69ab31230 ),
b69ab31231 };
b69ab31232 })}
b69ab31233 kind="stable"
b69ab31234 />
b69ab31235 )}
b69ab31236 {stableLocations?.repoSupportsCustomStables === true && <AddStableLocation />}
b69ab31237 </Section>
b69ab31238 );
b69ab31239}
b69ab31240
b69ab31241let typeaheadOptionsPromise: Promise<Result<Array<TypeaheadResult>>> | undefined;
b69ab31242const getStableLocationsTypeaheadOptions = () => {
b69ab31243 if (typeaheadOptionsPromise != null) {
b69ab31244 return typeaheadOptionsPromise;
b69ab31245 }
b69ab31246 typeaheadOptionsPromise = (async () => {
b69ab31247 serverAPI.postMessage({type: 'fetchStableLocationAutocompleteOptions'});
b69ab31248 const result = await serverAPI.nextMessageMatching(
b69ab31249 'fetchedStableLocationAutocompleteOptions',
b69ab31250 () => true,
b69ab31251 );
b69ab31252 return result.result;
b69ab31253 })();
b69ab31254 return typeaheadOptionsPromise;
b69ab31255};
b69ab31256
b69ab31257const stableLocationsTypeaheadOptions = atom(getStableLocationsTypeaheadOptions);
b69ab31258
b69ab31259function AddStableLocation() {
b69ab31260 const [showingInput, setShowingInput] = useState(false);
b69ab31261 const [query, setQuery] = useState('');
b69ab31262 const addRef = React.useRef<HTMLButtonElement>(null);
b69ab31263 return (
b69ab31264 <div style={{paddingTop: 'var(--pad)'}}>
b69ab31265 {showingInput ? (
b69ab31266 <div>
b69ab31267 <Subtle>{Internal.StableLocationAddInformation?.()}</Subtle>
b69ab31268 <Row>
b69ab31269 <Typeahead
b69ab31270 tokenString={query}
b69ab31271 setTokenString={setQuery}
b69ab31272 fetchTokens={async (query: string) => {
b69ab31273 const fetchStartTimestamp = Date.now();
b69ab31274 const options = await readAtom(stableLocationsTypeaheadOptions);
b69ab31275 const normalized = query.toLowerCase();
b69ab31276 return {
b69ab31277 fetchStartTimestamp,
b69ab31278 values:
b69ab31279 options.value?.filter(
b69ab31280 opt =>
b69ab31281 opt.value.toLowerCase().includes(normalized) ||
b69ab31282 opt.label.toLowerCase().includes(normalized),
b69ab31283 ) ?? [],
b69ab31284 };
b69ab31285 }}
b69ab31286 onSaveNewToken={() => {
b69ab31287 addRef?.current?.focus();
b69ab31288 }}
b69ab31289 autoFocus
b69ab31290 maxTokens={1}
b69ab31291 />
b69ab31292 <Button
b69ab31293 ref={addRef}
b69ab31294 primary
b69ab31295 onClick={e => {
b69ab31296 // only expect one token
b69ab31297 const [[token]] = extractTokens(query);
b69ab31298 const stable = token.trim();
b69ab31299 if (stable) {
b69ab31300 addManualStable(stable);
b69ab31301 setQuery('');
b69ab31302 setShowingInput(false);
b69ab31303 }
b69ab31304 e.stopPropagation();
b69ab31305 }}>
b69ab31306 <T>Add</T>
b69ab31307 </Button>
b69ab31308 </Row>
b69ab31309 </div>
b69ab31310 ) : (
b69ab31311 <Button
b69ab31312 icon
b69ab31313 onClick={e => {
b69ab31314 e.stopPropagation();
b69ab31315 setShowingInput(true);
b69ab31316
b69ab31317 // Start fetching options as soon as we show the typeahead
b69ab31318 getStableLocationsTypeaheadOptions();
b69ab31319 }}>
b69ab31320 <Icon icon="plus" />
b69ab31321 <T>Add Stable Location</T>
b69ab31322 </Button>
b69ab31323 )}
b69ab31324 </div>
b69ab31325 );
b69ab31326}
b69ab31327
b69ab31328export function Section({
b69ab31329 title,
b69ab31330 description,
b69ab31331 children,
b69ab31332}: {
b69ab31333 title: ReactNode;
b69ab31334 description?: ReactNode;
b69ab31335 children: ReactNode;
b69ab31336}) {
b69ab31337 return (
b69ab31338 <Column xstyle={styles.bookmarkGroup}>
b69ab31339 <strong>{title}</strong>
b69ab31340 {description && <Subtle {...stylex.props(styles.description)}>{description}</Subtle>}
b69ab31341 {children}
b69ab31342 </Column>
b69ab31343 );
b69ab31344}
b69ab31345
b69ab31346function BookmarksList({
b69ab31347 bookmarks,
b69ab31348 kind,
b69ab31349}: {
b69ab31350 bookmarks: Array<
b69ab31351 | string
b69ab31352 | (StableInfo & {extra?: ReactNode; kind?: undefined})
b69ab31353 | {kind: 'custom'; custom: ReactNode}
b69ab31354 >;
b69ab31355 kind: BookmarkKind;
b69ab31356}) {
b69ab31357 const [bookmarksData, setBookmarksData] = useAtom(bookmarksDataStorage);
b69ab31358 const recommendedBookmarks = useAtomValue(recommendedBookmarksAtom);
b69ab31359 const recommendedBookmarksAvailable = useAtomValue(recommendedBookmarksAvailableAtom);
b69ab31360 const showWarningOnMaster = Internal.shouldCheckRebase?.() ?? false;
b69ab31361 const hiddenMasterFeatureAvailable = useAtomValue(hiddenMasterFeatureAvailableAtom);
b69ab31362 const shouldAutoHideMaster = useAtomValue(shouldHideMasterAtom);
b69ab31363
b69ab31364 if (bookmarks.length == 0) {
b69ab31365 return null;
b69ab31366 }
b69ab31367 return (
b69ab31368 <ScrollY maxSize={300}>
b69ab31369 <Column xstyle={styles.bookmarkGroup}>
b69ab31370 {bookmarks.map(bookmark => {
b69ab31371 if (typeof bookmark !== 'string' && bookmark.kind === 'custom') {
b69ab31372 return bookmark.custom;
b69ab31373 }
b69ab31374 const name = typeof bookmark === 'string' ? bookmark : bookmark.name;
b69ab31375 const extra = typeof bookmark === 'string' ? undefined : bookmark.extra;
b69ab31376 const enableRecommended =
b69ab31377 bookmarksData.useRecommendedBookmark && recommendedBookmarksAvailable;
b69ab31378 const isRecommended = recommendedBookmarks.has(name);
b69ab31379 const tooltipOverride = typeof bookmark === 'string' ? undefined : bookmark.info;
b69ab31380 const {icon, tooltip} = getBookmarkAddons(
b69ab31381 name,
b69ab31382 isRecommended,
b69ab31383 showWarningOnMaster,
b69ab31384 tooltipOverride,
b69ab31385 );
b69ab31386
b69ab31387 const disabled =
b69ab31388 kind === 'remote' &&
b69ab31389 enableRecommended &&
b69ab31390 !isRecommended &&
b69ab31391 name !== REMOTE_MASTER_BOOKMARK;
b69ab31392
b69ab31393 // For remote/master when hidden master feature is available, show 3-state dropdown
b69ab31394 if (name === REMOTE_MASTER_BOOKMARK && hiddenMasterFeatureAvailable) {
b69ab31395 const currentVisibility = bookmarksData.masterBookmarkVisibility ?? 'auto';
b69ab31396 // Determine the label for "Auto" based on whether this repo would be auto-hidden
b69ab31397 const autoLabel = shouldAutoHideMaster ? t('Auto (hide)') : t('Auto (show)');
b69ab31398 return (
b69ab31399 <Row key={name} xstyle={styles.masterBookmarkRow}>
b69ab31400 <Bookmark fullLength kind={kind} tooltip={tooltip} icon={icon}>
b69ab31401 {name}
b69ab31402 </Bookmark>
b69ab31403 <Tooltip
b69ab31404 title={t(
b69ab31405 'Control master branch visibility. "Auto" derives from the current repo checkout whether it should be shown or hidden.',
b69ab31406 )}>
b69ab31407 <Dropdown<{value: MasterBookmarkVisibility; name: string}>
b69ab31408 value={currentVisibility}
b69ab31409 xstyle={styles.masterBookmarkDropdown}
b69ab31410 options={[
b69ab31411 {value: 'auto', name: autoLabel},
b69ab31412 {value: 'show', name: t('Show')},
b69ab31413 {value: 'hide', name: t('Hide')},
b69ab31414 ]}
b69ab31415 onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
b69ab31416 const newVisibility = e.target.value as MasterBookmarkVisibility;
b69ab31417 setBookmarksData({
b69ab31418 ...bookmarksData,
b69ab31419 masterBookmarkVisibility: newVisibility,
b69ab31420 });
b69ab31421 }}
b69ab31422 />
b69ab31423 </Tooltip>
b69ab31424 {extra}
b69ab31425 </Row>
b69ab31426 );
b69ab31427 }
b69ab31428
b69ab31429 return (
b69ab31430 <Checkbox
b69ab31431 key={name}
b69ab31432 checked={!bookmarksData.hiddenRemoteBookmarks.includes(name)}
b69ab31433 disabled={disabled}
b69ab31434 onChange={checked => {
b69ab31435 let hiddenRemoteBookmarks = bookmarksData.hiddenRemoteBookmarks;
b69ab31436
b69ab31437 if (!checked) {
b69ab31438 hiddenRemoteBookmarks = [...hiddenRemoteBookmarks, name];
b69ab31439 } else {
b69ab31440 hiddenRemoteBookmarks = hiddenRemoteBookmarks.filter(b => b !== name);
b69ab31441 }
b69ab31442
b69ab31443 setBookmarksData({...bookmarksData, hiddenRemoteBookmarks});
b69ab31444 }}>
b69ab31445 <Bookmark fullLength key={name} kind={kind} tooltip={tooltip} icon={icon}>
b69ab31446 {name}
b69ab31447 </Bookmark>
b69ab31448 {extra}
b69ab31449 </Checkbox>
b69ab31450 );
b69ab31451 })}
b69ab31452 </Column>
b69ab31453 </ScrollY>
b69ab31454 );
b69ab31455}