14.4 KB517 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 {
9 AbsolutePath,
10 CwdInfo,
11 CwdRelativePath,
12 RepoRelativePath,
13 Submodule,
14 SubmodulesByRoot,
15} from './types';
16
17import * as stylex from '@stylexjs/stylex';
18import {Badge} from 'isl-components/Badge';
19import {Button, buttonStyles} from 'isl-components/Button';
20import {ButtonDropdown} from 'isl-components/ButtonDropdown';
21import {Divider} from 'isl-components/Divider';
22import {Icon} from 'isl-components/Icon';
23import {Kbd} from 'isl-components/Kbd';
24import {KeyCode, Modifier} from 'isl-components/KeyboardShortcuts';
25import {RadioGroup} from 'isl-components/Radio';
26import {Subtle} from 'isl-components/Subtle';
27import {TextField} from 'isl-components/TextField';
28import {Tooltip} from 'isl-components/Tooltip';
29import {atom, useAtomValue} from 'jotai';
30import {Suspense, useState} from 'react';
31import {basename} from 'shared/utils';
32import {colors, spacing} from '../../components/theme/tokens.stylex';
33import serverAPI from './ClientToServerAPI';
34import {Column, Row, ScrollY} from './ComponentUtils';
35import {DropdownField, DropdownFields} from './DropdownFields';
36import {useCommandEvent} from './ISLShortcuts';
37import {codeReviewProvider} from './codeReview/CodeReviewInfo';
38import {T, t} from './i18n';
39import {writeAtom} from './jotaiUtils';
40import platform from './platform';
41import {serverCwd} from './repositoryData';
42import {repositoryInfo, submodulesByRoot} from './serverAPIState';
43import {registerCleanup, registerDisposable} from './utils';
44
45/**
46 * Give the relative path to `path` from `root`
47 * For example, relativePath('/home/user', '/home') -> 'user'
48 */
49export function relativePath(root: AbsolutePath, path: AbsolutePath) {
50 if (root == null || path === '') {
51 return '';
52 }
53 const sep = guessPathSep(path);
54 return maybeTrimPrefix(path.replace(root, ''), sep);
55}
56
57/**
58 * Simple version of path.join()
59 * Expect an absolute root path and a relative path
60 * e.g.
61 * joinPaths('/home', 'user') -> '/home/user'
62 * joinPaths('/home/', 'user/.config') -> '/home/user/.config'
63 */
64export function joinPaths(root: AbsolutePath, path: CwdRelativePath, sep = '/'): AbsolutePath {
65 return root.endsWith(sep) ? root + path : root + sep + path;
66}
67
68/**
69 * Trim a suffix if it exists
70 * maybeTrimSuffix('abc/', '/') -> 'abc'
71 * maybeTrimSuffix('abc', '/') -> 'abc'
72 */
73function maybeTrimSuffix(s: string, c: string): string {
74 return s.endsWith(c) ? s.slice(0, -c.length) : s;
75}
76
77function maybeTrimPrefix(s: string, c: string): string {
78 return s.startsWith(c) ? s.slice(c.length) : s;
79}
80
81function getMainSelectorLabel(
82 directRepoRoot: AbsolutePath,
83 nestedRepoRoots: AbsolutePath[] | undefined,
84 cwd: string,
85) {
86 const sep = guessPathSep(cwd);
87
88 // If there are multiple nested repo roots,
89 // show the first one as there will be following selectors for the rest
90 if (nestedRepoRoots && nestedRepoRoots.length > 1) {
91 return maybeTrimSuffix(basename(nestedRepoRoots[0], sep), sep);
92 }
93
94 // Otherwise, build the label with the direct and only repo root
95 const repoBasename = maybeTrimSuffix(basename(directRepoRoot, sep), sep);
96 const repoRelativeCwd = relativePath(directRepoRoot, cwd);
97 if (repoRelativeCwd === '') {
98 return repoBasename;
99 }
100 return joinPaths(repoBasename, repoRelativeCwd, sep);
101}
102
103export const availableCwds = atom<Array<CwdInfo>>([]);
104registerCleanup(
105 availableCwds,
106 serverAPI.onConnectOrReconnect(() => {
107 serverAPI.postMessage({
108 type: 'platform/subscribeToAvailableCwds',
109 });
110 }),
111 import.meta.hot,
112);
113
114registerDisposable(
115 availableCwds,
116 serverAPI.onMessageOfType('platform/availableCwds', event =>
117 writeAtom(availableCwds, event.options),
118 ),
119 import.meta.hot,
120);
121
122const styles = stylex.create({
123 container: {
124 display: 'flex',
125 gap: 0,
126 },
127 hideRightBorder: {
128 borderRight: 0,
129 marginRight: 0,
130 borderTopRightRadius: 0,
131 borderBottomRightRadius: 0,
132 },
133 hideLeftBorder: {
134 borderLeft: 0,
135 marginLeft: 0,
136 borderTopLeftRadius: 0,
137 borderBottomLeftRadius: 0,
138 },
139 submoduleSelect: {
140 width: 'auto',
141 maxWidth: '96px',
142 textOverflow: 'ellipsis',
143 boxShadow: 'none',
144 outline: 'none',
145 },
146 submoduleSeparator: {
147 // Override background to disable hover effect
148 background: {
149 default: colors.subtleHoverDarken,
150 },
151 },
152 submoduleDropdownContainer: {
153 minWidth: '200px',
154 alignItems: 'flex-start',
155 gap: spacing.pad,
156 },
157 submoduleList: {
158 width: '100%',
159 overflow: 'hidden',
160 },
161 submoduleOption: {
162 padding: 'var(--halfpad)',
163 borderRadius: 'var(--halfpad)',
164 cursor: 'pointer',
165 overflow: 'hidden',
166 textOverflow: 'ellipsis',
167 boxSizing: 'border-box',
168 backgroundColor: {
169 ':hover': 'var(--hover-darken)',
170 ':focus': 'var(--hover-darken)',
171 },
172 width: '100%',
173 },
174});
175
176export function CwdSelector() {
177 const info = useAtomValue(repositoryInfo);
178 const currentCwd = useAtomValue(serverCwd);
179 const submodulesMap = useAtomValue(submodulesByRoot);
180
181 if (info == null) {
182 return null;
183 }
184 // The most direct root of the cwd
185 const repoRoot = info.repoRoot;
186 // The list of repo roots down to the cwd, in order from furthest to closest
187 const repoRoots = info.repoRoots;
188
189 const mainLabel = getMainSelectorLabel(repoRoot, repoRoots, currentCwd);
190
191 return (
192 <div {...stylex.props(styles.container)}>
193 <MainCwdSelector
194 currentCwd={currentCwd}
195 label={mainLabel}
196 hideRightBorder={
197 (repoRoots && repoRoots.length > 1) ||
198 (submodulesMap?.get(repoRoot)?.value?.filter(m => m.active).length ?? 0) > 0
199 }
200 />
201 <SubmoduleSelectorGroup repoRoots={repoRoots} submoduleOptions={submodulesMap} />
202 </div>
203 );
204}
205
206/**
207 * The leftmost tooltip that can show cwd and repo details.
208 */
209function MainCwdSelector({
210 currentCwd,
211 label,
212 hideRightBorder,
213}: {
214 currentCwd: AbsolutePath;
215 label: string;
216 hideRightBorder: boolean;
217}) {
218 const allCwdOptions = useCwdOptions();
219 const cwdOptions = allCwdOptions.filter(opt => opt.valid);
220 const additionalToggles = useCommandEvent('ToggleCwdDropdown');
221
222 return (
223 <Tooltip
224 trigger="click"
225 component={dismiss => <CwdDetails dismiss={dismiss} />}
226 additionalToggles={additionalToggles.asEventTarget()}
227 group="topbar"
228 placement="bottom"
229 title={
230 <T replace={{$shortcut: <Kbd keycode={KeyCode.C} modifiers={[Modifier.ALT]} />}}>
231 Repository info & cwd ($shortcut)
232 </T>
233 }>
234 {hideRightBorder || cwdOptions.length < 2 ? (
235 <Button
236 icon
237 data-testid="cwd-dropdown-button"
238 {...stylex.props(hideRightBorder && styles.hideRightBorder)}>
239 <Icon icon="folder" />
240 {label}
241 </Button>
242 ) : (
243 // use a ButtonDropdown as a shortcut to quickly change cwd
244 <ButtonDropdown
245 data-testid="cwd-dropdown-button"
246 kind="icon"
247 options={cwdOptions}
248 selected={
249 cwdOptions.find(opt => opt.id === currentCwd) ?? {
250 id: currentCwd,
251 label,
252 valid: true,
253 }
254 }
255 icon={<Icon icon="folder" />}
256 onClick={
257 () => null // fall through to the Tooltip
258 }
259 onChangeSelected={value => {
260 if (value.id !== currentCwd) {
261 changeCwd(value.id);
262 }
263 }}></ButtonDropdown>
264 )}
265 </Tooltip>
266 );
267}
268
269function SubmoduleSelectorGroup({
270 repoRoots,
271 submoduleOptions,
272}: {
273 repoRoots: AbsolutePath[] | undefined;
274 submoduleOptions: SubmodulesByRoot;
275}) {
276 const currentCwd = useAtomValue(serverCwd);
277 if (repoRoots == null) {
278 return null;
279 }
280 const numRoots = repoRoots.length;
281 const directRepoRoot = repoRoots[numRoots - 1];
282 if (currentCwd !== directRepoRoot) {
283 // If the actual cwd is deeper than the supeproject root,
284 // submodule selectors don't make sense
285 return null;
286 }
287 const submodulesToBeSelected = submoduleOptions.get(directRepoRoot)?.value?.filter(m => m.active);
288
289 const out = [];
290
291 for (let i = 1; i < numRoots; i++) {
292 const currRoot = repoRoots[i];
293 const prevRoot = repoRoots[i - 1];
294 const submodules = submoduleOptions.get(prevRoot)?.value?.filter(m => m.active);
295 if (submodules != null && submodules.length > 0) {
296 out.push(
297 <SubmoduleSelector
298 submodules={submodules}
299 selected={submodules?.find(opt => opt.path === relativePath(prevRoot, currRoot))}
300 onChangeSelected={value => {
301 if (value.path !== relativePath(prevRoot, currRoot)) {
302 changeCwd(joinPaths(prevRoot, value.path));
303 }
304 }}
305 hideRightBorder={i < numRoots - 1 || submodulesToBeSelected != undefined}
306 root={prevRoot}
307 key={prevRoot}
308 />,
309 );
310 }
311 }
312
313 if (submodulesToBeSelected != null && submodulesToBeSelected.length > 0) {
314 out.push(
315 <SubmoduleSelector
316 submodules={submodulesToBeSelected}
317 onChangeSelected={value => {
318 if (value.path !== relativePath(directRepoRoot, currentCwd)) {
319 changeCwd(joinPaths(directRepoRoot, value.path));
320 }
321 }}
322 hideRightBorder={false}
323 root={directRepoRoot}
324 key={directRepoRoot}
325 />,
326 );
327 }
328
329 return out;
330}
331
332function CwdDetails({dismiss}: {dismiss: () => unknown}) {
333 const info = useAtomValue(repositoryInfo);
334 const repoRoot = info?.repoRoot ?? null;
335 const provider = useAtomValue(codeReviewProvider);
336 const cwd = useAtomValue(serverCwd);
337 const AddMoreCwdsHint = platform.AddMoreCwdsHint;
338 return (
339 <DropdownFields title={<T>Repository info</T>} icon="folder" data-testid="cwd-details-dropdown">
340 <CwdSelections dismiss={dismiss} divider />
341 {AddMoreCwdsHint && (
342 <Suspense>
343 <AddMoreCwdsHint />
344 </Suspense>
345 )}
346 <DropdownField title={<T>Active working directory</T>}>
347 <code>{cwd}</code>
348 </DropdownField>
349 <DropdownField title={<T>Repository Root</T>}>
350 <code>{repoRoot}</code>
351 </DropdownField>
352 {provider != null ? (
353 <DropdownField title={<T>Code Review Provider</T>}>
354 <span>
355 <Badge>{provider?.name}</Badge> <provider.RepoInfo />
356 </span>
357 </DropdownField>
358 ) : null}
359 </DropdownFields>
360 );
361}
362
363function changeCwd(newCwd: string) {
364 serverAPI.postMessage({
365 type: 'changeCwd',
366 cwd: newCwd,
367 });
368 serverAPI.cwdChanged();
369}
370
371function useCwdOptions() {
372 const cwdOptions = useAtomValue(availableCwds);
373
374 return cwdOptions.map((cwd, index) => ({
375 id: cwdOptions[index].cwd,
376 label: cwd.repoRelativeCwdLabel ?? cwd.cwd,
377 valid: cwd.repoRoot != null,
378 }));
379}
380
381function guessPathSep(path: string): '/' | '\\' {
382 if (path.includes('\\')) {
383 return '\\';
384 } else {
385 return '/';
386 }
387}
388
389export function CwdSelections({dismiss, divider}: {dismiss: () => unknown; divider?: boolean}) {
390 const currentCwd = useAtomValue(serverCwd);
391 const options = useCwdOptions();
392 if (options.length < 2) {
393 return null;
394 }
395
396 return (
397 <DropdownField title={<T>Change active working directory</T>}>
398 <RadioGroup
399 choices={options.map(({id, label, valid}) => ({
400 title: valid ? (
401 label
402 ) : (
403 <Row key={id}>
404 {label}{' '}
405 <Subtle>
406 <T>Not a valid repository</T>
407 </Subtle>
408 </Row>
409 ),
410 value: id,
411 tooltip: valid
412 ? id
413 : t('Path $path does not appear to be a valid Sapling repository', {
414 replace: {$path: id},
415 }),
416 disabled: !valid,
417 }))}
418 current={currentCwd}
419 onChange={newCwd => {
420 if (newCwd === currentCwd) {
421 // nothing to change
422 return;
423 }
424 changeCwd(newCwd);
425 dismiss();
426 }}
427 />
428 {divider && <Divider />}
429 </DropdownField>
430 );
431}
432
433/**
434 * Dropdown selector for submodules in a breadcrumb style.
435 */
436function SubmoduleSelector({
437 submodules,
438 selected,
439 onChangeSelected,
440 root,
441 hideRightBorder = true,
442}: {
443 submodules: ReadonlyArray<Submodule>;
444 selected?: Submodule;
445 onChangeSelected: (newSelected: Submodule) => unknown;
446 root: AbsolutePath;
447 hideRightBorder?: boolean;
448}) {
449 const selectedValue = submodules.find(m => m.path === selected?.path)?.path;
450 const [query, setQuery] = useState('');
451 const toDisplay = submodules
452 .filter(m => m.name.toLowerCase().includes(query.toLowerCase()))
453 .sort((a, b) => a.name.localeCompare(b.name));
454
455 return (
456 <>
457 <Icon
458 icon="chevron-right"
459 {...stylex.props(
460 buttonStyles.icon,
461 styles.submoduleSeparator,
462 styles.hideLeftBorder,
463 styles.hideRightBorder,
464 )}
465 />
466 <Tooltip
467 trigger="click"
468 placement="bottom"
469 title={<SubmoduleHint path={selectedValue} root={root} />}
470 component={dismiss => (
471 <Column xstyle={styles.submoduleDropdownContainer}>
472 <TextField
473 autoFocus
474 width="100%"
475 placeholder={t('search submodule name')}
476 value={query}
477 onInput={e => setQuery(e.currentTarget?.value ?? '')}
478 />
479 <div {...stylex.props(styles.submoduleList)}>
480 <ScrollY maxSize={360}>
481 {toDisplay.map(m => (
482 <div
483 key={m.path}
484 {...stylex.props(styles.submoduleOption)}
485 onClick={() => {
486 onChangeSelected(m);
487 setQuery('');
488 dismiss();
489 }}
490 title={m.path}>
491 {m.name}
492 </div>
493 ))}
494 </ScrollY>
495 </div>
496 </Column>
497 )}>
498 <Button
499 kind="icon"
500 {...stylex.props(
501 styles.submoduleSelect,
502 styles.hideLeftBorder,
503 hideRightBorder && styles.hideRightBorder,
504 )}>
505 {selected ? selected.name : `${t('submodules')}...`}
506 </Button>
507 </Tooltip>
508 </>
509 );
510}
511
512function SubmoduleHint({path, root}: {path: RepoRelativePath | undefined; root: AbsolutePath}) {
513 return (
514 <T>{path ? `${t('Submodule at')}: ${path}` : `${t('Select a submodule under')}: ${root}`}</T>
515 );
516}
517