addons/isl/src/CwdSelector.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 {
b69ab319 AbsolutePath,
b69ab3110 CwdInfo,
b69ab3111 CwdRelativePath,
b69ab3112 RepoRelativePath,
b69ab3113 Submodule,
b69ab3114 SubmodulesByRoot,
b69ab3115} from './types';
b69ab3116
b69ab3117import * as stylex from '@stylexjs/stylex';
b69ab3118import {Badge} from 'isl-components/Badge';
b69ab3119import {Button, buttonStyles} from 'isl-components/Button';
b69ab3120import {ButtonDropdown} from 'isl-components/ButtonDropdown';
b69ab3121import {Divider} from 'isl-components/Divider';
b69ab3122import {Icon} from 'isl-components/Icon';
b69ab3123import {Kbd} from 'isl-components/Kbd';
b69ab3124import {KeyCode, Modifier} from 'isl-components/KeyboardShortcuts';
b69ab3125import {RadioGroup} from 'isl-components/Radio';
b69ab3126import {Subtle} from 'isl-components/Subtle';
b69ab3127import {TextField} from 'isl-components/TextField';
b69ab3128import {Tooltip} from 'isl-components/Tooltip';
b69ab3129import {atom, useAtomValue} from 'jotai';
b69ab3130import {Suspense, useState} from 'react';
b69ab3131import {basename} from 'shared/utils';
b69ab3132import {colors, spacing} from '../../components/theme/tokens.stylex';
b69ab3133import serverAPI from './ClientToServerAPI';
b69ab3134import {Column, Row, ScrollY} from './ComponentUtils';
b69ab3135import {DropdownField, DropdownFields} from './DropdownFields';
b69ab3136import {useCommandEvent} from './ISLShortcuts';
b69ab3137import {codeReviewProvider} from './codeReview/CodeReviewInfo';
b69ab3138import {T, t} from './i18n';
b69ab3139import {writeAtom} from './jotaiUtils';
b69ab3140import platform from './platform';
b69ab3141import {serverCwd} from './repositoryData';
b69ab3142import {repositoryInfo, submodulesByRoot} from './serverAPIState';
b69ab3143import {registerCleanup, registerDisposable} from './utils';
b69ab3144
b69ab3145/**
b69ab3146 * Give the relative path to `path` from `root`
b69ab3147 * For example, relativePath('/home/user', '/home') -> 'user'
b69ab3148 */
b69ab3149export function relativePath(root: AbsolutePath, path: AbsolutePath) {
b69ab3150 if (root == null || path === '') {
b69ab3151 return '';
b69ab3152 }
b69ab3153 const sep = guessPathSep(path);
b69ab3154 return maybeTrimPrefix(path.replace(root, ''), sep);
b69ab3155}
b69ab3156
b69ab3157/**
b69ab3158 * Simple version of path.join()
b69ab3159 * Expect an absolute root path and a relative path
b69ab3160 * e.g.
b69ab3161 * joinPaths('/home', 'user') -> '/home/user'
b69ab3162 * joinPaths('/home/', 'user/.config') -> '/home/user/.config'
b69ab3163 */
b69ab3164export function joinPaths(root: AbsolutePath, path: CwdRelativePath, sep = '/'): AbsolutePath {
b69ab3165 return root.endsWith(sep) ? root + path : root + sep + path;
b69ab3166}
b69ab3167
b69ab3168/**
b69ab3169 * Trim a suffix if it exists
b69ab3170 * maybeTrimSuffix('abc/', '/') -> 'abc'
b69ab3171 * maybeTrimSuffix('abc', '/') -> 'abc'
b69ab3172 */
b69ab3173function maybeTrimSuffix(s: string, c: string): string {
b69ab3174 return s.endsWith(c) ? s.slice(0, -c.length) : s;
b69ab3175}
b69ab3176
b69ab3177function maybeTrimPrefix(s: string, c: string): string {
b69ab3178 return s.startsWith(c) ? s.slice(c.length) : s;
b69ab3179}
b69ab3180
b69ab3181function getMainSelectorLabel(
b69ab3182 directRepoRoot: AbsolutePath,
b69ab3183 nestedRepoRoots: AbsolutePath[] | undefined,
b69ab3184 cwd: string,
b69ab3185) {
b69ab3186 const sep = guessPathSep(cwd);
b69ab3187
b69ab3188 // If there are multiple nested repo roots,
b69ab3189 // show the first one as there will be following selectors for the rest
b69ab3190 if (nestedRepoRoots && nestedRepoRoots.length > 1) {
b69ab3191 return maybeTrimSuffix(basename(nestedRepoRoots[0], sep), sep);
b69ab3192 }
b69ab3193
b69ab3194 // Otherwise, build the label with the direct and only repo root
b69ab3195 const repoBasename = maybeTrimSuffix(basename(directRepoRoot, sep), sep);
b69ab3196 const repoRelativeCwd = relativePath(directRepoRoot, cwd);
b69ab3197 if (repoRelativeCwd === '') {
b69ab3198 return repoBasename;
b69ab3199 }
b69ab31100 return joinPaths(repoBasename, repoRelativeCwd, sep);
b69ab31101}
b69ab31102
b69ab31103export const availableCwds = atom<Array<CwdInfo>>([]);
b69ab31104registerCleanup(
b69ab31105 availableCwds,
b69ab31106 serverAPI.onConnectOrReconnect(() => {
b69ab31107 serverAPI.postMessage({
b69ab31108 type: 'platform/subscribeToAvailableCwds',
b69ab31109 });
b69ab31110 }),
b69ab31111 import.meta.hot,
b69ab31112);
b69ab31113
b69ab31114registerDisposable(
b69ab31115 availableCwds,
b69ab31116 serverAPI.onMessageOfType('platform/availableCwds', event =>
b69ab31117 writeAtom(availableCwds, event.options),
b69ab31118 ),
b69ab31119 import.meta.hot,
b69ab31120);
b69ab31121
b69ab31122const styles = stylex.create({
b69ab31123 container: {
b69ab31124 display: 'flex',
b69ab31125 gap: 0,
b69ab31126 },
b69ab31127 hideRightBorder: {
b69ab31128 borderRight: 0,
b69ab31129 marginRight: 0,
b69ab31130 borderTopRightRadius: 0,
b69ab31131 borderBottomRightRadius: 0,
b69ab31132 },
b69ab31133 hideLeftBorder: {
b69ab31134 borderLeft: 0,
b69ab31135 marginLeft: 0,
b69ab31136 borderTopLeftRadius: 0,
b69ab31137 borderBottomLeftRadius: 0,
b69ab31138 },
b69ab31139 submoduleSelect: {
b69ab31140 width: 'auto',
b69ab31141 maxWidth: '96px',
b69ab31142 textOverflow: 'ellipsis',
b69ab31143 boxShadow: 'none',
b69ab31144 outline: 'none',
b69ab31145 },
b69ab31146 submoduleSeparator: {
b69ab31147 // Override background to disable hover effect
b69ab31148 background: {
b69ab31149 default: colors.subtleHoverDarken,
b69ab31150 },
b69ab31151 },
b69ab31152 submoduleDropdownContainer: {
b69ab31153 minWidth: '200px',
b69ab31154 alignItems: 'flex-start',
b69ab31155 gap: spacing.pad,
b69ab31156 },
b69ab31157 submoduleList: {
b69ab31158 width: '100%',
b69ab31159 overflow: 'hidden',
b69ab31160 },
b69ab31161 submoduleOption: {
b69ab31162 padding: 'var(--halfpad)',
b69ab31163 borderRadius: 'var(--halfpad)',
b69ab31164 cursor: 'pointer',
b69ab31165 overflow: 'hidden',
b69ab31166 textOverflow: 'ellipsis',
b69ab31167 boxSizing: 'border-box',
b69ab31168 backgroundColor: {
b69ab31169 ':hover': 'var(--hover-darken)',
b69ab31170 ':focus': 'var(--hover-darken)',
b69ab31171 },
b69ab31172 width: '100%',
b69ab31173 },
b69ab31174});
b69ab31175
b69ab31176export function CwdSelector() {
b69ab31177 const info = useAtomValue(repositoryInfo);
b69ab31178 const currentCwd = useAtomValue(serverCwd);
b69ab31179 const submodulesMap = useAtomValue(submodulesByRoot);
b69ab31180
b69ab31181 if (info == null) {
b69ab31182 return null;
b69ab31183 }
b69ab31184 // The most direct root of the cwd
b69ab31185 const repoRoot = info.repoRoot;
b69ab31186 // The list of repo roots down to the cwd, in order from furthest to closest
b69ab31187 const repoRoots = info.repoRoots;
b69ab31188
b69ab31189 const mainLabel = getMainSelectorLabel(repoRoot, repoRoots, currentCwd);
b69ab31190
b69ab31191 return (
b69ab31192 <div {...stylex.props(styles.container)}>
b69ab31193 <MainCwdSelector
b69ab31194 currentCwd={currentCwd}
b69ab31195 label={mainLabel}
b69ab31196 hideRightBorder={
b69ab31197 (repoRoots && repoRoots.length > 1) ||
b69ab31198 (submodulesMap?.get(repoRoot)?.value?.filter(m => m.active).length ?? 0) > 0
b69ab31199 }
b69ab31200 />
b69ab31201 <SubmoduleSelectorGroup repoRoots={repoRoots} submoduleOptions={submodulesMap} />
b69ab31202 </div>
b69ab31203 );
b69ab31204}
b69ab31205
b69ab31206/**
b69ab31207 * The leftmost tooltip that can show cwd and repo details.
b69ab31208 */
b69ab31209function MainCwdSelector({
b69ab31210 currentCwd,
b69ab31211 label,
b69ab31212 hideRightBorder,
b69ab31213}: {
b69ab31214 currentCwd: AbsolutePath;
b69ab31215 label: string;
b69ab31216 hideRightBorder: boolean;
b69ab31217}) {
b69ab31218 const allCwdOptions = useCwdOptions();
b69ab31219 const cwdOptions = allCwdOptions.filter(opt => opt.valid);
b69ab31220 const additionalToggles = useCommandEvent('ToggleCwdDropdown');
b69ab31221
b69ab31222 return (
b69ab31223 <Tooltip
b69ab31224 trigger="click"
b69ab31225 component={dismiss => <CwdDetails dismiss={dismiss} />}
b69ab31226 additionalToggles={additionalToggles.asEventTarget()}
b69ab31227 group="topbar"
b69ab31228 placement="bottom"
b69ab31229 title={
b69ab31230 <T replace={{$shortcut: <Kbd keycode={KeyCode.C} modifiers={[Modifier.ALT]} />}}>
b69ab31231 Repository info & cwd ($shortcut)
b69ab31232 </T>
b69ab31233 }>
b69ab31234 {hideRightBorder || cwdOptions.length < 2 ? (
b69ab31235 <Button
b69ab31236 icon
b69ab31237 data-testid="cwd-dropdown-button"
b69ab31238 {...stylex.props(hideRightBorder && styles.hideRightBorder)}>
b69ab31239 <Icon icon="folder" />
b69ab31240 {label}
b69ab31241 </Button>
b69ab31242 ) : (
b69ab31243 // use a ButtonDropdown as a shortcut to quickly change cwd
b69ab31244 <ButtonDropdown
b69ab31245 data-testid="cwd-dropdown-button"
b69ab31246 kind="icon"
b69ab31247 options={cwdOptions}
b69ab31248 selected={
b69ab31249 cwdOptions.find(opt => opt.id === currentCwd) ?? {
b69ab31250 id: currentCwd,
b69ab31251 label,
b69ab31252 valid: true,
b69ab31253 }
b69ab31254 }
b69ab31255 icon={<Icon icon="folder" />}
b69ab31256 onClick={
b69ab31257 () => null // fall through to the Tooltip
b69ab31258 }
b69ab31259 onChangeSelected={value => {
b69ab31260 if (value.id !== currentCwd) {
b69ab31261 changeCwd(value.id);
b69ab31262 }
b69ab31263 }}></ButtonDropdown>
b69ab31264 )}
b69ab31265 </Tooltip>
b69ab31266 );
b69ab31267}
b69ab31268
b69ab31269function SubmoduleSelectorGroup({
b69ab31270 repoRoots,
b69ab31271 submoduleOptions,
b69ab31272}: {
b69ab31273 repoRoots: AbsolutePath[] | undefined;
b69ab31274 submoduleOptions: SubmodulesByRoot;
b69ab31275}) {
b69ab31276 const currentCwd = useAtomValue(serverCwd);
b69ab31277 if (repoRoots == null) {
b69ab31278 return null;
b69ab31279 }
b69ab31280 const numRoots = repoRoots.length;
b69ab31281 const directRepoRoot = repoRoots[numRoots - 1];
b69ab31282 if (currentCwd !== directRepoRoot) {
b69ab31283 // If the actual cwd is deeper than the supeproject root,
b69ab31284 // submodule selectors don't make sense
b69ab31285 return null;
b69ab31286 }
b69ab31287 const submodulesToBeSelected = submoduleOptions.get(directRepoRoot)?.value?.filter(m => m.active);
b69ab31288
b69ab31289 const out = [];
b69ab31290
b69ab31291 for (let i = 1; i < numRoots; i++) {
b69ab31292 const currRoot = repoRoots[i];
b69ab31293 const prevRoot = repoRoots[i - 1];
b69ab31294 const submodules = submoduleOptions.get(prevRoot)?.value?.filter(m => m.active);
b69ab31295 if (submodules != null && submodules.length > 0) {
b69ab31296 out.push(
b69ab31297 <SubmoduleSelector
b69ab31298 submodules={submodules}
b69ab31299 selected={submodules?.find(opt => opt.path === relativePath(prevRoot, currRoot))}
b69ab31300 onChangeSelected={value => {
b69ab31301 if (value.path !== relativePath(prevRoot, currRoot)) {
b69ab31302 changeCwd(joinPaths(prevRoot, value.path));
b69ab31303 }
b69ab31304 }}
b69ab31305 hideRightBorder={i < numRoots - 1 || submodulesToBeSelected != undefined}
b69ab31306 root={prevRoot}
b69ab31307 key={prevRoot}
b69ab31308 />,
b69ab31309 );
b69ab31310 }
b69ab31311 }
b69ab31312
b69ab31313 if (submodulesToBeSelected != null && submodulesToBeSelected.length > 0) {
b69ab31314 out.push(
b69ab31315 <SubmoduleSelector
b69ab31316 submodules={submodulesToBeSelected}
b69ab31317 onChangeSelected={value => {
b69ab31318 if (value.path !== relativePath(directRepoRoot, currentCwd)) {
b69ab31319 changeCwd(joinPaths(directRepoRoot, value.path));
b69ab31320 }
b69ab31321 }}
b69ab31322 hideRightBorder={false}
b69ab31323 root={directRepoRoot}
b69ab31324 key={directRepoRoot}
b69ab31325 />,
b69ab31326 );
b69ab31327 }
b69ab31328
b69ab31329 return out;
b69ab31330}
b69ab31331
b69ab31332function CwdDetails({dismiss}: {dismiss: () => unknown}) {
b69ab31333 const info = useAtomValue(repositoryInfo);
b69ab31334 const repoRoot = info?.repoRoot ?? null;
b69ab31335 const provider = useAtomValue(codeReviewProvider);
b69ab31336 const cwd = useAtomValue(serverCwd);
b69ab31337 const AddMoreCwdsHint = platform.AddMoreCwdsHint;
b69ab31338 return (
b69ab31339 <DropdownFields title={<T>Repository info</T>} icon="folder" data-testid="cwd-details-dropdown">
b69ab31340 <CwdSelections dismiss={dismiss} divider />
b69ab31341 {AddMoreCwdsHint && (
b69ab31342 <Suspense>
b69ab31343 <AddMoreCwdsHint />
b69ab31344 </Suspense>
b69ab31345 )}
b69ab31346 <DropdownField title={<T>Active working directory</T>}>
b69ab31347 <code>{cwd}</code>
b69ab31348 </DropdownField>
b69ab31349 <DropdownField title={<T>Repository Root</T>}>
b69ab31350 <code>{repoRoot}</code>
b69ab31351 </DropdownField>
b69ab31352 {provider != null ? (
b69ab31353 <DropdownField title={<T>Code Review Provider</T>}>
b69ab31354 <span>
b69ab31355 <Badge>{provider?.name}</Badge> <provider.RepoInfo />
b69ab31356 </span>
b69ab31357 </DropdownField>
b69ab31358 ) : null}
b69ab31359 </DropdownFields>
b69ab31360 );
b69ab31361}
b69ab31362
b69ab31363function changeCwd(newCwd: string) {
b69ab31364 serverAPI.postMessage({
b69ab31365 type: 'changeCwd',
b69ab31366 cwd: newCwd,
b69ab31367 });
b69ab31368 serverAPI.cwdChanged();
b69ab31369}
b69ab31370
b69ab31371function useCwdOptions() {
b69ab31372 const cwdOptions = useAtomValue(availableCwds);
b69ab31373
b69ab31374 return cwdOptions.map((cwd, index) => ({
b69ab31375 id: cwdOptions[index].cwd,
b69ab31376 label: cwd.repoRelativeCwdLabel ?? cwd.cwd,
b69ab31377 valid: cwd.repoRoot != null,
b69ab31378 }));
b69ab31379}
b69ab31380
b69ab31381function guessPathSep(path: string): '/' | '\\' {
b69ab31382 if (path.includes('\\')) {
b69ab31383 return '\\';
b69ab31384 } else {
b69ab31385 return '/';
b69ab31386 }
b69ab31387}
b69ab31388
b69ab31389export function CwdSelections({dismiss, divider}: {dismiss: () => unknown; divider?: boolean}) {
b69ab31390 const currentCwd = useAtomValue(serverCwd);
b69ab31391 const options = useCwdOptions();
b69ab31392 if (options.length < 2) {
b69ab31393 return null;
b69ab31394 }
b69ab31395
b69ab31396 return (
b69ab31397 <DropdownField title={<T>Change active working directory</T>}>
b69ab31398 <RadioGroup
b69ab31399 choices={options.map(({id, label, valid}) => ({
b69ab31400 title: valid ? (
b69ab31401 label
b69ab31402 ) : (
b69ab31403 <Row key={id}>
b69ab31404 {label}{' '}
b69ab31405 <Subtle>
b69ab31406 <T>Not a valid repository</T>
b69ab31407 </Subtle>
b69ab31408 </Row>
b69ab31409 ),
b69ab31410 value: id,
b69ab31411 tooltip: valid
b69ab31412 ? id
b69ab31413 : t('Path $path does not appear to be a valid Sapling repository', {
b69ab31414 replace: {$path: id},
b69ab31415 }),
b69ab31416 disabled: !valid,
b69ab31417 }))}
b69ab31418 current={currentCwd}
b69ab31419 onChange={newCwd => {
b69ab31420 if (newCwd === currentCwd) {
b69ab31421 // nothing to change
b69ab31422 return;
b69ab31423 }
b69ab31424 changeCwd(newCwd);
b69ab31425 dismiss();
b69ab31426 }}
b69ab31427 />
b69ab31428 {divider && <Divider />}
b69ab31429 </DropdownField>
b69ab31430 );
b69ab31431}
b69ab31432
b69ab31433/**
b69ab31434 * Dropdown selector for submodules in a breadcrumb style.
b69ab31435 */
b69ab31436function SubmoduleSelector({
b69ab31437 submodules,
b69ab31438 selected,
b69ab31439 onChangeSelected,
b69ab31440 root,
b69ab31441 hideRightBorder = true,
b69ab31442}: {
b69ab31443 submodules: ReadonlyArray<Submodule>;
b69ab31444 selected?: Submodule;
b69ab31445 onChangeSelected: (newSelected: Submodule) => unknown;
b69ab31446 root: AbsolutePath;
b69ab31447 hideRightBorder?: boolean;
b69ab31448}) {
b69ab31449 const selectedValue = submodules.find(m => m.path === selected?.path)?.path;
b69ab31450 const [query, setQuery] = useState('');
b69ab31451 const toDisplay = submodules
b69ab31452 .filter(m => m.name.toLowerCase().includes(query.toLowerCase()))
b69ab31453 .sort((a, b) => a.name.localeCompare(b.name));
b69ab31454
b69ab31455 return (
b69ab31456 <>
b69ab31457 <Icon
b69ab31458 icon="chevron-right"
b69ab31459 {...stylex.props(
b69ab31460 buttonStyles.icon,
b69ab31461 styles.submoduleSeparator,
b69ab31462 styles.hideLeftBorder,
b69ab31463 styles.hideRightBorder,
b69ab31464 )}
b69ab31465 />
b69ab31466 <Tooltip
b69ab31467 trigger="click"
b69ab31468 placement="bottom"
b69ab31469 title={<SubmoduleHint path={selectedValue} root={root} />}
b69ab31470 component={dismiss => (
b69ab31471 <Column xstyle={styles.submoduleDropdownContainer}>
b69ab31472 <TextField
b69ab31473 autoFocus
b69ab31474 width="100%"
b69ab31475 placeholder={t('search submodule name')}
b69ab31476 value={query}
b69ab31477 onInput={e => setQuery(e.currentTarget?.value ?? '')}
b69ab31478 />
b69ab31479 <div {...stylex.props(styles.submoduleList)}>
b69ab31480 <ScrollY maxSize={360}>
b69ab31481 {toDisplay.map(m => (
b69ab31482 <div
b69ab31483 key={m.path}
b69ab31484 {...stylex.props(styles.submoduleOption)}
b69ab31485 onClick={() => {
b69ab31486 onChangeSelected(m);
b69ab31487 setQuery('');
b69ab31488 dismiss();
b69ab31489 }}
b69ab31490 title={m.path}>
b69ab31491 {m.name}
b69ab31492 </div>
b69ab31493 ))}
b69ab31494 </ScrollY>
b69ab31495 </div>
b69ab31496 </Column>
b69ab31497 )}>
b69ab31498 <Button
b69ab31499 kind="icon"
b69ab31500 {...stylex.props(
b69ab31501 styles.submoduleSelect,
b69ab31502 styles.hideLeftBorder,
b69ab31503 hideRightBorder && styles.hideRightBorder,
b69ab31504 )}>
b69ab31505 {selected ? selected.name : `${t('submodules')}...`}
b69ab31506 </Button>
b69ab31507 </Tooltip>
b69ab31508 </>
b69ab31509 );
b69ab31510}
b69ab31511
b69ab31512function SubmoduleHint({path, root}: {path: RepoRelativePath | undefined; root: AbsolutePath}) {
b69ab31513 return (
b69ab31514 <T>{path ? `${t('Submodule at')}: ${path}` : `${t('Select a submodule under')}: ${root}`}</T>
b69ab31515 );
b69ab31516}