addons/isl/src/CommandHistoryAndProgress.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 {ReactNode} from 'react';
b69ab319import type {Operation} from './operations/Operation';
b69ab3110import type {ValidatedRepoInfo} from './types';
b69ab3111
b69ab3112import {Banner, BannerKind} from 'isl-components/Banner';
b69ab3113import {Button} from 'isl-components/Button';
b69ab3114import {Column, Row} from 'isl-components/Flex';
b69ab3115import {Icon} from 'isl-components/Icon';
b69ab3116import {Subtle} from 'isl-components/Subtle';
b69ab3117import {Tooltip} from 'isl-components/Tooltip';
b69ab3118import {atom, useAtom, useAtomValue} from 'jotai';
b69ab3119import {notEmpty, truncate} from 'shared/utils';
b69ab3120import {Delayed} from './Delayed';
b69ab3121import {LogRenderExposures} from './analytics/LogRenderExposures';
b69ab3122import {codeReviewProvider} from './codeReview/CodeReviewInfo';
b69ab3123import {T, t} from './i18n';
b69ab3124import {
b69ab3125 EXIT_CODE_FORGET,
b69ab3126 operationList,
b69ab3127 queuedOperations,
b69ab3128 queuedOperationsErrorAtom,
b69ab3129 useAbortRunningOperation,
b69ab3130} from './operationsState';
b69ab3131import {repositoryInfo} from './serverAPIState';
b69ab3132import {processTerminalLines} from './terminalOutput';
b69ab3133import {CommandRunner} from './types';
b69ab3134import {short} from './utils';
b69ab3135
b69ab3136import './CommandHistoryAndProgress.css';
b69ab3137
b69ab3138function OperationDescription(props: {
b69ab3139 info: ValidatedRepoInfo;
b69ab3140 operation: Operation;
b69ab3141 className?: string;
b69ab3142 long?: boolean;
b69ab3143}): React.ReactElement {
b69ab3144 const {info, operation, className} = props;
b69ab3145 const desc = operation.getDescriptionForDisplay();
b69ab3146
b69ab3147 const reviewProvider = useAtomValue(codeReviewProvider);
b69ab3148
b69ab3149 if (desc?.description) {
b69ab3150 return <span className={className}>{desc.description}</span>;
b69ab3151 }
b69ab3152
b69ab3153 const commandName =
b69ab3154 operation.runner === CommandRunner.Sapling
b69ab3155 ? (/[^\\/]+$/.exec(info.command)?.[0] ?? 'sl')
b69ab3156 : operation.runner === CommandRunner.CodeReviewProvider
b69ab3157 ? reviewProvider?.cliName
b69ab3158 : operation.runner === CommandRunner.InternalArcanist
b69ab3159 ? CommandRunner.InternalArcanist
b69ab3160 : null;
b69ab3161 return (
b69ab3162 <code className={className}>
b69ab3163 {(commandName ?? '') +
b69ab3164 ' ' +
b69ab3165 operation
b69ab3166 .getArgs()
b69ab3167 .map(arg => {
b69ab3168 if (typeof arg === 'object') {
b69ab3169 switch (arg.type) {
b69ab3170 case 'config':
b69ab3171 // don't show configs in the UI
b69ab3172 return undefined;
b69ab3173 case 'repo-relative-file':
b69ab3174 return arg.path;
b69ab3175 case 'repo-relative-file-list':
b69ab3176 return truncate(arg.paths.join(' '), 200);
b69ab3177 case 'exact-revset':
b69ab3178 case 'succeedable-revset':
b69ab3179 case 'optimistic-revset':
b69ab3180 return props.long
b69ab3181 ? arg.revset
b69ab3182 : // truncate full commit hashes to short representation visually
b69ab3183 // revset could also be a remote bookmark, so only do this if it looks like a hash
b69ab3184 /^[a-z0-9]{40}$/.test(arg.revset)
b69ab3185 ? short(arg.revset)
b69ab3186 : truncate(arg.revset, 80);
b69ab3187 }
b69ab3188 }
b69ab3189 if (/\s/.test(arg)) {
b69ab3190 return `"${props.long ? arg : truncate(arg, 30)}"`;
b69ab3191 }
b69ab3192 return arg;
b69ab3193 })
b69ab3194 .filter(notEmpty)
b69ab3195 .join(' ')}
b69ab3196 </code>
b69ab3197 );
b69ab3198}
b69ab3199
b69ab31100const nextToRunCollapsedAtom = atom(false);
b69ab31101const queueErrorCollapsedAtom = atom(true);
b69ab31102
b69ab31103export function CommandHistoryAndProgress() {
b69ab31104 const list = useAtomValue(operationList);
b69ab31105 const queued = useAtomValue(queuedOperations);
b69ab31106 const [queuedError, setQueuedError] = useAtom(queuedOperationsErrorAtom);
b69ab31107 const abortRunningOperation = useAbortRunningOperation();
b69ab31108
b69ab31109 const [collapsed, setCollapsed] = useAtom(nextToRunCollapsedAtom);
b69ab31110 const [errorCollapsed, setErrorCollapsed] = useAtom(queueErrorCollapsedAtom);
b69ab31111
b69ab31112 const info = useAtomValue(repositoryInfo);
b69ab31113 if (!info) {
b69ab31114 return null;
b69ab31115 }
b69ab31116
b69ab31117 const progress = list.currentOperation;
b69ab31118 if (progress == null) {
b69ab31119 return null;
b69ab31120 }
b69ab31121
b69ab31122 const desc = progress.operation.getDescriptionForDisplay();
b69ab31123 const command = (
b69ab31124 <OperationDescription
b69ab31125 info={info}
b69ab31126 operation={progress.operation}
b69ab31127 className="progress-container-command"
b69ab31128 />
b69ab31129 );
b69ab31130
b69ab31131 let label;
b69ab31132 let icon;
b69ab31133 let abort = null;
b69ab31134 let showLastLineOfOutput = false;
b69ab31135 if (progress.exitCode == null) {
b69ab31136 label = desc?.description ? command : <T replace={{$command: command}}>Running $command</T>;
b69ab31137 icon = <Icon icon="loading" />;
b69ab31138 showLastLineOfOutput = desc?.tooltip == null;
b69ab31139 // Only show "Abort" for slow commands, since "Abort" might leave modified
b69ab31140 // files or pending commits around.
b69ab31141 const slowThreshold = 10000;
b69ab31142 const hideUntil = new Date((progress.startTime?.getTime() || 0) + slowThreshold);
b69ab31143 abort = (
b69ab31144 <Delayed hideUntil={hideUntil}>
b69ab31145 <Button
b69ab31146 data-testid="abort-button"
b69ab31147 disabled={progress.aborting}
b69ab31148 onClick={() => {
b69ab31149 abortRunningOperation(progress.operation.id);
b69ab31150 }}>
b69ab31151 <Icon slot="start" icon={progress.aborting ? 'loading' : 'stop-circle'} />
b69ab31152 <T>Abort</T>
b69ab31153 </Button>
b69ab31154 </Delayed>
b69ab31155 );
b69ab31156 } else if (progress.exitCode === 0) {
b69ab31157 label = <span>{command}</span>;
b69ab31158 icon = <Icon icon="pass" aria-label={t('Command exited successfully')} />;
b69ab31159 } else if (progress.aborting) {
b69ab31160 // Exited (tested above) by abort.
b69ab31161 label = <T replace={{$command: command}}>Aborted $command</T>;
b69ab31162 icon = <Icon icon="stop-circle" aria-label={t('Command aborted')} />;
b69ab31163 } else if (progress.exitCode === EXIT_CODE_FORGET) {
b69ab31164 label = <span>{command}</span>;
b69ab31165 icon = (
b69ab31166 <Icon
b69ab31167 icon="question"
b69ab31168 aria-label={t('Command ran during disconnection. Exit status is lost.')}
b69ab31169 />
b69ab31170 );
b69ab31171 } else {
b69ab31172 label = <span>{command}</span>;
b69ab31173 icon = <Icon icon="error" aria-label={t('Command exited unsuccessfully')} />;
b69ab31174 showLastLineOfOutput = true;
b69ab31175 }
b69ab31176
b69ab31177 let processedLines = processTerminalLines(progress.commandOutput ?? []);
b69ab31178 if (desc?.tooltip != null) {
b69ab31179 // Output might contain a JSON string not suitable for human reading.
b69ab31180 // Filter the line out.
b69ab31181 processedLines = processedLines.filter(line => !line.startsWith('{'));
b69ab31182 }
b69ab31183
b69ab31184 return (
b69ab31185 <div className="progress-container" data-testid="progress-container">
b69ab31186 {queuedError != null || queued.length > 0 ? (
b69ab31187 <div className="queued-operations-container" data-testid="queued-commands">
b69ab31188 {queuedError != null && (
b69ab31189 <LogRenderExposures eventName="QueueCancelledWarningShown">
b69ab31190 <Column alignStart data-testid="cancelled-queued-commands">
b69ab31191 <Tooltip
b69ab31192 title={t(
b69ab31193 'When an operation process fails or is aborted, any operations queued after that are cancelled, as they may depend on the previous operation succeeding.',
b69ab31194 )}>
b69ab31195 <Row
b69ab31196 style={{cursor: 'pointer'}}
b69ab31197 onClick={() => {
b69ab31198 setErrorCollapsed(!errorCollapsed);
b69ab31199 }}>
b69ab31200 <Icon icon={errorCollapsed ? 'chevron-right' : 'chevron-down'} />
b69ab31201 <Banner kind={BannerKind.warning}>
b69ab31202 <Icon icon="warning" color="yellow" />
b69ab31203 <T count={queuedError.operations.length}>queuedOperationsWereCancelled</T>
b69ab31204 </Banner>
b69ab31205 <Tooltip title={t('Dismiss')}>
b69ab31206 <Button
b69ab31207 icon
b69ab31208 onClick={() => {
b69ab31209 setQueuedError(undefined);
b69ab31210 }}>
b69ab31211 <Icon icon="x" />
b69ab31212 </Button>
b69ab31213 </Tooltip>
b69ab31214 </Row>
b69ab31215 </Tooltip>
b69ab31216 {errorCollapsed ? null : (
b69ab31217 <TruncatedOperationList operations={queuedError.operations} info={info} />
b69ab31218 )}
b69ab31219 </Column>
b69ab31220 </LogRenderExposures>
b69ab31221 )}
b69ab31222 {queued.length > 0 ? (
b69ab31223 <>
b69ab31224 <Row
b69ab31225 style={{cursor: 'pointer'}}
b69ab31226 onClick={() => {
b69ab31227 setCollapsed(!collapsed);
b69ab31228 }}>
b69ab31229 <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} />
b69ab31230 <strong>
b69ab31231 <T>Next to run</T>
b69ab31232 </strong>
b69ab31233 </Row>
b69ab31234 {collapsed ? (
b69ab31235 <div>
b69ab31236 <T count={queued.length}>moreCommandsToRun</T>
b69ab31237 </div>
b69ab31238 ) : (
b69ab31239 <TruncatedOperationList operations={queued} info={info} />
b69ab31240 )}
b69ab31241 </>
b69ab31242 ) : null}
b69ab31243 </div>
b69ab31244 ) : null}
b69ab31245
b69ab31246 <Tooltip
b69ab31247 component={() => (
b69ab31248 <div className="progress-command-tooltip">
b69ab31249 {desc?.tooltip || (
b69ab31250 <>
b69ab31251 <div className="progress-command-tooltip-command">
b69ab31252 <strong>Command: </strong>
b69ab31253 <OperationDescription info={info} operation={progress.operation} long />
b69ab31254 </div>
b69ab31255 </>
b69ab31256 )}
b69ab31257 <br />
b69ab31258 <b>Command output:</b>
b69ab31259 <br />
b69ab31260 {processedLines.length === 0 ? (
b69ab31261 <Subtle>
b69ab31262 <T>No output</T>
b69ab31263 </Subtle>
b69ab31264 ) : (
b69ab31265 <pre>
b69ab31266 {processedLines.map((line, i) => (
b69ab31267 <div key={i}>{line}</div>
b69ab31268 ))}
b69ab31269 </pre>
b69ab31270 )}
b69ab31271 </div>
b69ab31272 )}
b69ab31273 interactive>
b69ab31274 <div className="progress-container-row">
b69ab31275 {icon}
b69ab31276 {label}
b69ab31277 {progress.warnings?.map(warning => (
b69ab31278 <Banner
b69ab31279 icon={<Icon icon="warning" color="yellow" />}
b69ab31280 alwaysShowButtons
b69ab31281 kind={BannerKind.warning}>
b69ab31282 <T replace={{$provider: warning}}>$provider</T>
b69ab31283 </Banner>
b69ab31284 ))}
b69ab31285 </div>
b69ab31286 {showLastLineOfOutput ? (
b69ab31287 <div className="progress-container-row">
b69ab31288 <div className="progress-container-last-output">
b69ab31289 {progress.currentProgress != null && progress.currentProgress.unit != null ? (
b69ab31290 <ProgressLine
b69ab31291 progress={progress.currentProgress.progress}
b69ab31292 progressTotal={progress.currentProgress.progressTotal}>
b69ab31293 {progress.currentProgress.message +
b69ab31294 ` - ${progress.currentProgress.progress}/${progress.currentProgress.progressTotal} ${progress.currentProgress.unit}`}
b69ab31295 </ProgressLine>
b69ab31296 ) : (
b69ab31297 processedLines.length > 0 && <ProgressLine>{processedLines.at(-1)}</ProgressLine>
b69ab31298 )}
b69ab31299 </div>
b69ab31300 </div>
b69ab31301 ) : null}
b69ab31302 {abort}
b69ab31303 </Tooltip>
b69ab31304 </div>
b69ab31305 );
b69ab31306}
b69ab31307
b69ab31308const MAX_VISIBLE_NEXT_TO_RUN = 10;
b69ab31309function TruncatedOperationList({
b69ab31310 info,
b69ab31311 operations,
b69ab31312}: {
b69ab31313 info: ValidatedRepoInfo;
b69ab31314 operations: Array<Operation>;
b69ab31315}) {
b69ab31316 return (
b69ab31317 <>
b69ab31318 {(operations.length > MAX_VISIBLE_NEXT_TO_RUN
b69ab31319 ? operations.slice(0, MAX_VISIBLE_NEXT_TO_RUN)
b69ab31320 : operations
b69ab31321 ).map(op => (
b69ab31322 <div key={op.id} id={op.id} className="queued-operation">
b69ab31323 <OperationDescription info={info} operation={op} />
b69ab31324 </div>
b69ab31325 ))}
b69ab31326 {operations.length > MAX_VISIBLE_NEXT_TO_RUN && (
b69ab31327 <div>
b69ab31328 <T replace={{$count: operations.length - MAX_VISIBLE_NEXT_TO_RUN}}>+$count more</T>
b69ab31329 </div>
b69ab31330 )}
b69ab31331 </>
b69ab31332 );
b69ab31333}
b69ab31334
b69ab31335function ProgressLine({
b69ab31336 children,
b69ab31337 progress,
b69ab31338 progressTotal,
b69ab31339}: {
b69ab31340 children: ReactNode;
b69ab31341 progress?: number;
b69ab31342 progressTotal?: number;
b69ab31343}) {
b69ab31344 return (
b69ab31345 <span className="progress-line">
b69ab31346 {progress != null && progressTotal != null ? (
b69ab31347 <ProgressBar progress={progress} progressTotal={progressTotal} />
b69ab31348 ) : null}
b69ab31349 <code>{children}</code>
b69ab31350 </span>
b69ab31351 );
b69ab31352}
b69ab31353
b69ab31354function ProgressBar({progress, progressTotal}: {progress: number; progressTotal: number}) {
b69ab31355 const pct = progress / progressTotal;
b69ab31356 return (
b69ab31357 <span className="progress-bar">
b69ab31358 <span className="progress-bar-filled" style={{width: `${Math.round(100 * pct)}%`}} />
b69ab31359 </span>
b69ab31360 );
b69ab31361}