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 : }
|