LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - cell_detail_overlay.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.9 % 280 274
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './cell_detail_overlay.module.css';
       3              : import * as dashql from '@ankoh/dashql-core';
       4              : 
       5              : import { XIcon, ChevronUpIcon, ChevronDownIcon, ScreenFullIcon, ScreenNormalIcon } from '../../ui/foundations/symbol_icon.js';
       6              : 
       7              : import { ButtonSize, IconButton, ButtonVariant } from '../../ui/foundations/button.js';
       8              : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
       9              : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      10              : import { JsonView } from '../../ui/json/json_view.js';
      11              : import { projectSqlText, ReadonlySqlView } from '../../session/editor/readonly_sql_view.js';
      12              : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
      13              : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
      14              : import { CopyToClipboardButton } from '../../ui/foundations/clipboard.js';
      15              : import { useKeyEvents } from '../../utils/key_events.js';
      16              : import { peekFormat } from './format_peek.js';
      17              : import { useAppConfig } from '../../config/app_config.js';
      18              : import { getKeywordCase } from '../../session/scripts/script_format.js';
      19              : import { EngineProfileView } from '../query_profile/engine_profile_view.js';
      20              : import { detectPlanEngine } from '../query_profile/plan_engine.js';
      21              : 
      22            1 : const LOG_CTX = 'cell_detail_overlay';
      23              : 
      24            1 : enum FormatMode {
      25            1 :     Raw = 0,
      26            1 :     Structured = 1,
      27            1 :     SQL = 2,
      28            1 :     Plan = 3,
      29            1 : }
      30              : 
      31            1 : const FORMAT_LABELS: Record<FormatMode, string> = {
      32            1 :     [FormatMode.Raw]: 'Raw',
      33            1 :     [FormatMode.Structured]: 'Structured',
      34            1 :     [FormatMode.SQL]: 'SQL',
      35            1 :     [FormatMode.Plan]: 'Plan',
      36            1 : };
      37              : 
      38              : interface DetectedFormats {
      39              :     json: object | null;
      40              :     sql: {
      41              :         originalText: string;
      42              :         originalUpdate: dashql.buffers.editor.EditorUpdateT;
      43              :         formattedText: string | null;
      44              :         formattedUpdate: dashql.buffers.editor.EditorUpdateT | null;
      45              :         hasErrors: boolean;
      46              :     } | null;
      47              :     plan: string | null;
      48              : }
      49              : 
      50            1 : export function detectFormats(core: dashql.DashQL | null, value: string | null, keywordCase = dashql.buffers.formatting.KeywordCase.LOWER): DetectedFormats {
      51            1 :     const result: DetectedFormats = { json: null, sql: null, plan: null };
      52            1 :     if (value == null) return result;
      53              : 
      54            1 :     const peeked = peekFormat(value);
      55            1 :     if (peeked === 'plan') {
      56            1 :         result.plan = value;
      57              :     }
      58              : 
      59            1 :     if (peeked === 'plan' || peeked === 'json') {
      60            1 :         try {
      61            1 :             const parsed = JSON.parse(value);
      62            1 :             if (typeof parsed === 'object' && parsed !== null) {
      63            1 :                 result.json = parsed;
      64              :             }
      65            1 :         } catch {
      66              :             // Not JSON
      67            0 :         }
      68              :     }
      69              : 
      70              :     // JSON is never meaningful SQL, skip scanning, parsing, analyzing and formatting large documents
      71            1 :     if (core != null && result.json == null) {
      72            1 :         let catalog: dashql.DashQLCatalog | null = null;
      73            1 :         let script: dashql.DashQLScript | null = null;
      74            1 :         let formattedScript: dashql.DashQLScript | null = null;
      75            1 :         try {
      76            1 :             catalog = core.createCatalog();
      77            1 :             script = core.createScript(catalog);
      78            1 :             script.insertTextAt(0, value);
      79            1 :             script.parse();
      80            1 :             const parsed = script.getParsed();
      81            1 :             const hasErrors = parsed != null && (parsed.read().scannerErrorsLength() > 0 || parsed.read().parserErrorsLength() > 0);
      82            1 :             const originalUpdate = projectSqlText(core, catalog, value);
      83            1 :             let formattedText: string | null = null;
      84            1 :             let formattedUpdate: dashql.buffers.editor.EditorUpdateT | null = null;
      85            1 :             try {
      86            1 :                 const config = new dashql.buffers.formatting.FormattingConfigT(
      87            1 :                     dashql.buffers.formatting.FormattingDialect.HYPER,
      88            1 :                     dashql.buffers.formatting.FormattingMode.PRETTY,
      89            1 :                     80,
      90            1 :                     4,
      91            1 :                     false,
      92            1 :                     keywordCase,
      93            1 :                 );
      94            1 :                 formattedScript = script.format(config, null);
      95            1 :                 const text = formattedScript.toString();
      96            1 :                 formattedUpdate = projectSqlText(core, catalog, text);
      97            1 :                 formattedText = text;
      98            1 :             } catch {
      99              :                 // Format failed, but parse succeeded
     100            0 :             }
     101            1 :             result.sql = { originalText: value, originalUpdate, formattedText, formattedUpdate, hasErrors };
     102            1 :             formattedScript?.ptr.destroy();
     103            1 :             script.ptr.destroy();
     104            1 :             catalog.ptr.destroy();
     105            1 :             return result;
     106            1 :         } catch {
     107              :             // Not valid SQL — destroy everything
     108            0 :             formattedScript?.ptr.destroy();
     109            0 :             script?.ptr.destroy();
     110            0 :             catalog?.ptr.destroy();
     111            0 :         }
     112            1 :     }
     113              : 
     114            1 :     return result;
     115            1 : }
     116              : 
     117            1 : export function detectStructuredFormats(value: object): DetectedFormats {
     118            1 :     return { json: value, sql: null, plan: null };
     119            1 : }
     120              : 
     121              : export const STRUCTURED_ARRAY_PAGE_SIZE = 100;
     122              : 
     123            1 : export function structuredValueToText(value: object): string {
     124            1 :     return JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? `${item}n` : item, 2);
     125            1 : }
     126              : 
     127            1 : function pickDefaultMode(formats: DetectedFormats): FormatMode {
     128            1 :     if (formats.plan != null) return FormatMode.Plan;
     129            1 :     if (formats.sql != null && !formats.sql.hasErrors) return FormatMode.SQL;
     130            1 :     if (formats.json != null) return FormatMode.Structured;
     131            1 :     return FormatMode.Raw;
     132            1 : }
     133              : 
     134            1 : function getAvailableModes(formats: DetectedFormats): FormatMode[] {
     135            1 :     const modes: FormatMode[] = [FormatMode.Raw];
     136            1 :     if (formats.json != null) modes.push(FormatMode.Structured);
     137            1 :     if (formats.sql != null) modes.push(FormatMode.SQL);
     138            1 :     if (formats.plan != null) modes.push(FormatMode.Plan);
     139            1 :     return modes;
     140            1 : }
     141              : 
     142              : /// Read-only CodeMirror sub-view (no syntax highlighting)
     143            1 : function ReadonlyTextView(props: { text: string }) {
     144            1 :     return (
     145            1 :         <div className={styles.codemirror_container}>
     146            1 :             <ReadonlySqlView text={props.text} editorUpdate={null} lineWrapping autoHeight />
     147            1 :         </div>
     148              :     );
     149            1 : }
     150              : 
     151            1 : const PencilAIIcon = SymbolIcon('pencil_ai_16');
     152              : 
     153              : interface SqlTextViewProps {
     154              :     originalText: string;
     155              :     originalUpdate: dashql.buffers.editor.EditorUpdateT;
     156              :     formattedText: string | null;
     157              :     formattedUpdate: dashql.buffers.editor.EditorUpdateT | null;
     158              : }
     159              : 
     160              : // SQL CodeMirror sub-view (with DashQL syntax highlighting)
     161            1 : function SqlTextView(props: SqlTextViewProps) {
     162            1 :     const [pretty, setPretty] = React.useState(false);
     163              : 
     164            1 :     const activeText = pretty && props.formattedText != null ? props.formattedText : props.originalText;
     165            1 :     const activeUpdate = pretty && props.formattedUpdate != null ? props.formattedUpdate : props.originalUpdate;
     166              : 
     167            1 :     return (
     168            1 :         <div className={styles.codemirror_container}>
     169            1 :             <ReadonlySqlView text={activeText} editorUpdate={activeUpdate} />
     170            1 :             {props.formattedText != null && (
     171            1 :                 <div className={styles.pretty_toggle}>
     172            1 :                     <IconButton
     173            1 :                         className={styles.pretty_toggle_button}
     174            1 :                         variant={ButtonVariant.Default}
     175            1 :                         aria-label="Pretty format"
     176            1 :                         aria-pressed={pretty}
     177            1 :                         onClick={() => setPretty(p => !p)}
     178              :                     >
     179            1 :                         <PencilAIIcon />
     180            1 :                     </IconButton>
     181            1 :                 </div>
     182              :             )}
     183            1 :         </div>
     184              :     );
     185            1 : }
     186              : 
     187            1 : function StructuredValueView(props: { value: object }) {
     188            1 :     return (
     189            1 :         <JsonView
     190            1 :             className={styles.structured_view}
     191            1 :             value={props.value}
     192            1 :             collapsed={2}
     193            1 :             shortenTextAfterLength={100}
     194            1 :             arrayPageSize={STRUCTURED_ARRAY_PAGE_SIZE}
     195            1 :         />
     196              :     );
     197            1 : }
     198              : 
     199              : 
     200              : export interface CellDetailOverlayProps {
     201              :     isOpen: boolean;
     202              :     onClose: () => void;
     203              :     formattedValue: string | null;
     204              :     structuredValue: object | null;
     205              :     columnName: string | null;
     206              :     dataRow: number;
     207              :     maxRow: number;
     208              :     onNavigate: (delta: number) => void;
     209              : }
     210              : 
     211              : /// Public interface
     212            1 : export function CellDetailOverlay(props: CellDetailOverlayProps) {
     213            1 :     if (!props.isOpen) return null;
     214            1 :     return <CellDetailOverlayInner {...props} />;
     215            1 : }
     216              : 
     217              : 
     218            1 : const EMPTY_FORMATS: DetectedFormats = { json: null, sql: null, plan: null };
     219              : 
     220              : /// Inner component — only mounted when overlay is open
     221            1 : function CellDetailOverlayInner(props: CellDetailOverlayProps) {
     222            1 :     const keywordCase = getKeywordCase(useAppConfig()?.settings);
     223            1 :     const coreSetup = useDashQLCoreSetup();
     224            1 :     const [core, setCore] = React.useState<dashql.DashQL | null>(null);
     225            1 :     const [formats, setFormats] = React.useState<DetectedFormats>(EMPTY_FORMATS);
     226            1 :     const [selectedFormat, setSelectedFormat] = React.useState<FormatMode>(FormatMode.Raw);
     227            1 :     const [expanded, setExpanded] = React.useState(false);
     228            1 :     const value = props.structuredValue ?? props.formattedValue;
     229              :     // The value that the user picked a format for. A value starts with its default format, but the formats are detected
     230              :     // again when the core arrives, which must not undo a pick.
     231            1 :     const pickedValue = React.useRef<typeof value | undefined>(undefined);
     232              : 
     233            1 :     React.useEffect(() => {
     234            1 :         let cancelled = false;
     235            1 :         coreSetup('cell_detail').then(c => {
     236            1 :             if (!cancelled) setCore(c);
     237            1 :         });
     238            1 :         return () => { cancelled = true; };
     239            1 :     }, [coreSetup]);
     240              : 
     241            1 :     React.useEffect(() => {
     242            1 :         const f = props.structuredValue == null
     243            1 :             ? detectFormats(core, props.formattedValue, keywordCase)
     244            1 :             : detectStructuredFormats(props.structuredValue);
     245            1 :         setFormats(f);
     246            1 :         setSelectedFormat(selected => pickedValue.current === value && getAvailableModes(f).includes(selected)
     247            1 :             ? selected
     248            1 :             : pickDefaultMode(f));
     249            1 :     }, [props.formattedValue, props.structuredValue, core, keywordCase]);
     250              : 
     251            1 :     const availableModes = React.useMemo(() => getAvailableModes(formats), [formats]);
     252              : 
     253            1 :     const onSegmentChange = React.useCallback((index: number) => {
     254            1 :         pickedValue.current = value;
     255            1 :         setSelectedFormat(availableModes[index]);
     256            1 :     }, [availableModes, value]);
     257              : 
     258            1 :     useKeyEvents(React.useMemo(() => [
     259            1 :         {
     260            1 :             key: 'ArrowUp',
     261            1 :             callback: (e: KeyboardEvent) => {
     262            1 :                 e.preventDefault();
     263            1 :                 if (props.dataRow > 0) {
     264            1 :                     props.onNavigate(-1);
     265              :                 }
     266            1 :             },
     267            1 :         },
     268            1 :         {
     269            1 :             key: 'ArrowDown',
     270            1 :             callback: (e: KeyboardEvent) => {
     271            1 :                 e.preventDefault();
     272            1 :                 if (props.dataRow < props.maxRow) {
     273            1 :                     props.onNavigate(1);
     274              :                 }
     275            1 :             },
     276            1 :         },
     277            1 :     ], [props.dataRow, props.maxRow, props.onNavigate]));
     278              : 
     279            1 :     const rawText = React.useMemo(() => props.structuredValue == null
     280            1 :         ? (props.formattedValue ?? 'NULL')
     281            1 :         : structuredValueToText(props.structuredValue), [props.structuredValue, props.formattedValue]);
     282              : 
     283            1 :     return (
     284            1 :         <Overlay
     285            1 :             centered
     286            1 :             onEscape={props.onClose}
     287            1 :             onClickOutside={props.onClose}
     288            1 :             preventFocusOnOpen
     289            1 :             width={OverlaySize.XL}
     290            1 :             maxHeight={OverlaySize.XL}
     291            1 :             expanded={expanded}
     292              :         >
     293            1 :             <div className={styles.modal} data-expanded={expanded || undefined}>
     294            1 :                 <div className={styles.main}>
     295            1 :                     <div className={styles.header}>
     296            1 :                         <span className={styles.header_title}>
     297            1 :                             <span className={styles.header_title_label}>row</span>
     298            1 :                             <span className={styles.header_title_equal}>=</span>
     299            1 :                             <span className={styles.header_title_index}>{props.dataRow}</span>
     300            1 :                             <span className={styles.header_title_label}>field</span>
     301            1 :                             <span className={styles.header_title_equal}>=</span>
     302            1 :                             <span className={styles.header_title_field}>{props.columnName ?? 'value'}</span>
     303            1 :                         </span>
     304            1 :                         <div className={styles.header_spacer} />
     305            1 :                         <CopyToClipboardButton
     306            1 :                             variant={ButtonVariant.Invisible}
     307            1 :                             size={ButtonSize.Small}
     308            1 :                             value={rawText}
     309            1 :                             logContext={LOG_CTX}
     310            1 :                             aria-label="Copy raw output"
     311            1 :                             aria-labelledby=""
     312            1 :                         />
     313            1 :                         {availableModes.length > 1 && (
     314            1 :                             <SegmentedControl
     315            1 :                                 aria-label="Format mode"
     316            1 :                                 size={SegmentedControlSize.Small}
     317            1 :                                 onChange={onSegmentChange}
     318              :                             >
     319            1 :                                 {availableModes.map(mode => (
     320            1 :                                     <SegmentedControl.Button
     321            1 :                                         key={mode}
     322            1 :                                         selected={mode === selectedFormat}
     323              :                                     >
     324            1 :                                         {FORMAT_LABELS[mode]}
     325            1 :                                     </SegmentedControl.Button>
     326            1 :                                 ))}
     327            1 :                             </SegmentedControl>
     328              :                         )}
     329            1 :                     </div>
     330            1 :                     <div className={`${styles.body} ${selectedFormat === FormatMode.Structured ? styles.body_padded : ''}`}>
     331            1 :                         {selectedFormat === FormatMode.Raw && (
     332            1 :                             <ReadonlyTextView text={rawText} />
     333              :                         )}
     334            1 :                         {selectedFormat === FormatMode.Structured && formats.json != null && (
     335            1 :                             <StructuredValueView value={formats.json} />
     336              :                         )}
     337            1 :                         {selectedFormat === FormatMode.SQL && formats.sql != null && (
     338            1 :                             <SqlTextView
     339            1 :                                 originalText={formats.sql.originalText}
     340            1 :                                 originalUpdate={formats.sql.originalUpdate}
     341            1 :                                 formattedText={formats.sql.formattedText}
     342            1 :                                 formattedUpdate={formats.sql.formattedUpdate}
     343            1 :                             />
     344              :                         )}
     345            1 :                         {selectedFormat === FormatMode.Plan && formats.plan != null && (
     346            1 :                             <EngineProfileView
     347            1 :                                 engine={detectPlanEngine(formats.plan) ?? dashql.buffers.profile.ProfileEngine.HYPER}
     348            1 :                                 planText={formats.plan}
     349            1 :                                 className={styles.plan_container}
     350            1 :                                 fallback={<div className={styles.plan_error}>Could not render plan</div>}
     351            1 :                             />
     352              :                         )}
     353            1 :                     </div>
     354            1 :                 </div>
     355            1 :                 <div className={styles.sidebar}>
     356            1 :                     <div className={styles.sidebar_top}>
     357            1 :                         <IconButton
     358            1 :                             variant={ButtonVariant.Invisible}
     359            1 :                             aria-label="Close"
     360            1 :                             onClick={props.onClose}
     361              :                         >
     362            1 :                             <XIcon />
     363            1 :                         </IconButton>
     364            1 :                         <IconButton
     365            1 :                             variant={ButtonVariant.Invisible}
     366            1 :                             aria-label={expanded ? 'Restore size' : 'Expand'}
     367            1 :                             aria-pressed={expanded}
     368            1 :                             onClick={() => setExpanded(e => !e)}
     369              :                         >
     370            1 :                             {expanded ? <ScreenNormalIcon /> : <ScreenFullIcon />}
     371            1 :                         </IconButton>
     372            1 :                     </div>
     373            1 :                     <div className={styles.sidebar_bottom}>
     374            1 :                         <IconButton
     375            1 :                             variant={ButtonVariant.Invisible}
     376            1 :                             aria-label="Previous row"
     377            1 :                             onClick={() => props.onNavigate(-1)}
     378            1 :                             disabled={props.dataRow <= 0}
     379              :                         >
     380            1 :                             <ChevronUpIcon />
     381            1 :                         </IconButton>
     382            1 :                         <IconButton
     383            1 :                             variant={ButtonVariant.Invisible}
     384            1 :                             aria-label="Next row"
     385            1 :                             onClick={() => props.onNavigate(1)}
     386            1 :                             disabled={props.dataRow >= props.maxRow}
     387              :                         >
     388            1 :                             <ChevronDownIcon />
     389            1 :                         </IconButton>
     390            1 :                     </div>
     391            1 :                 </div>
     392            1 :             </div>
     393            1 :         </Overlay>
     394              :     );
     395            1 : }
        

Generated by: LCOV version 2.0-1