18.4 KB594 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 {ThemeColor} from './theme';
9import type {PreferredSubmitCommand} from './types';
10
11import {Button} from 'isl-components/Button';
12import {Checkbox} from 'isl-components/Checkbox';
13import {Dropdown} from 'isl-components/Dropdown';
14import {Icon} from 'isl-components/Icon';
15import {Kbd} from 'isl-components/Kbd';
16import {KeyCode, Modifier} from 'isl-components/KeyboardShortcuts';
17import {Subtle} from 'isl-components/Subtle';
18import {Tooltip} from 'isl-components/Tooltip';
19import {useAtom, useAtomValue} from 'jotai';
20import {Suspense} from 'react';
21import {nullthrows, tryJsonParse} from 'shared/utils';
22import {
23 distantRebaseWarningEnabled,
24 rebaseOffWarmWarningEnabled,
25 rebaseOntoMasterWarningEnabled,
26} from './Commit';
27import {condenseObsoleteStacks} from './CommitTreeList';
28import {Column, Row} from './ComponentUtils';
29import {confirmShouldSubmitEnabledAtom} from './ConfirmSubmitStack';
30import {DropdownField, DropdownFields} from './DropdownFields';
31import {useShowKeyboardShortcutsHelp} from './ISLShortcuts';
32import {Link} from './Link';
33import {RestackBehaviorSetting} from './RestackBehavior';
34import {Setting} from './Setting';
35import {
36 currentExperimentalFeaturesList,
37 hasExperimentalFeatures,
38} from './atoms/experimentalFeatureAtoms';
39import {codeReviewProvider} from './codeReview/CodeReviewInfo';
40import {showDiffNumberConfig} from './codeReview/DiffBadge';
41import {SubmitAsDraftCheckbox} from './codeReview/DraftCheckbox';
42import {
43 branchPRsSupported,
44 experimentalBranchPRsEnabled,
45 overrideDisabledSubmitModes,
46} from './codeReview/github/branchPrState';
47import {debugToolsEnabledState} from './debug/DebugToolsState';
48import {externalMergeToolAtom} from './externalMergeTool';
49import {t, T} from './i18n';
50import {configBackedAtom, readAtom} from './jotaiUtils';
51import {AutoResolveSettingCheckbox} from './mergeConflicts/state';
52import {SetConfigOperation} from './operations/SetConfigOperation';
53import {useRunOperation} from './operationsState';
54import platform from './platform';
55import {irrelevantCwdDisplayModeAtom} from './repositoryData';
56import {renderCompactAtom, useZoomShortcut, zoomUISettingAtom} from './responsive';
57import {mainCommandName, repositoryInfo} from './serverAPIState';
58import {themeState, useThemeShortcut} from './theme';
59
60import './SettingsTooltip.css';
61import {enableSaplingDebugFlag, enableSaplingVerboseFlag} from './atoms/debugToolAtoms';
62
63export function SettingsGearButton() {
64 useThemeShortcut();
65 useZoomShortcut();
66 const showShortcutsHelp = useShowKeyboardShortcutsHelp();
67 return (
68 <Tooltip
69 trigger="click"
70 component={dismiss => (
71 <SettingsDropdown dismiss={dismiss} showShortcutsHelp={showShortcutsHelp} />
72 )}
73 group="topbar"
74 placement="bottom">
75 <Button icon data-testid="settings-gear-button">
76 <Icon icon="gear" />
77 </Button>
78 </Tooltip>
79 );
80}
81
82function SettingsDropdown({
83 dismiss,
84 showShortcutsHelp,
85}: {
86 dismiss: () => unknown;
87 showShortcutsHelp: () => unknown;
88}) {
89 const [theme, setTheme] = useAtom(themeState);
90 const [repoInfo, setRepoInfo] = useAtom(repositoryInfo);
91 const runOperation = useRunOperation();
92 const [showDiffNumber, setShowDiffNumber] = useAtom(showDiffNumberConfig);
93 return (
94 <DropdownFields title={<T>Settings</T>} icon="gear" data-testid="settings-dropdown">
95 <Button
96 style={{justifyContent: 'center', gap: 0}}
97 icon
98 onClick={() => {
99 dismiss();
100 showShortcutsHelp();
101 }}>
102 <T
103 replace={{
104 $shortcut: <Kbd keycode={KeyCode.QuestionMark} modifiers={[Modifier.SHIFT]} />,
105 }}>
106 View Keyboard Shortcuts - $shortcut
107 </T>
108 </Button>
109 {platform.theme != null ? null : (
110 <Setting title={<T>Theme</T>}>
111 <Dropdown
112 options={
113 [
114 {value: 'light', name: 'Light'},
115 {value: 'dark', name: 'Dark'},
116 ] as Array<{value: ThemeColor; name: string}>
117 }
118 value={theme}
119 onChange={event => setTheme(event.currentTarget.value as ThemeColor)}
120 />
121 <div style={{marginTop: 'var(--pad)'}}>
122 <Subtle>
123 <T>Toggle: </T>
124 <Kbd keycode={KeyCode.T} modifiers={[Modifier.ALT]} />
125 </Subtle>
126 </div>
127 </Setting>
128 )}
129
130 <Setting title={<T>UI Scale</T>}>
131 <ZoomUISetting />
132 </Setting>
133 <Setting title={<T>Commits</T>}>
134 <Column alignStart>
135 <RenderCompactSetting />
136 <CondenseObsoleteSetting />
137 <RebaseOffWarmWarningSetting />
138 <DistantRebaseWarningSetting />
139 <RebaseOntoMasterWarningSetting />
140 <DeemphasizeIrrelevantCommitsSetting />
141 </Column>
142 </Setting>
143 <Setting title={<T>Conflicts</T>}>
144 <AutoResolveSettingCheckbox />
145 <RestackBehaviorSetting />
146 </Setting>
147 {/* TODO: enable this setting when there is actually a chocie to be made here. */}
148 {/* <Setting
149 title={<T>Language</T>}
150 description={<T>Locale for translations used in the UI. Currently only en supported.</T>}>
151 <Dropdown value="en" options=['en'] />
152 </Setting> */}
153 {repoInfo == null ? (
154 <Icon icon="loading" />
155 ) : repoInfo.codeReviewSystem.type === 'github' ? (
156 <Setting
157 title={<T>Preferred Code Review Submit Method</T>}
158 description={
159 <>
160 <T>How to submit code for code review on GitHub.</T>{' '}
161 {/* TODO: update this to document branchign PRs */}
162 <Link href="https://sapling-scm.com/docs/git/intro#pull-requests">
163 <T>Learn More</T>
164 </Link>
165 </>
166 }>
167 <Dropdown
168 value={repoInfo.preferredSubmitCommand ?? 'not set'}
169 options={(repoInfo.preferredSubmitCommand == null
170 ? [{value: 'not set', name: '(not set)'}]
171 : []
172 ).concat([
173 {value: 'ghstack', name: 'sl ghstack (stacked PRs)'},
174 {value: 'pr', name: 'sl pr (stacked PRs)'},
175 ...(readAtom(branchPRsSupported)
176 ? [{value: 'push', name: 'sl push (branching PR)'}]
177 : []),
178 ])}
179 onChange={event => {
180 const value = (event as React.FormEvent<HTMLSelectElement>).currentTarget.value as
181 | PreferredSubmitCommand
182 | 'not set';
183 if (value === 'not set') {
184 return;
185 }
186
187 runOperation(
188 new SetConfigOperation('local', 'github.preferred_submit_command', value),
189 );
190 setRepoInfo(info => ({...nullthrows(info), preferredSubmitCommand: value}));
191 }}
192 />
193 </Setting>
194 ) : null}
195 <Setting title={<T>Code Review</T>}>
196 <div className="multiple-settings">
197 <Checkbox
198 checked={showDiffNumber}
199 onChange={checked => {
200 setShowDiffNumber(checked);
201 }}>
202 <T>Show copyable Diff / Pull Request numbers inline for each commit</T>
203 </Checkbox>
204 <ConfirmSubmitStackSetting />
205 <SubmitAsDraftCheckbox forceShow />
206 </div>
207 </Setting>
208 {platform.canCustomizeFileOpener && (
209 <Setting title={<T>Environment</T>}>
210 <Column alignStart>
211 <OpenFilesCmdSetting />
212 <ExternalMergeToolSetting />
213 </Column>
214 </Setting>
215 )}
216 <Suspense>{platform.Settings == null ? null : <platform.Settings />}</Suspense>
217 <DebugToolsField />
218 </DropdownFields>
219 );
220}
221
222function ConfirmSubmitStackSetting() {
223 const [value, setValue] = useAtom(confirmShouldSubmitEnabledAtom);
224 const provider = useAtomValue(codeReviewProvider);
225 if (provider == null || !provider.supportSubmittingAsDraft) {
226 return null;
227 }
228 return (
229 <Tooltip
230 title={t(
231 'This lets you choose to submit as draft and provide an update message. ' +
232 'If false, no confirmation is shown and it will submit as draft if you previously ' +
233 'checked the submit as draft checkbox.',
234 )}>
235 <Checkbox
236 checked={value}
237 onChange={checked => {
238 setValue(checked);
239 }}>
240 <T>Show confirmation when submitting a stack</T>
241 </Checkbox>
242 </Tooltip>
243 );
244}
245
246function RenderCompactSetting() {
247 const [value, setValue] = useAtom(renderCompactAtom);
248 return (
249 <Tooltip
250 title={t(
251 'Render commits in the tree more compactly, by reducing spacing and not wrapping Diff info to multiple lines. ' +
252 'May require more horizontal scrolling.',
253 )}>
254 <Checkbox
255 checked={value}
256 onChange={checked => {
257 setValue(checked);
258 }}>
259 <T>Compact Mode</T>
260 </Checkbox>
261 </Tooltip>
262 );
263}
264
265function CondenseObsoleteSetting() {
266 const [value, setValue] = useAtom(condenseObsoleteStacks);
267 return (
268 <Tooltip
269 title={t(
270 'Visually condense a continuous stack of obsolete commits into just the top and bottom commits.',
271 )}>
272 <Checkbox
273 data-testid="condense-obsolete-stacks"
274 checked={value !== false}
275 onChange={checked => {
276 setValue(checked);
277 }}>
278 <T>Condense Obsolete Stacks</T>
279 </Checkbox>
280 </Tooltip>
281 );
282}
283
284function DeemphasizeIrrelevantCommitsSetting() {
285 const [value, setValue] = useAtom(irrelevantCwdDisplayModeAtom);
286 return (
287 <Tooltip
288 title={t(
289 'How to display commits which only change files in an unrelated directory to your current working directory.\n',
290 )}>
291 <div className="dropdown-container setting-inline-dropdown">
292 <T>Cwd-Irrelevant Commits</T>
293 <Dropdown<{value: typeof value; name: string}>
294 data-testid="cwd-irrelevant-commits"
295 options={[
296 {value: 'show', name: t('Show')},
297 {value: 'deemphasize', name: t('Deemphasize')},
298 {value: 'hide', name: t('Hide')},
299 ]}
300 value={value}
301 onChange={event => {
302 setValue(event.currentTarget.value as typeof value);
303 }}
304 />
305 </div>
306 </Tooltip>
307 );
308}
309
310function RebaseOffWarmWarningSetting() {
311 const [value, setValue] = useAtom(rebaseOffWarmWarningEnabled);
312 return (
313 <Tooltip
314 title={t(
315 'Show a warning when rebasing off a commit that is not warm (i.e. not in the current stack).',
316 )}>
317 <Checkbox
318 data-testid="rebase-off-warm-warning-enabled"
319 checked={value}
320 onChange={checked => {
321 setValue(checked);
322 }}>
323 <T>Show Warning on Rebase Off Warm</T>
324 </Checkbox>
325 </Tooltip>
326 );
327}
328
329function DistantRebaseWarningSetting() {
330 const [value, setValue] = useAtom(distantRebaseWarningEnabled);
331 return (
332 <Tooltip
333 title={t(
334 'Show a warning when rebasing onto a commit that is significantly older than the current commit.',
335 )}>
336 <Checkbox
337 data-testid="distant-rebase-warning-enabled"
338 checked={value}
339 onChange={checked => {
340 setValue(checked);
341 }}>
342 <T>Show Warning on Distant Rebase</T>
343 </Checkbox>
344 </Tooltip>
345 );
346}
347
348function RebaseOntoMasterWarningSetting() {
349 const [value, setValue] = useAtom(rebaseOntoMasterWarningEnabled);
350 return (
351 <Tooltip
352 title={t(
353 'Show a warning when rebasing directly onto master branch, which can cause unexpected failures and slower builds.',
354 )}>
355 <Checkbox
356 data-testid="rebase-master-warning-enabled"
357 checked={value}
358 onChange={checked => {
359 setValue(checked);
360 }}>
361 <T>Show Warning on Rebase onto Master</T>
362 </Checkbox>
363 </Tooltip>
364 );
365}
366
367export const openFileCmdAtom = configBackedAtom<string | null>(
368 'isl.open-file-cmd',
369 null,
370 /* readonly */ true,
371 /* use raw value */ true,
372);
373
374function OpenFilesCmdSetting() {
375 const cmdRaw = useAtomValue(openFileCmdAtom);
376 const cmd = cmdRaw == null ? null : ((tryJsonParse(cmdRaw) as string | Array<string>) ?? cmdRaw);
377 const cmdEl =
378 cmd == null ? (
379 <T>OS Default Program</T>
380 ) : (
381 <code>{Array.isArray(cmd) ? cmd.join(' ') : cmd}</code>
382 );
383 return (
384 <Tooltip
385 component={() => (
386 <div>
387 <div>
388 <T>You can configure how to open files from ISL via</T>
389 </div>
390 <pre>sl config --user isl.open-file-cmd "/path/to/command"</pre>
391 <div>
392 <T>or</T>
393 </div>
394 <pre>sl config --user isl.open-file-cmd '["cmd", "with", "args"]'</pre>
395 </div>
396 )}>
397 <Row>
398 <T replace={{$cmd: cmdEl}}>Open files in: $cmd</T>
399 <Subtle>
400 <T>How to configure?</T>
401 </Subtle>
402 <Icon icon="question" />
403 </Row>
404 </Tooltip>
405 );
406}
407
408function ExternalMergeToolSetting() {
409 const mergeTool = useAtomValue(externalMergeToolAtom);
410 const cmdEl = mergeTool == null ? <T>None</T> : <code>{mergeTool}</code>;
411 return (
412 <Tooltip
413 component={() => (
414 <div>
415 <div style={{alignItems: 'flex-start', maxWidth: 400}}>
416 <T
417 replace={{
418 $help: <code>sl help config.merge-tools</code>,
419 $configedit: <code>sl config --edit</code>,
420 $mymergetool: <code>merge-tools.mymergetool</code>,
421 $uimerge: <code>ui.merge = mymergetool</code>,
422 $gui: <code>merge-tools.mymergetool.gui</code>,
423 $local: <code>--local</code>,
424 $br: (
425 <>
426 <br />
427 <br />
428 </>
429 ),
430 }}>
431 You can configure Sapling and ISL to use a custom external merge tool, which is used
432 when a merge conflict is detected.$br Define your tool with $configedit (or with
433 $local to configure only for the current repository), by setting $mymergetool and
434 $uimerge$brCLI merge tools like vimdiff won't be used from ISL. Ensure $gui is set to
435 True.$br For more information, see: $help
436 </T>
437 </div>
438 </div>
439 )}>
440 <Row>
441 <T replace={{$cmd: cmdEl}}>External Merge Tool: $cmd</T>
442 <Subtle>
443 <T>How to configure?</T>
444 </Subtle>
445 <Icon icon="question" />
446 </Row>
447 </Tooltip>
448 );
449}
450
451function ZoomUISetting() {
452 const [zoom, setZoom] = useAtom(zoomUISettingAtom);
453 function roundToPercent(n: number): number {
454 return Math.round(n * 100) / 100;
455 }
456 return (
457 <div className="zoom-setting">
458 <Tooltip title={t('Decrease UI Zoom')}>
459 <Button
460 icon
461 onClick={() => {
462 setZoom(roundToPercent(zoom - 0.1));
463 }}>
464 <Icon icon="zoom-out" />
465 </Button>
466 </Tooltip>
467 <span>{`${Math.round(100 * zoom)}%`}</span>
468 <Tooltip title={t('Increase UI Zoom')}>
469 <Button
470 icon
471 onClick={() => {
472 setZoom(roundToPercent(zoom + 0.1));
473 }}>
474 <Icon icon="zoom-in" />
475 </Button>
476 </Tooltip>
477 <div style={{width: '20px'}} />
478 <label>
479 <T>Presets:</T>
480 </label>
481 <Button
482 style={{fontSize: '80%'}}
483 icon
484 onClick={() => {
485 setZoom(0.8);
486 }}>
487 <T>Small</T>
488 </Button>
489 <Button
490 icon
491 onClick={() => {
492 setZoom(1.0);
493 }}>
494 <T>Normal</T>
495 </Button>
496 <Button
497 style={{fontSize: '120%'}}
498 icon
499 onClick={() => {
500 setZoom(1.2);
501 }}>
502 <T>Large</T>
503 </Button>
504 </div>
505 );
506}
507
508function DebugToolsField() {
509 const [isDebug, setIsDebug] = useAtom(debugToolsEnabledState);
510 const [overrideDisabledSubmit, setOverrideDisabledSubmit] = useAtom(overrideDisabledSubmitModes);
511 const [debugFlag, setDebugFlag] = useAtom(enableSaplingDebugFlag);
512 const [verboseFlag, setVerboseFlag] = useAtom(enableSaplingVerboseFlag);
513 const provider = useAtomValue(codeReviewProvider);
514 const commandName = useAtomValue(mainCommandName);
515
516 const [branchPrsEnabled, setBranchPrsEnabled] = useAtom(experimentalBranchPRsEnabled);
517
518 return (
519 <DropdownField title={t('Debug Tools & Experimental')}>
520 <Column alignStart>
521 <Checkbox
522 checked={isDebug}
523 onChange={checked => {
524 setIsDebug(checked);
525 }}>
526 <T>Enable Debug Tools</T>
527 </Checkbox>
528 <ExperimentalFeaturesCheckbox />
529 {provider?.submitDisabledReason?.() != null && (
530 <Checkbox
531 checked={overrideDisabledSubmit}
532 onChange={setOverrideDisabledSubmit}
533 data-testid="force-enable-github-submit">
534 <T>Force enable `sl pr submit` and `sl ghstack submit`</T>
535 </Checkbox>
536 )}
537 {provider?.supportBranchingPrs === true && (
538 <Checkbox
539 checked={branchPrsEnabled}
540 onChange={checked => {
541 setBranchPrsEnabled(checked);
542 }}>
543 <T>Enable Experimental Branching PRs for GitHub</T>
544 </Checkbox>
545 )}
546 <Row>
547 <T
548 replace={{
549 $sl: <code>{commandName}</code>,
550 $verbose: (
551 <Checkbox checked={verboseFlag} onChange={setVerboseFlag}>
552 <code>--verbose</code>
553 </Checkbox>
554 ),
555 $debug: (
556 <Checkbox checked={debugFlag} onChange={setDebugFlag}>
557 <code>--debug</code>
558 </Checkbox>
559 ),
560 }}>
561 Pass extra flags to $sl: $verbose $debug
562 </T>
563 </Row>
564 </Column>
565 </DropdownField>
566 );
567}
568
569function ExperimentalFeaturesCheckbox() {
570 const [experimentalFeaturesEnabled, setExperimentalFeaturesEnabled] =
571 useAtom(hasExperimentalFeatures);
572
573 if (currentExperimentalFeaturesList.length === 0) {
574 return null;
575 }
576
577 return (
578 <Tooltip
579 title={t(
580 `Enable experimental features that are still being developed and may not work as expected.
581
582Current experimental features: ${currentExperimentalFeaturesList.join(', ')}`,
583 )}>
584 <Checkbox
585 checked={experimentalFeaturesEnabled}
586 onChange={checked => {
587 setExperimentalFeaturesEnabled(checked);
588 }}>
589 <T>Enable Experimental Features</T>
590 </Checkbox>
591 </Tooltip>
592 );
593}
594