11.9 KB362 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 {ReactNode} from 'react';
9import type {Operation} from './operations/Operation';
10import type {ValidatedRepoInfo} from './types';
11
12import {Banner, BannerKind} from 'isl-components/Banner';
13import {Button} from 'isl-components/Button';
14import {Column, Row} from 'isl-components/Flex';
15import {Icon} from 'isl-components/Icon';
16import {Subtle} from 'isl-components/Subtle';
17import {Tooltip} from 'isl-components/Tooltip';
18import {atom, useAtom, useAtomValue} from 'jotai';
19import {notEmpty, truncate} from 'shared/utils';
20import {Delayed} from './Delayed';
21import {LogRenderExposures} from './analytics/LogRenderExposures';
22import {codeReviewProvider} from './codeReview/CodeReviewInfo';
23import {T, t} from './i18n';
24import {
25 EXIT_CODE_FORGET,
26 operationList,
27 queuedOperations,
28 queuedOperationsErrorAtom,
29 useAbortRunningOperation,
30} from './operationsState';
31import {repositoryInfo} from './serverAPIState';
32import {processTerminalLines} from './terminalOutput';
33import {CommandRunner} from './types';
34import {short} from './utils';
35
36import './CommandHistoryAndProgress.css';
37
38function OperationDescription(props: {
39 info: ValidatedRepoInfo;
40 operation: Operation;
41 className?: string;
42 long?: boolean;
43}): React.ReactElement {
44 const {info, operation, className} = props;
45 const desc = operation.getDescriptionForDisplay();
46
47 const reviewProvider = useAtomValue(codeReviewProvider);
48
49 if (desc?.description) {
50 return <span className={className}>{desc.description}</span>;
51 }
52
53 const commandName =
54 operation.runner === CommandRunner.Sapling
55 ? (/[^\\/]+$/.exec(info.command)?.[0] ?? 'sl')
56 : operation.runner === CommandRunner.CodeReviewProvider
57 ? reviewProvider?.cliName
58 : operation.runner === CommandRunner.InternalArcanist
59 ? CommandRunner.InternalArcanist
60 : null;
61 return (
62 <code className={className}>
63 {(commandName ?? '') +
64 ' ' +
65 operation
66 .getArgs()
67 .map(arg => {
68 if (typeof arg === 'object') {
69 switch (arg.type) {
70 case 'config':
71 // don't show configs in the UI
72 return undefined;
73 case 'repo-relative-file':
74 return arg.path;
75 case 'repo-relative-file-list':
76 return truncate(arg.paths.join(' '), 200);
77 case 'exact-revset':
78 case 'succeedable-revset':
79 case 'optimistic-revset':
80 return props.long
81 ? arg.revset
82 : // truncate full commit hashes to short representation visually
83 // revset could also be a remote bookmark, so only do this if it looks like a hash
84 /^[a-z0-9]{40}$/.test(arg.revset)
85 ? short(arg.revset)
86 : truncate(arg.revset, 80);
87 }
88 }
89 if (/\s/.test(arg)) {
90 return `"${props.long ? arg : truncate(arg, 30)}"`;
91 }
92 return arg;
93 })
94 .filter(notEmpty)
95 .join(' ')}
96 </code>
97 );
98}
99
100const nextToRunCollapsedAtom = atom(false);
101const queueErrorCollapsedAtom = atom(true);
102
103export function CommandHistoryAndProgress() {
104 const list = useAtomValue(operationList);
105 const queued = useAtomValue(queuedOperations);
106 const [queuedError, setQueuedError] = useAtom(queuedOperationsErrorAtom);
107 const abortRunningOperation = useAbortRunningOperation();
108
109 const [collapsed, setCollapsed] = useAtom(nextToRunCollapsedAtom);
110 const [errorCollapsed, setErrorCollapsed] = useAtom(queueErrorCollapsedAtom);
111
112 const info = useAtomValue(repositoryInfo);
113 if (!info) {
114 return null;
115 }
116
117 const progress = list.currentOperation;
118 if (progress == null) {
119 return null;
120 }
121
122 const desc = progress.operation.getDescriptionForDisplay();
123 const command = (
124 <OperationDescription
125 info={info}
126 operation={progress.operation}
127 className="progress-container-command"
128 />
129 );
130
131 let label;
132 let icon;
133 let abort = null;
134 let showLastLineOfOutput = false;
135 if (progress.exitCode == null) {
136 label = desc?.description ? command : <T replace={{$command: command}}>Running $command</T>;
137 icon = <Icon icon="loading" />;
138 showLastLineOfOutput = desc?.tooltip == null;
139 // Only show "Abort" for slow commands, since "Abort" might leave modified
140 // files or pending commits around.
141 const slowThreshold = 10000;
142 const hideUntil = new Date((progress.startTime?.getTime() || 0) + slowThreshold);
143 abort = (
144 <Delayed hideUntil={hideUntil}>
145 <Button
146 data-testid="abort-button"
147 disabled={progress.aborting}
148 onClick={() => {
149 abortRunningOperation(progress.operation.id);
150 }}>
151 <Icon slot="start" icon={progress.aborting ? 'loading' : 'stop-circle'} />
152 <T>Abort</T>
153 </Button>
154 </Delayed>
155 );
156 } else if (progress.exitCode === 0) {
157 label = <span>{command}</span>;
158 icon = <Icon icon="pass" aria-label={t('Command exited successfully')} />;
159 } else if (progress.aborting) {
160 // Exited (tested above) by abort.
161 label = <T replace={{$command: command}}>Aborted $command</T>;
162 icon = <Icon icon="stop-circle" aria-label={t('Command aborted')} />;
163 } else if (progress.exitCode === EXIT_CODE_FORGET) {
164 label = <span>{command}</span>;
165 icon = (
166 <Icon
167 icon="question"
168 aria-label={t('Command ran during disconnection. Exit status is lost.')}
169 />
170 );
171 } else {
172 label = <span>{command}</span>;
173 icon = <Icon icon="error" aria-label={t('Command exited unsuccessfully')} />;
174 showLastLineOfOutput = true;
175 }
176
177 let processedLines = processTerminalLines(progress.commandOutput ?? []);
178 if (desc?.tooltip != null) {
179 // Output might contain a JSON string not suitable for human reading.
180 // Filter the line out.
181 processedLines = processedLines.filter(line => !line.startsWith('{'));
182 }
183
184 return (
185 <div className="progress-container" data-testid="progress-container">
186 {queuedError != null || queued.length > 0 ? (
187 <div className="queued-operations-container" data-testid="queued-commands">
188 {queuedError != null && (
189 <LogRenderExposures eventName="QueueCancelledWarningShown">
190 <Column alignStart data-testid="cancelled-queued-commands">
191 <Tooltip
192 title={t(
193 'When an operation process fails or is aborted, any operations queued after that are cancelled, as they may depend on the previous operation succeeding.',
194 )}>
195 <Row
196 style={{cursor: 'pointer'}}
197 onClick={() => {
198 setErrorCollapsed(!errorCollapsed);
199 }}>
200 <Icon icon={errorCollapsed ? 'chevron-right' : 'chevron-down'} />
201 <Banner kind={BannerKind.warning}>
202 <Icon icon="warning" color="yellow" />
203 <T count={queuedError.operations.length}>queuedOperationsWereCancelled</T>
204 </Banner>
205 <Tooltip title={t('Dismiss')}>
206 <Button
207 icon
208 onClick={() => {
209 setQueuedError(undefined);
210 }}>
211 <Icon icon="x" />
212 </Button>
213 </Tooltip>
214 </Row>
215 </Tooltip>
216 {errorCollapsed ? null : (
217 <TruncatedOperationList operations={queuedError.operations} info={info} />
218 )}
219 </Column>
220 </LogRenderExposures>
221 )}
222 {queued.length > 0 ? (
223 <>
224 <Row
225 style={{cursor: 'pointer'}}
226 onClick={() => {
227 setCollapsed(!collapsed);
228 }}>
229 <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} />
230 <strong>
231 <T>Next to run</T>
232 </strong>
233 </Row>
234 {collapsed ? (
235 <div>
236 <T count={queued.length}>moreCommandsToRun</T>
237 </div>
238 ) : (
239 <TruncatedOperationList operations={queued} info={info} />
240 )}
241 </>
242 ) : null}
243 </div>
244 ) : null}
245
246 <Tooltip
247 component={() => (
248 <div className="progress-command-tooltip">
249 {desc?.tooltip || (
250 <>
251 <div className="progress-command-tooltip-command">
252 <strong>Command: </strong>
253 <OperationDescription info={info} operation={progress.operation} long />
254 </div>
255 </>
256 )}
257 <br />
258 <b>Command output:</b>
259 <br />
260 {processedLines.length === 0 ? (
261 <Subtle>
262 <T>No output</T>
263 </Subtle>
264 ) : (
265 <pre>
266 {processedLines.map((line, i) => (
267 <div key={i}>{line}</div>
268 ))}
269 </pre>
270 )}
271 </div>
272 )}
273 interactive>
274 <div className="progress-container-row">
275 {icon}
276 {label}
277 {progress.warnings?.map(warning => (
278 <Banner
279 icon={<Icon icon="warning" color="yellow" />}
280 alwaysShowButtons
281 kind={BannerKind.warning}>
282 <T replace={{$provider: warning}}>$provider</T>
283 </Banner>
284 ))}
285 </div>
286 {showLastLineOfOutput ? (
287 <div className="progress-container-row">
288 <div className="progress-container-last-output">
289 {progress.currentProgress != null && progress.currentProgress.unit != null ? (
290 <ProgressLine
291 progress={progress.currentProgress.progress}
292 progressTotal={progress.currentProgress.progressTotal}>
293 {progress.currentProgress.message +
294 ` - ${progress.currentProgress.progress}/${progress.currentProgress.progressTotal} ${progress.currentProgress.unit}`}
295 </ProgressLine>
296 ) : (
297 processedLines.length > 0 && <ProgressLine>{processedLines.at(-1)}</ProgressLine>
298 )}
299 </div>
300 </div>
301 ) : null}
302 {abort}
303 </Tooltip>
304 </div>
305 );
306}
307
308const MAX_VISIBLE_NEXT_TO_RUN = 10;
309function TruncatedOperationList({
310 info,
311 operations,
312}: {
313 info: ValidatedRepoInfo;
314 operations: Array<Operation>;
315}) {
316 return (
317 <>
318 {(operations.length > MAX_VISIBLE_NEXT_TO_RUN
319 ? operations.slice(0, MAX_VISIBLE_NEXT_TO_RUN)
320 : operations
321 ).map(op => (
322 <div key={op.id} id={op.id} className="queued-operation">
323 <OperationDescription info={info} operation={op} />
324 </div>
325 ))}
326 {operations.length > MAX_VISIBLE_NEXT_TO_RUN && (
327 <div>
328 <T replace={{$count: operations.length - MAX_VISIBLE_NEXT_TO_RUN}}>+$count more</T>
329 </div>
330 )}
331 </>
332 );
333}
334
335function ProgressLine({
336 children,
337 progress,
338 progressTotal,
339}: {
340 children: ReactNode;
341 progress?: number;
342 progressTotal?: number;
343}) {
344 return (
345 <span className="progress-line">
346 {progress != null && progressTotal != null ? (
347 <ProgressBar progress={progress} progressTotal={progressTotal} />
348 ) : null}
349 <code>{children}</code>
350 </span>
351 );
352}
353
354function ProgressBar({progress, progressTotal}: {progress: number; progressTotal: number}) {
355 const pct = progress / progressTotal;
356 return (
357 <span className="progress-bar">
358 <span className="progress-bar-filled" style={{width: `${Math.round(100 * pct)}%`}} />
359 </span>
360 );
361}
362