addons/isl/src/Diagnostics.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 {Tracker} from 'isl-server/src/analytics/tracker';
b69ab319import type {UseUncommittedSelection} from './partialSelection';
b69ab3110import type {CommitInfo, Diagnostic, DiagnosticAllowlist} from './types';
b69ab3111
b69ab3112import * as stylex from '@stylexjs/stylex';
b69ab3113import {Checkbox} from 'isl-components/Checkbox';
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 {useAtom} from 'jotai';
b69ab3119import {basename} from 'shared/utils';
b69ab3120import {spacing} from '../../components/theme/tokens.stylex';
b69ab3121import serverAPI from './ClientToServerAPI';
b69ab3122import {Collapsable} from './Collapsable';
b69ab3123import {Internal} from './Internal';
b69ab3124import {tracker} from './analytics';
b69ab3125import {getFeatureFlag} from './featureFlags';
b69ab3126import {T, t} from './i18n';
b69ab3127import {localStorageBackedAtom, readAtom} from './jotaiUtils';
b69ab3128import platform from './platform';
b69ab3129import {uncommittedChangesWithPreviews} from './previews';
b69ab3130import {showModal} from './useModal';
b69ab3131
b69ab3132export const shouldWarnAboutDiagnosticsAtom = localStorageBackedAtom<boolean>(
b69ab3133 'isl.warn-about-diagnostics',
b69ab3134 true,
b69ab3135);
b69ab3136
b69ab3137const hideNonBlockingDiagnosticsAtom = localStorageBackedAtom<boolean>(
b69ab3138 'isl.hide-non-blocking-diagnostics',
b69ab3139 true,
b69ab3140);
b69ab3141
b69ab3142const styles = stylex.create({
b69ab3143 diagnosticList: {
b69ab3144 paddingInline: spacing.double,
b69ab3145 paddingBlock: spacing.half,
b69ab3146 gap: 0,
b69ab3147 },
b69ab3148 nowrap: {
b69ab3149 whiteSpace: 'nowrap',
b69ab3150 },
b69ab3151 diagnosticRow: {
b69ab3152 maxWidth: 'max(400px, 80vw)',
b69ab3153 padding: spacing.half,
b69ab3154 cursor: 'pointer',
b69ab3155 ':hover': {
b69ab3156 backgroundColor: 'var(--hover-darken)',
b69ab3157 },
b69ab3158 },
b69ab3159 allDiagnostics: {
b69ab3160 maxHeight: 'calc(100vh - 200px)',
b69ab3161 minHeight: '50px',
b69ab3162 overflowY: 'scroll',
b69ab3163 },
b69ab3164 confirmCheckbox: {
b69ab3165 paddingTop: spacing.double,
b69ab3166 },
b69ab3167});
b69ab3168
b69ab3169export function isBlockingDiagnostic(
b69ab3170 d: Diagnostic,
b69ab3171 /** Many diagnostics are low-quality and don't reflect what would appear on CI.
b69ab3172 * Start with an allowlist while we validate which signals are worthwhile. */
b69ab3173 allowlistedCodesBySource: undefined | DiagnosticAllowlist = Internal.allowlistedDiagnosticCodes ??
b69ab3174 undefined,
b69ab3175): boolean {
b69ab3176 if (allowlistedCodesBySource == null) {
b69ab3177 // In OSS, let's assume all errors are blocking.
b69ab3178 return true;
b69ab3179 }
b69ab3180 if (d.severity !== 'error' && d.severity !== 'warning') {
b69ab3181 return false;
b69ab3182 }
b69ab3183 if (allowlistedCodesBySource == null) {
b69ab3184 return true;
b69ab3185 }
b69ab3186 // source/code may be missing, but we still want to route that through the allowlist
b69ab3187 const source = d.source ?? 'undefined';
b69ab3188 const code = d.code ?? 'undefined';
b69ab3189 const relevantAllowlist = allowlistedCodesBySource.get(d.severity)?.get(source);
b69ab3190 return (
b69ab3191 relevantAllowlist != null &&
b69ab3192 (relevantAllowlist.allow
b69ab3193 ? relevantAllowlist.allow.has(code) === true
b69ab3194 : relevantAllowlist.block.has(code) === false)
b69ab3195 );
b69ab3196}
b69ab3197
b69ab3198function isErrorDiagnosticToLog(d: Diagnostic): boolean {
b69ab3199 return d.severity === 'error';
b69ab31100}
b69ab31101
b69ab31102/** Render diagnostic to a string, in the format `Source(Code): Snippet of error message` */
b69ab31103function previewDiagnostic(diagnostic: Diagnostic | undefined) {
b69ab31104 return diagnostic != null
b69ab31105 ? `${diagnostic.source}(${diagnostic.code}): ${diagnostic?.message.slice(0, 100)}`
b69ab31106 : undefined;
b69ab31107}
b69ab31108
b69ab31109/**
b69ab31110 * Check IDE diagnostics for files that will be commit/amended/submitted,
b69ab31111 * to confirm if they intended the errors.
b69ab31112 */
b69ab31113export async function confirmNoBlockingDiagnostics(
b69ab31114 /** Check diagnostics for these selected files. */
b69ab31115 selection: UseUncommittedSelection,
b69ab31116 /** If provided, warn for changes to files in this commit. Used when checking diagnostics when amending a commit. */
b69ab31117 commit?: CommitInfo,
b69ab31118): Promise<boolean> {
b69ab31119 if (!readAtom(shouldWarnAboutDiagnosticsAtom)) {
b69ab31120 return true;
b69ab31121 }
b69ab31122 if (platform.platformName === 'vscode') {
b69ab31123 const allFiles = new Set<string>();
b69ab31124 for (const file of readAtom(uncommittedChangesWithPreviews)) {
b69ab31125 if (selection.isFullyOrPartiallySelected(file.path)) {
b69ab31126 allFiles.add(file.path);
b69ab31127 }
b69ab31128 }
b69ab31129 for (const filePath of commit?.filePathsSample ?? []) {
b69ab31130 allFiles.add(filePath);
b69ab31131 }
b69ab31132
b69ab31133 serverAPI.postMessage({
b69ab31134 type: 'platform/checkForDiagnostics',
b69ab31135 paths: [...allFiles],
b69ab31136 });
b69ab31137 const [result, enabled] = await Promise.all([
b69ab31138 serverAPI.nextMessageMatching('platform/gotDiagnostics', () => true),
b69ab31139 getFeatureFlag(
b69ab31140 Internal.featureFlags?.ShowPresubmitDiagnosticsWarning,
b69ab31141 /* enable this feature in OSS */ true,
b69ab31142 ),
b69ab31143 ]);
b69ab31144 if (result.diagnostics.size > 0) {
b69ab31145 const allDiagnostics = [...result.diagnostics.values()];
b69ab31146 const allBlockingErrors = allDiagnostics
b69ab31147 .map(value => value.filter(d => isBlockingDiagnostic(d)))
b69ab31148 .flat();
b69ab31149 const totalErrors = allBlockingErrors.length;
b69ab31150
b69ab31151 // It's useful to track even the diagnostics that are filtered out, to refine the allowlist in the future
b69ab31152 const unfilteredErrors = allDiagnostics
b69ab31153 .map(value => value.filter(isErrorDiagnosticToLog))
b69ab31154 .flat();
b69ab31155
b69ab31156 const totalDiagnostics = allDiagnostics.flat().length;
b69ab31157
b69ab31158 const firstError = allBlockingErrors[0];
b69ab31159 const firstUnfilteredError = unfilteredErrors[0];
b69ab31160
b69ab31161 const childTracker = tracker.trackAsParent('DiagnosticsConfirmationOpportunity', {
b69ab31162 extras: {
b69ab31163 shown: enabled,
b69ab31164 unfilteredErrorCodes: [...new Set(unfilteredErrors.map(d => `${d.source}(${d.code})`))],
b69ab31165 filteredErrorCodes: [...new Set(allBlockingErrors.map(d => `${d.source}(${d.code})`))],
b69ab31166 sampleMessage: previewDiagnostic(firstError),
b69ab31167 unfilteredSampleMessage: previewDiagnostic(firstUnfilteredError),
b69ab31168 totalUnfilteredErrors: unfilteredErrors.length,
b69ab31169 totalErrors,
b69ab31170 totalDiagnostics,
b69ab31171 },
b69ab31172 });
b69ab31173
b69ab31174 if (!enabled) {
b69ab31175 return true;
b69ab31176 }
b69ab31177
b69ab31178 if (totalErrors > 0) {
b69ab31179 const buttons = [{label: 'Cancel'}, {label: 'Continue', primary: true}] as const;
b69ab31180 const shouldContinue =
b69ab31181 (await showModal({
b69ab31182 type: 'confirm',
b69ab31183 title: (
b69ab31184 <Row>
b69ab31185 <T count={totalErrors}>codeIssuesFound</T>
b69ab31186 <Tooltip
b69ab31187 title={t(
b69ab31188 'Error-severity issues are typically land blocking and should be resolved before submitting for code review.\n\n' +
b69ab31189 'Errors shown here are best-effort and not necessarily comprehensive.',
b69ab31190 )}>
b69ab31191 <Icon icon="info" />
b69ab31192 </Tooltip>
b69ab31193 </Row>
b69ab31194 ),
b69ab31195 message: (
b69ab31196 <DiagnosticsList
b69ab31197 diagnostics={[...result.diagnostics.entries()]}
b69ab31198 tracker={childTracker}
b69ab31199 />
b69ab31200 ),
b69ab31201 buttons,
b69ab31202 })) === buttons[1];
b69ab31203
b69ab31204 childTracker.track('DiagnosticsConfirmationAction', {
b69ab31205 extras: {
b69ab31206 action: shouldContinue ? 'continue' : 'cancel',
b69ab31207 },
b69ab31208 });
b69ab31209
b69ab31210 return shouldContinue;
b69ab31211 }
b69ab31212 }
b69ab31213 }
b69ab31214 return true;
b69ab31215}
b69ab31216
b69ab31217function DiagnosticsList({
b69ab31218 diagnostics,
b69ab31219 tracker,
b69ab31220}: {
b69ab31221 diagnostics: Array<[string, Array<Diagnostic>]>;
b69ab31222 tracker: Tracker<{parentId: string}>;
b69ab31223}) {
b69ab31224 const [hideNonBlocking, setHideNonBlocking] = useAtom(hideNonBlockingDiagnosticsAtom);
b69ab31225 const [shouldWarn, setShouldWarn] = useAtom(shouldWarnAboutDiagnosticsAtom);
b69ab31226 return (
b69ab31227 <>
b69ab31228 <Column alignStart xstyle={styles.allDiagnostics}>
b69ab31229 {diagnostics.map(([filepath, diagnostics]) => {
b69ab31230 const sortedDiagnostics = [...diagnostics].filter(d =>
b69ab31231 hideNonBlocking ? isBlockingDiagnostic(d) : true,
b69ab31232 );
b69ab31233 sortedDiagnostics.sort((a, b) => {
b69ab31234 return severityComparator(a) - severityComparator(b);
b69ab31235 });
b69ab31236 if (sortedDiagnostics.length === 0) {
b69ab31237 return null;
b69ab31238 }
b69ab31239 return (
b69ab31240 <Column key={filepath} alignStart>
b69ab31241 <Collapsable
b69ab31242 startExpanded
b69ab31243 title={
b69ab31244 <Row>
b69ab31245 <span>{basename(filepath)}</span>
b69ab31246 <Subtle>{filepath}</Subtle>
b69ab31247 </Row>
b69ab31248 }>
b69ab31249 <Column alignStart xstyle={styles.diagnosticList}>
b69ab31250 {sortedDiagnostics.map((d, i) => (
b69ab31251 <Row
b69ab31252 role="button"
b69ab31253 tabIndex={0}
b69ab31254 key={i}
b69ab31255 xstyle={styles.diagnosticRow}
b69ab31256 onClick={() => {
b69ab31257 platform.openFile(filepath, {line: d.range.startLine + 1});
b69ab31258 tracker.track('DiagnosticsConfirmationAction', {
b69ab31259 extras: {action: 'openFile'},
b69ab31260 });
b69ab31261 }}>
b69ab31262 {iconForDiagnostic(d)}
b69ab31263 <span>{d.message}</span>
b69ab31264 {d.source && (
b69ab31265 <Subtle {...stylex.props(styles.nowrap)}>
b69ab31266 {d.source}
b69ab31267 {d.code ? `(${d.code})` : null}
b69ab31268 </Subtle>
b69ab31269 )}{' '}
b69ab31270 <Subtle {...stylex.props(styles.nowrap)}>
b69ab31271 [Ln {d.range.startLine}, Col {d.range.startCol}]
b69ab31272 </Subtle>
b69ab31273 </Row>
b69ab31274 ))}
b69ab31275 </Column>
b69ab31276 </Collapsable>
b69ab31277 </Column>
b69ab31278 );
b69ab31279 })}
b69ab31280 </Column>
b69ab31281 <Row xstyle={styles.confirmCheckbox}>
b69ab31282 <Checkbox
b69ab31283 checked={!shouldWarn}
b69ab31284 onChange={checked => {
b69ab31285 setShouldWarn(!checked);
b69ab31286 if (checked) {
b69ab31287 tracker.track('DiagnosticsConfirmationAction', {extras: {action: 'dontAskAgain'}});
b69ab31288 }
b69ab31289 }}>
b69ab31290 <T>Don't ask again</T>
b69ab31291 </Checkbox>
b69ab31292 <Tooltip
b69ab31293 title={t(
b69ab31294 "Only 'error' severity issues known to cause problems will cause this dialog to appear, but less severe issues can still be shown here. This option hides these non-blocking issues.",
b69ab31295 )}>
b69ab31296 <Checkbox checked={hideNonBlocking} onChange={setHideNonBlocking}>
b69ab31297 <T>Hide non-blocking issues</T>
b69ab31298 </Checkbox>
b69ab31299 </Tooltip>
b69ab31300 </Row>
b69ab31301 </>
b69ab31302 );
b69ab31303}
b69ab31304
b69ab31305function severityComparator(a: Diagnostic) {
b69ab31306 switch (a.severity) {
b69ab31307 case 'error':
b69ab31308 return 0;
b69ab31309 case 'warning':
b69ab31310 return 1;
b69ab31311 case 'info':
b69ab31312 return 2;
b69ab31313 case 'hint':
b69ab31314 return 3;
b69ab31315 }
b69ab31316}
b69ab31317
b69ab31318function iconForDiagnostic(d: Diagnostic): React.ReactNode {
b69ab31319 switch (d.severity) {
b69ab31320 case 'error':
b69ab31321 return <Icon icon="error" color="red" />;
b69ab31322 case 'warning':
b69ab31323 return <Icon icon="warning" color="yellow" />;
b69ab31324 case 'info':
b69ab31325 return <Icon icon="info" />;
b69ab31326 case 'hint':
b69ab31327 return <Icon icon="info" />;
b69ab31328 }
b69ab31329}