LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_diagnostics.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 180 180
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : import * as styles from './script_diagnostics.module.css';
       4              : 
       5              : import { AlertIcon, XCircleFillIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
       6              : 
       7              : import type { ScriptData } from './session_scripts.js';
       8              : import type { ScriptReferenceReport } from './script_reference_check.js';
       9              : import { formattingDialectForConnector } from './script_format.js';
      10              : import type { ConnectorType } from '../../connections/connector_info.js';
      11              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
      12              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
      13              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
      14              : import { JsonView } from '../../ui/json/json_view.js';
      15              : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
      16              : 
      17            1 : export const SCRIPT_FORMATTING_WARNING = 'This script cannot be formatted';
      18              : 
      19              : export interface ScriptDiagnostic {
      20              :     severity: 'error' | 'warning';
      21              :     message: string;
      22              :     details: Record<string, unknown>;
      23              : }
      24              : 
      25            1 : function formattingDetails(scriptData: ScriptData, connectorType?: ConnectorType): Record<string, unknown> {
      26            1 :     const details: Record<string, unknown> = {
      27            1 :         source: 'formatter',
      28            1 :         severity: 'warning',
      29            1 :         message: SCRIPT_FORMATTING_WARNING,
      30            1 :         scriptKey: scriptData.scriptKey,
      31            1 :     };
      32            1 :     try {
      33            1 :         const dialect = formattingDialectForConnector(connectorType);
      34            1 :         const config = new dashql.buffers.formatting.FormattingConfigT(
      35            1 :             dialect,
      36            1 :             dashql.buffers.formatting.FormattingMode.PRETTY,
      37            1 :             80,
      38            1 :             4,
      39            1 :         );
      40            1 :         details.configuration = {
      41            1 :             dialect: dashql.buffers.formatting.FormattingDialect[dialect],
      42            1 :             mode: 'PRETTY',
      43            1 :             maxWidth: 80,
      44            1 :             indentationWidth: 4,
      45            1 :         };
      46            1 :         details.fullyFormattable = scriptData.scriptSession.isFullyFormattable(config, true);
      47            1 :     } catch (error) {
      48            1 :         details.inspectionError = error instanceof Error ? error.message : String(error);
      49            1 :     }
      50            1 :     return details;
      51            1 : }
      52              : 
      53            1 : export function collectScriptDiagnostics(scriptData: ScriptData, isFormattable: boolean,
      54            1 :     connectorType?: ConnectorType, formatting: Record<string, unknown> | null = null,
      55            1 :     references: ScriptReferenceReport | null = null): ScriptDiagnostic[] {
      56            1 :     const diagnostics: ScriptDiagnostic[] = (scriptData.editorUpdate?.diagnostics ?? []).map(diagnostic => {
      57            1 :         const severity = diagnostic.severity === dashql.buffers.editor.EditorDiagnosticSeverity.WARNING
      58            1 :             ? 'warning'
      59            1 :             : 'error';
      60            1 :         const source = dashql.buffers.editor.EditorDiagnosticSource[diagnostic.source].toLowerCase();
      61            1 :         const span = diagnostic.textSpan;
      62            1 :         const textSpan = span == null ? null : {
      63            1 :             offset: Number(span.offset),
      64            1 :             length: Number(span.length),
      65            1 :             end: Number(span.offset + span.length),
      66            1 :         };
      67            1 :         const message = typeof diagnostic.message === 'string' ? diagnostic.message : '';
      68            1 :         return {
      69            1 :             severity,
      70            1 :             message,
      71            1 :             details: { source, severity, message, scriptKey: scriptData.scriptKey, textSpan },
      72            1 :         };
      73            1 :     });
      74              : 
      75              :     // The errors of the script references, of the analyzed text
      76            1 :     if (references != null && references.textVersion === scriptData.editorUpdate?.textVersion) {
      77            1 :         for (const mark of references.marks) {
      78            1 :             for (const message of mark.errors) {
      79            1 :                 const textSpan = { offset: mark.from, length: mark.to - mark.from, end: mark.to };
      80            1 :                 diagnostics.push({
      81            1 :                     severity: 'error',
      82            1 :                     message,
      83            1 :                     details: { source: 'script references', severity: 'error', message, scriptKey: scriptData.scriptKey, textSpan },
      84            1 :                 });
      85            1 :             }
      86              :         }
      87              :     }
      88              : 
      89            1 :     if (!isFormattable) {
      90            1 :         diagnostics.push({
      91            1 :             severity: 'warning',
      92            1 :             message: SCRIPT_FORMATTING_WARNING,
      93            1 :             details: formatting ?? formattingDetails(scriptData, connectorType),
      94            1 :         });
      95              :     }
      96            1 :     return diagnostics;
      97            1 : }
      98              : 
      99            1 : export const ScriptDiagnosticsButton: React.FC<{
     100              :     scriptData: ScriptData;
     101              :     isFormattable: boolean;
     102              :     connectorType?: ConnectorType;
     103              :     /// The size of the icon of the trigger, 16 by default
     104              :     iconSize?: number;
     105              :     /// The checked script references of the script
     106              :     references?: ScriptReferenceReport | null;
     107            1 : }> = ({ scriptData, isFormattable, connectorType, iconSize = 16, references = null }) => {
     108            1 :     const [isOpen, setIsOpen] = React.useState(false);
     109            1 :     const [selectedDiagnostic, setSelectedDiagnostic] = React.useState<ScriptDiagnostic | null>(null);
     110            1 :     const triggerRef = React.useRef<HTMLButtonElement | null>(null);
     111              :     // Inspecting the formatter is a full pass over the script, only repeat it when the text changes
     112            1 :     const formatting = React.useMemo(
     113            1 :         () => isFormattable ? null : formattingDetails(scriptData, connectorType),
     114            1 :         [scriptData.scriptSession, scriptData.editorUpdate?.textVersion, isFormattable, connectorType],
     115            1 :     );
     116            1 :     const diagnostics = React.useMemo(
     117            1 :         () => collectScriptDiagnostics(scriptData, isFormattable, connectorType, formatting, references),
     118            1 :         [scriptData.editorUpdate, isFormattable, connectorType, formatting, references],
     119            1 :     );
     120            1 :     React.useEffect(() => {
     121            1 :         if (diagnostics.length === 0) {
     122            1 :             if (isOpen) setIsOpen(false);
     123            1 :             if (selectedDiagnostic != null) setSelectedDiagnostic(null);
     124            1 :         }
     125            1 :     }, [diagnostics.length, isOpen, selectedDiagnostic]);
     126            1 :     if (diagnostics.length === 0) return null;
     127              : 
     128            1 :     const hasErrors = diagnostics.some(diagnostic => diagnostic.severity === 'error');
     129            1 :     const errorCount = diagnostics.filter(diagnostic => diagnostic.severity === 'error').length;
     130            1 :     const warningCount = diagnostics.length - errorCount;
     131            1 :     const label = hasErrors ? 'Show script errors' : 'Show script warnings';
     132            1 :     const summary = [
     133            1 :         errorCount > 0 ? `${errorCount} ${errorCount === 1 ? 'error' : 'errors'}` : null,
     134            1 :         warningCount > 0 ? `${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}` : null,
     135            1 :     ].filter(Boolean).join(', ');
     136              : 
     137            1 :     return (
     138            1 :         <>
     139            1 :             <AnchoredOverlay
     140            1 :                 open={isOpen}
     141            1 :                 onOpen={() => setIsOpen(true)}
     142            1 :                 onClose={() => setIsOpen(false)}
     143            1 :                 side={AnchorSide.OutsideBottom}
     144            1 :                 align={AnchorAlignment.End}
     145            1 :                 anchorOffset={4}
     146            1 :                 width={OverlaySize.M}
     147            1 :                 anchorRef={triggerRef}
     148            1 :                 returnFocusRef={triggerRef}
     149            1 :                 focusZoneSettings={{ disabled: true }}
     150            1 :                 renderAnchor={(anchorProps) => (
     151            1 :                     <IconButton
     152            1 :                         {...anchorProps}
     153            1 :                         ref={triggerRef}
     154            1 :                         className={hasErrors ? styles.error_trigger : styles.warning_trigger}
     155            1 :                         variant={ButtonVariant.Invisible}
     156            1 :                         size={ButtonSize.Small}
     157            1 :                         aria-label={label}
     158              :                     >
     159            1 :                         {hasErrors
     160            1 :                             ? <XCircleFillIcon size={iconSize} aria-hidden="true" />
     161            1 :                             : <AlertIcon size={iconSize} aria-hidden="true" />}
     162            1 :                     </IconButton>
     163              :                 )}
     164              :             >
     165            1 :                 <section className={styles.overlay} role="dialog" aria-label="Script diagnostics">
     166            1 :                     <header className={styles.header}>
     167            1 :                         <h2 className={styles.title}>Script diagnostics</h2>
     168            1 :                         <span className={styles.summary}>{summary}</span>
     169            1 :                     </header>
     170            1 :                     <ul className={styles.list}>
     171            1 :                         {diagnostics.map((diagnostic, index) => (
     172            1 :                             <li key={`${diagnostic.severity}-${index}`}>
     173            1 :                                 <button
     174            1 :                                     type="button"
     175            1 :                                     className={styles.item}
     176            1 :                                     onClick={() => {
     177            1 :                                         setIsOpen(false);
     178            1 :                                         setSelectedDiagnostic(diagnostic);
     179            1 :                                     }}
     180            1 :                                     aria-label={`Show details: ${diagnostic.message}`}
     181              :                                 >
     182            1 :                                     <span
     183            1 :                                         className={diagnostic.severity === 'error' ? styles.error_icon : styles.warning_icon}
     184            1 :                                         aria-hidden="true"
     185              :                                     >
     186            1 :                                         {diagnostic.severity === 'error'
     187            1 :                                             ? <XCircleFillIcon size={16} />
     188            1 :                                             : <AlertIcon size={16} />}
     189            1 :                                     </span>
     190            1 :                                     <span className={styles.message}>{diagnostic.message}</span>
     191            1 :                                 </button>
     192            1 :                             </li>
     193            1 :                         ))}
     194            1 :                     </ul>
     195            1 :                 </section>
     196            1 :             </AnchoredOverlay>
     197            1 :             {selectedDiagnostic != null && (
     198            1 :                 <Overlay
     199            1 :                     centered
     200            1 :                     width={OverlaySize.L}
     201            1 :                     maxHeight={OverlaySize.XL}
     202            1 :                     returnFocusRef={triggerRef}
     203            1 :                     onEscape={() => setSelectedDiagnostic(null)}
     204            1 :                     onClickOutside={() => setSelectedDiagnostic(null)}
     205              :                 >
     206            1 :                     <section className={styles.detail_overlay} role="dialog" aria-label="Diagnostic details">
     207            1 :                         <header className={styles.detail_header}>
     208            1 :                             <h2 className={styles.title}>Diagnostic details</h2>
     209            1 :                             <IconButton
     210            1 :                                 variant={ButtonVariant.Invisible}
     211            1 :                                 size={ButtonSize.Small}
     212            1 :                                 aria-label="Close diagnostic details"
     213            1 :                                 onClick={() => setSelectedDiagnostic(null)}
     214              :                             >
     215            1 :                                 <XIcon size={16} />
     216            1 :                             </IconButton>
     217            1 :                         </header>
     218            1 :                         <div className={styles.json_details}>
     219            1 :                             <JsonView
     220            1 :                                 value={selectedDiagnostic.details}
     221            1 :                                 collapsed={2}
     222            1 :                                 shortenTextAfterLength={100}
     223            1 :                             />
     224            1 :                         </div>
     225            1 :                     </section>
     226            1 :                 </Overlay>
     227              :             )}
     228            1 :         </>
     229              :     );
     230            1 : };
        

Generated by: LCOV version 2.0-1