Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as themes from '../session/editor/themes/index.js';
4 :
5 : import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
6 : import { type Extension } from '@codemirror/state';
7 : import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view';
8 :
9 : import { createProfileLayoutConfig, QueryProfileView, type QueryProfileViewProps } from '../views/query_profile/query_profile_view.js';
10 : import { rotateProfileLayoutOrientation } from '../views/query_profile/profile_layout_config.js';
11 : import { ProfileExecutionState } from '../query/profile_execution_state.js';
12 : import { CodeMirror } from '../session/editor/codemirror.js';
13 : import type { PlatformEventListener } from '../platform/events/event_listener.js';
14 : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
15 : import { useFocusTrap } from '../ui/foundations/focus.js';
16 : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
17 : import { SegmentedControl, SegmentedControlSize } from '../ui/foundations/segmented_control.js';
18 : import { AlertIcon, ScreenFullIcon, ScreenNormalIcon, XIcon } from '../ui/foundations/symbol_icon.js';
19 : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
20 : import * as styles from './plan_dialog.module.css';
21 :
22 1 : const IGNORE_OUTSIDE_CLICK = () => {};
23 1 : const LOG_CTX = 'shell_plan';
24 : /// Edits are parsed once the user pauses typing, loading the plan per keystroke is too expensive
25 1 : const VALIDATION_DELAY_MS = 150;
26 :
27 1 : const enum PlanMode {
28 1 : Raw = 0,
29 1 : Viewer = 1,
30 1 : }
31 :
32 : interface PlanResources {
33 : core: dashql.DashQL;
34 : }
35 :
36 : interface PendingRequest {
37 : resolve: () => void;
38 : signal?: AbortSignal;
39 : onAbort?: () => void;
40 : }
41 :
42 : export interface PlanDialogController {
43 : request(core: dashql.DashQL, signal?: AbortSignal): Promise<void>;
44 : }
45 :
46 : export interface PlanDialogHookResult {
47 : controller: PlanDialogController;
48 : dialog: React.ReactElement | null;
49 : }
50 :
51 : type PlanDialogEvents = Pick<PlatformEventListener, 'subscribeFallbackPasteEvents' | 'unsubscribeFallbackPasteEvents'>;
52 :
53 : export interface PlanDialogDependencies {
54 : createProfileLayoutConfig: typeof createProfileLayoutConfig;
55 : QueryProfileView: React.ComponentType<QueryProfileViewProps>;
56 : }
57 :
58 1 : const DEFAULT_DEPENDENCIES: PlanDialogDependencies = {
59 1 : createProfileLayoutConfig,
60 1 : QueryProfileView,
61 1 : };
62 :
63 1 : export function usePlanDialog(
64 1 : appEvents: PlanDialogEvents,
65 1 : dependencies: PlanDialogDependencies = DEFAULT_DEPENDENCIES,
66 1 : ): PlanDialogHookResult {
67 1 : const [resources, setResources] = React.useState<PlanResources | null>(null);
68 1 : const pendingRequestRef = React.useRef<PendingRequest | null>(null);
69 :
70 1 : const dismiss = React.useCallback(() => {
71 1 : const pending = pendingRequestRef.current;
72 1 : pendingRequestRef.current = null;
73 1 : if (pending?.signal != null && pending.onAbort != null) {
74 1 : pending.signal.removeEventListener('abort', pending.onAbort);
75 : }
76 1 : setResources(null);
77 1 : pending?.resolve();
78 1 : }, []);
79 :
80 1 : const request = React.useCallback((core: dashql.DashQL, signal?: AbortSignal) => {
81 1 : if (pendingRequestRef.current != null) {
82 0 : return Promise.reject(new Error('A query plan request is already pending.'));
83 : }
84 1 : if (signal?.aborted) return Promise.resolve();
85 :
86 1 : setResources({ core });
87 1 : return new Promise<void>(resolve => {
88 1 : const pending: PendingRequest = { resolve, signal };
89 1 : pending.onAbort = dismiss;
90 1 : pendingRequestRef.current = pending;
91 1 : signal?.addEventListener('abort', pending.onAbort, { once: true });
92 1 : });
93 1 : }, [dismiss]);
94 :
95 1 : React.useEffect(() => () => {
96 1 : const pending = pendingRequestRef.current;
97 1 : pendingRequestRef.current = null;
98 1 : if (pending?.signal != null && pending.onAbort != null) {
99 0 : pending.signal.removeEventListener('abort', pending.onAbort);
100 : }
101 1 : pending?.resolve();
102 1 : }, []);
103 :
104 1 : const controller = React.useMemo<PlanDialogController>(() => ({ request }), [request]);
105 1 : return {
106 1 : controller,
107 1 : dialog: resources == null ? null : (
108 1 : <PlanDialog
109 1 : appEvents={appEvents}
110 1 : core={resources.core}
111 1 : dependencies={dependencies}
112 1 : onClose={dismiss}
113 1 : />
114 : ),
115 1 : };
116 1 : }
117 :
118 : interface PlanDialogProps extends PlanResources {
119 : appEvents: PlanDialogEvents;
120 : dependencies: PlanDialogDependencies;
121 : onClose: () => void;
122 : }
123 :
124 1 : function errorMessage(error: unknown): string {
125 1 : return error instanceof Error ? error.message : String(error);
126 1 : }
127 :
128 1 : function PlanDialog(props: PlanDialogProps) {
129 1 : const ProfileViewComponent = props.dependencies.QueryProfileView;
130 1 : const headingId = React.useId();
131 1 : const dialogRef = React.useRef<HTMLElement>(null);
132 1 : const editorFocusRef = React.useRef<HTMLElement>(null);
133 1 : const editorViewRef = React.useRef<EditorView | null>(null);
134 1 : const rawTextRef = React.useRef('');
135 1 : const viewModelRef = React.useRef<dashql.DashQLQueryProfile | null>(null);
136 1 : const [mode, setMode] = React.useState(PlanMode.Raw);
137 1 : const [plan, setPlan] = React.useState<dashql.FlatBufferPtr<dashql.buffers.profile.QueryProfile> | null>(null);
138 1 : const [execution, setExecution] = React.useState(ProfileExecutionState.EMPTY);
139 1 : const [diagnostic, setDiagnostic] = React.useState<string | null>(null);
140 1 : const [expanded, setExpanded] = React.useState(false);
141 1 : const onRawChangeRef = React.useRef<(text: string) => void>(() => {});
142 1 : const rawTextIsValidRef = React.useRef(false);
143 1 : const layoutConfig = React.useMemo(() => props.dependencies.createProfileLayoutConfig(), [props.dependencies]);
144 :
145 1 : const validate = React.useCallback((text: string): boolean => {
146 1 : setPlan(null);
147 1 : if (text.trim().length === 0) {
148 0 : setDiagnostic(null);
149 0 : return false;
150 0 : }
151 :
152 1 : viewModelRef.current ??= props.core.createQueryProfile(layoutConfig);
153 1 : try {
154 1 : const nextPlan = viewModelRef.current.loadHyperPlan(text);
155 1 : if (nextPlan.read().operatorsLength() === 0) {
156 1 : throw new Error('Plan contains no operators');
157 : }
158 1 : setPlan(nextPlan);
159 1 : setExecution(ProfileExecutionState.EMPTY.withEvents(viewModelRef.current.snapshotEvents().read()));
160 1 : setDiagnostic(null);
161 1 : return true;
162 1 : } catch (error) {
163 1 : setDiagnostic(errorMessage(error));
164 1 : return false;
165 1 : }
166 1 : }, [layoutConfig, props.core]);
167 :
168 : // The view model keeps the orientation, the next plans of the editor are laid out with it as well
169 1 : const rotate = React.useCallback(() => {
170 0 : const viewModel = viewModelRef.current;
171 0 : if (viewModel == null || plan == null) return;
172 0 : const orientation = rotateProfileLayoutOrientation(viewModel.layout.orientation);
173 0 : setPlan(viewModel.relayout(props.dependencies.createProfileLayoutConfig(orientation)));
174 1 : }, [plan, props.dependencies]);
175 :
176 1 : const validationTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
177 1 : const cancelValidation = React.useCallback(() => {
178 1 : if (validationTimerRef.current != null) {
179 1 : clearTimeout(validationTimerRef.current);
180 1 : validationTimerRef.current = null;
181 1 : }
182 1 : }, []);
183 1 : const runValidation = React.useCallback(() => {
184 1 : cancelValidation();
185 1 : rawTextIsValidRef.current = validate(rawTextRef.current);
186 1 : }, [cancelValidation, validate]);
187 : // Validate pending edits right away, e.g. before showing the viewer
188 1 : const flushValidation = React.useCallback(() => {
189 1 : if (validationTimerRef.current != null) runValidation();
190 1 : }, [runValidation]);
191 :
192 1 : onRawChangeRef.current = text => {
193 1 : rawTextRef.current = text;
194 1 : cancelValidation();
195 1 : validationTimerRef.current = setTimeout(runValidation, VALIDATION_DELAY_MS);
196 1 : };
197 :
198 1 : const extensions = React.useMemo<Extension[]>(() => [
199 1 : themes.xcode,
200 1 : lineNumbers(),
201 1 : drawSelection(),
202 1 : history(),
203 1 : keymap.of([...defaultKeymap, ...historyKeymap]),
204 1 : EditorView.contentAttributes.of({ 'aria-label': 'Query plan JSON' }),
205 1 : EditorView.updateListener.of(update => {
206 1 : if (update.docChanged) onRawChangeRef.current(update.state.doc.toString());
207 1 : }),
208 1 : ], []);
209 :
210 1 : const setView = React.useCallback((view: EditorView | null) => {
211 1 : editorViewRef.current = view;
212 1 : editorFocusRef.current = view?.contentDOM ?? null;
213 1 : view?.focus();
214 1 : }, []);
215 :
216 1 : React.useEffect(() => () => {
217 : // A pending validation must not load a plan into the destroyed query profile
218 1 : cancelValidation();
219 1 : viewModelRef.current?.destroy();
220 1 : viewModelRef.current = null;
221 1 : }, [cancelValidation]);
222 :
223 1 : React.useEffect(() => {
224 1 : const handleFallbackPaste = (text: string) => {
225 1 : const view = editorViewRef.current;
226 1 : if (view?.contentDOM.isConnected) {
227 1 : view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
228 : } else {
229 0 : onRawChangeRef.current(text);
230 : }
231 1 : flushValidation();
232 1 : if (rawTextIsValidRef.current) setMode(PlanMode.Viewer);
233 1 : };
234 1 : props.appEvents.subscribeFallbackPasteEvents(handleFallbackPaste);
235 1 : return () => props.appEvents.unsubscribeFallbackPasteEvents(handleFallbackPaste);
236 1 : }, [props.appEvents, flushValidation]);
237 :
238 1 : const selectMode = React.useCallback((nextMode: PlanMode) => {
239 1 : flushValidation();
240 : // The viewer button was enabled for the text before the pending edits
241 1 : if (nextMode === PlanMode.Viewer && !rawTextIsValidRef.current) return;
242 1 : setMode(nextMode);
243 1 : }, [flushValidation]);
244 :
245 : /// Copy the raw plan, also from the viewer
246 1 : const getCopyText = React.useCallback(() => rawTextRef.current, []);
247 :
248 1 : useFocusTrap({
249 1 : containerRef: dialogRef as React.RefObject<HTMLElement>,
250 1 : initialFocusRef: editorFocusRef as React.RefObject<HTMLElement>,
251 1 : restoreFocusOnCleanUp: true,
252 1 : });
253 :
254 1 : return (
255 1 : <Overlay
256 1 : centered
257 1 : width={OverlaySize.XXL}
258 1 : height={OverlaySize.XL}
259 1 : maxHeight={OverlaySize.XL}
260 1 : preventFocusOnOpen
261 1 : onEscape={props.onClose}
262 1 : onClickOutside={IGNORE_OUTSIDE_CLICK}
263 1 : expanded={expanded}
264 : >
265 1 : <section
266 1 : ref={dialogRef}
267 1 : className={styles.dialog}
268 1 : role="dialog"
269 1 : aria-modal="true"
270 1 : aria-labelledby={headingId}
271 1 : data-expanded={expanded || undefined}
272 : >
273 1 : <header className={styles.header}>
274 1 : <h2 id={headingId} className={styles.title}>Query Plan</h2>
275 1 : <div className={styles.spacer} />
276 1 : {diagnostic != null && (
277 1 : <div className={styles.diagnostic} role="alert" title={diagnostic}>
278 1 : <AlertIcon size={16} aria-hidden="true" />
279 1 : <span className={styles.diagnostic_text}>{diagnostic}</span>
280 1 : </div>
281 : )}
282 1 : <SegmentedControl
283 1 : aria-label="Plan view"
284 1 : size={SegmentedControlSize.Small}
285 1 : onChange={index => selectMode(index as PlanMode)}
286 : >
287 1 : <SegmentedControl.Button selected={mode === PlanMode.Raw} data-testid="plan-mode-raw">
288 : Raw
289 1 : </SegmentedControl.Button>
290 1 : <SegmentedControl.Button
291 1 : selected={mode === PlanMode.Viewer}
292 1 : disabled={plan == null}
293 1 : data-testid="plan-mode-viewer"
294 1 : >
295 : Viewer
296 1 : </SegmentedControl.Button>
297 1 : </SegmentedControl>
298 1 : <CopyToClipboardButton
299 1 : variant={ButtonVariant.Invisible}
300 1 : size={ButtonSize.Small}
301 1 : getValue={getCopyText}
302 1 : logContext={LOG_CTX}
303 1 : aria-label="Copy query plan JSON"
304 1 : aria-labelledby=""
305 1 : data-testid="dialog-copy"
306 1 : />
307 1 : <IconButton
308 1 : variant={ButtonVariant.Invisible}
309 1 : size={ButtonSize.Small}
310 1 : aria-label={expanded ? 'Restore size' : 'Expand'}
311 1 : aria-pressed={expanded}
312 1 : data-testid="dialog-expand"
313 1 : onClick={() => setExpanded(e => !e)}
314 : >
315 1 : {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
316 1 : </IconButton>
317 1 : <IconButton
318 1 : variant={ButtonVariant.Invisible}
319 1 : size={ButtonSize.Small}
320 1 : aria-label="Close query plan"
321 1 : data-testid="dialog-close"
322 1 : onClick={props.onClose}
323 : >
324 1 : <XIcon size={16} />
325 1 : </IconButton>
326 1 : </header>
327 1 : <div className={styles.body}>
328 1 : {mode === PlanMode.Raw && (
329 1 : <div className={styles.editor}>
330 1 : <CodeMirror ref={setView} extensions={extensions} initialDoc={rawTextRef.current} />
331 1 : </div>
332 : )}
333 1 : {mode === PlanMode.Viewer && plan != null && (
334 1 : <div className={styles.viewer}>
335 1 : <ProfileViewComponent plan={plan} execution={execution} onRotate={rotate} autoFocus />
336 1 : </div>
337 : )}
338 1 : </div>
339 1 : </section>
340 1 : </Overlay>
341 : );
342 1 : }
|