LCOV - code coverage report
Current view: top level - dashql-app/src/session/snippets - snippets_overlay.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 97.7 % 306 299
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : 
       4              : import type { EditorView } from '@codemirror/view';
       5              : 
       6              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
       7              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
       8              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       9              : import { OverlaySize } from '../../ui/foundations/overlay.js';
      10              : import { DatabaseIcon, FileDirectoryIcon, FileIcon, LibraryIcon, SearchIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
      11              : import { TextInput } from '../../ui/foundations/text_input.js';
      12              : import { TreeEntry, TreeEntryName } from '../../ui/foundations/tree_entry.js';
      13              : import { useEmbeddedDatabaseSetup } from '../../platform/database/embedded_database_provider.js';
      14              : import { ConnectorType } from '../../connections/connector_info.js';
      15              : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
      16              : import { ReadonlySqlView, useProjectedSqlText } from '../editor/readonly_sql_view.js';
      17              : import { createScriptFormatConfig, formattingDialectForConnector, getKeywordCase, measureScriptFormatWidth } from '../scripts/script_format.js';
      18              : import { useAppConfig } from '../../config/app_config.js';
      19              : import { SNIPPET_DATASETS_FOLDER, type Snippet } from './snippet.js';
      20              : import { getBundledSnippets, snippetText, snippetsForConnector, type SnippetRegistry } from './snippet_registry.js';
      21              : import { getSnippetSearch } from './snippet_search.js';
      22              : import * as styles from './snippets_overlay.module.css';
      23              : 
      24              : export interface SnippetsOverlayProps {
      25              :     /// The name of the script, for the labels of the trigger
      26              :     scriptName: string;
      27              :     /// The connector of the script, the browser shows the snippets that run on it
      28              :     connectorType: ConnectorType;
      29              :     /// Disable the trigger, e.g. while a rewrite of the script is pending
      30              :     disabled?: boolean;
      31              :     /// Replace the text of the script with the text of a snippet
      32              :     onApply: (text: string) => void;
      33              :     /// The snippets to browse, the bundled ones by default
      34              :     registry?: SnippetRegistry;
      35              :     /// The test id of the trigger
      36              :     testId?: string;
      37              :     /// The size of the library icon of the trigger, 16 by default
      38              :     iconSize?: number;
      39              : }
      40              : 
      41              : /// The registry while the bundled snippets are parsed
      42            1 : const EMPTY_REGISTRY: SnippetRegistry = { snippets: [], byId: new Map(), errors: [] };
      43              : 
      44              : /// The bounds of the width of the snippet list, the preview keeps the rest
      45            1 : const LIST_WIDTH_DEFAULT = 300;
      46            1 : const LIST_WIDTH_MIN = 180;
      47            1 : const PREVIEW_WIDTH_MIN = 320;
      48            1 : const LIST_WIDTH_KEY_STEP = 16;
      49              : 
      50              : /// The width of the snippet list and the handlers of the handle on its border that resizes it
      51            1 : function useListWidth(bodyRef: React.RefObject<HTMLDivElement | null>) {
      52            1 :     const [width, setWidthState] = React.useState(LIST_WIDTH_DEFAULT);
      53            1 :     const maxWidth = React.useCallback(() =>
      54            1 :         Math.max(LIST_WIDTH_MIN, (bodyRef.current?.getBoundingClientRect().width ?? 0) - PREVIEW_WIDTH_MIN), [bodyRef]);
      55            1 :     const setWidth = React.useCallback((next: number) =>
      56            1 :         setWidthState(Math.round(Math.min(Math.max(next, LIST_WIDTH_MIN), maxWidth()))), [maxWidth]);
      57            1 :     const stopDraggingRef = React.useRef<(() => void) | null>(null);
      58            1 :     React.useEffect(() => () => stopDraggingRef.current?.(), []);
      59              : 
      60            1 :     const onPointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
      61            1 :         if (event.button !== 0) return;
      62            1 :         event.preventDefault();
      63            1 :         stopDraggingRef.current?.();
      64            1 :         const pointerId = event.pointerId;
      65            1 :         const left = bodyRef.current?.getBoundingClientRect().left ?? 0;
      66            1 :         const onMove = (move: PointerEvent) => {
      67            1 :             if (move.pointerId === pointerId) setWidth(move.clientX - left);
      68            1 :         };
      69            1 :         const onEnd = (end: PointerEvent) => {
      70            1 :             if (end.pointerId === pointerId) stop();
      71            1 :         };
      72            1 :         const stop = () => {
      73            1 :             window.removeEventListener('pointermove', onMove);
      74            1 :             window.removeEventListener('pointerup', onEnd);
      75            1 :             window.removeEventListener('pointercancel', onEnd);
      76            1 :             stopDraggingRef.current = null;
      77            1 :         };
      78            1 :         window.addEventListener('pointermove', onMove);
      79            1 :         window.addEventListener('pointerup', onEnd);
      80            1 :         window.addEventListener('pointercancel', onEnd);
      81            1 :         stopDraggingRef.current = stop;
      82            1 :     }, [bodyRef, setWidth]);
      83              : 
      84            1 :     const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
      85            1 :         switch (event.key) {
      86            0 :             case 'ArrowLeft': setWidth(width - LIST_WIDTH_KEY_STEP); break;
      87            0 :             case 'ArrowRight': setWidth(width + LIST_WIDTH_KEY_STEP); break;
      88            0 :             case 'Home': setWidth(LIST_WIDTH_MIN); break;
      89            1 :             case 'End': setWidth(maxWidth()); break;
      90            0 :             default: return;
      91            1 :         }
      92            1 :         event.preventDefault();
      93            1 :     }, [setWidth, maxWidth, width]);
      94            1 :     return { width, onPointerDown, onKeyDown };
      95            1 : }
      96              : 
      97              : /// A folder of the snippet tree
      98              : interface SnippetFolder {
      99              :     /// The path of the folder, it identifies the folder. Empty for the root of the tree.
     100              :     path: string;
     101              :     /// The name of the folder
     102              :     name: string;
     103              :     /// The folders in the folder, in the order of their first snippets
     104              :     folders: SnippetFolder[];
     105              :     /// The snippets in the folder
     106              :     snippets: Snippet[];
     107              : }
     108              : 
     109              : /// Build the tree of the folders of snippets, it keeps the order of the snippets
     110            1 : function buildSnippetTree(snippets: readonly Snippet[]): SnippetFolder {
     111            1 :     const root: SnippetFolder = { path: '', name: '', folders: [], snippets: [] };
     112            1 :     const byPath = new Map<string, SnippetFolder>([['', root]]);
     113            1 :     const folderOf = (path: string): SnippetFolder => {
     114            1 :         let folder = byPath.get(path);
     115            1 :         if (folder == null) {
     116            1 :             const slash = path.lastIndexOf('/');
     117            1 :             folder = { path, name: path.slice(slash + 1), folders: [], snippets: [] };
     118            1 :             byPath.set(path, folder);
     119            1 :             folderOf(slash < 0 ? '' : path.slice(0, slash)).folders.push(folder);
     120            1 :         }
     121            1 :         return folder;
     122            1 :     };
     123            1 :     for (const snippet of snippets) {
     124            1 :         folderOf(snippet.category).snippets.push(snippet);
     125              :     }
     126            1 :     return root;
     127            1 : }
     128              : 
     129              : /// The snippets of the expanded folders below a folder as the tree lists them, the folders before the files
     130            1 : function expandedSnippets(folder: SnippetFolder, isExpanded: (path: string) => boolean): Snippet[] {
     131            1 :     return [
     132            1 :         ...folder.folders.flatMap(child => isExpanded(child.path) ? expandedSnippets(child, isExpanded) : []),
     133            1 :         ...folder.snippets,
     134            1 :     ];
     135            1 : }
     136              : 
     137              : /// The bundled snippets once the core is set up, null until then
     138            1 : function useBundledSnippets(): SnippetRegistry | null {
     139            1 :     const coreSetup = useDashQLCoreSetup();
     140            1 :     const [registry, setRegistry] = React.useState<SnippetRegistry | null>(null);
     141            1 :     React.useEffect(() => {
     142            1 :         if (coreSetup == null) return;
     143            1 :         let cancelled = false;
     144            1 :         coreSetup('snippets').then(core => {
     145            1 :             if (!cancelled) setRegistry(getBundledSnippets(core));
     146            1 :         });
     147            1 :         return () => { cancelled = true; };
     148            1 :     }, [coreSetup]);
     149            1 :     return registry;
     150            1 : }
     151              : 
     152              : /// The snippets that contain a search text
     153              : interface SnippetMatches {
     154              :     /// The ids of the matching snippets, null without a text or before the first search finished
     155              :     ids: ReadonlySet<string> | null;
     156              :     /// The error of the search, null if it succeeded
     157              :     error: string | null;
     158              : }
     159              : 
     160              : /// Search the snippets of a connector in the snippet database of the embedded Hyper.
     161              : /// The matches of a text stay until the search of the next text finished.
     162            1 : function useSnippetMatches(registry: SnippetRegistry, connector: ConnectorType, text: string): SnippetMatches {
     163            1 :     const setupDatabase = useEmbeddedDatabaseSetup();
     164            1 :     const [matches, setMatches] = React.useState<SnippetMatches>({ ids: null, error: null });
     165            1 :     React.useEffect(() => {
     166            1 :         if (text.length === 0) {
     167            1 :             setMatches({ ids: null, error: null });
     168            1 :             return;
     169            1 :         }
     170              :         // Without a provider of the embedded database, e.g. in tests of the browser alone
     171            1 :         if (setupDatabase == null) {
     172            1 :             setMatches({ ids: null, error: 'The search needs the embedded database.' });
     173            1 :             return;
     174            1 :         }
     175            1 :         let cancelled = false;
     176            1 :         setupDatabase('snippets')
     177            1 :             .then(database => getSnippetSearch(database))
     178            1 :             .then(search => search.search(registry, connector, text))
     179            1 :             .then(
     180            1 :                 ids => { if (!cancelled) setMatches({ ids, error: null }); },
     181            0 :                 (error: unknown) => {
     182            0 :                     if (!cancelled) setMatches({ ids: null, error: `The search failed: ${error instanceof Error ? error.message : String(error)}` });
     183            0 :                 },
     184            1 :             );
     185            1 :         return () => { cancelled = true; };
     186            1 :     }, [setupDatabase, registry, connector, text]);
     187            1 :     return text.length === 0 ? { ids: null, error: null } : matches;
     188            1 : }
     189              : 
     190              : /// A library button that opens a browser of SQL snippets, applying a snippet replaces the text of the script
     191            1 : export function SnippetsOverlay(props: SnippetsOverlayProps): React.ReactElement {
     192            1 :     const anchorRef = React.useRef<HTMLButtonElement>(null);
     193            1 :     const listRef = React.useRef<HTMLDivElement>(null);
     194            1 :     const searchRef = React.useRef<HTMLInputElement>(null);
     195            1 :     const [open, setOpen] = React.useState(false);
     196            1 :     const apply = React.useCallback((text: string) => {
     197            1 :         setOpen(false);
     198            1 :         props.onApply(text);
     199            1 :     }, [props.onApply]);
     200              : 
     201            1 :     return (
     202            1 :         <>
     203            1 :             <IconButton
     204            1 :                 ref={anchorRef}
     205            1 :                 variant={open ? ButtonVariant.Default : ButtonVariant.Invisible}
     206            1 :                 size={ButtonSize.Small}
     207            1 :                 aria-label={`Insert a snippet into ${props.scriptName}`}
     208            1 :                 aria-haspopup="dialog"
     209            1 :                 aria-expanded={open}
     210            1 :                 data-testid={props.testId ?? 'script-snippets'}
     211            1 :                 disabled={props.disabled}
     212            1 :                 onClick={(event) => { event.stopPropagation(); setOpen(value => !value); }}
     213              :             >
     214            1 :                 <LibraryIcon size={props.iconSize ?? 16} />
     215            1 :             </IconButton>
     216            1 :             <AnchoredOverlay
     217            1 :                 renderAnchor={null}
     218            1 :                 anchorRef={anchorRef}
     219            1 :                 returnFocusRef={anchorRef}
     220            1 :                 open={open}
     221            1 :                 onClose={() => setOpen(false)}
     222            1 :                 side={AnchorSide.OutsideBottom}
     223            1 :                 align={AnchorAlignment.End}
     224            1 :                 width={OverlaySize.XXL}
     225            1 :                 height={OverlaySize.L}
     226            1 :                 focusTrapSettings={{ initialFocusRef: searchRef as React.RefObject<HTMLElement> }}
     227              :             >
     228            1 :                 <SnippetBrowser
     229            1 :                     connectorType={props.connectorType}
     230            1 :                     registry={props.registry}
     231            1 :                     listRef={listRef}
     232            1 :                     searchRef={searchRef}
     233            1 :                     onApply={apply}
     234            1 :                     onClose={() => setOpen(false)}
     235            1 :                 />
     236            1 :             </AnchoredOverlay>
     237            1 :         </>
     238              :     );
     239            1 : }
     240              : 
     241              : export interface SnippetBrowserProps {
     242              :     /// The connector of the script
     243              :     connectorType: ConnectorType;
     244              :     /// The snippets to browse, the bundled ones by default
     245              :     registry?: SnippetRegistry;
     246              :     /// The snippet list
     247              :     listRef: React.RefObject<HTMLDivElement | null>;
     248              :     /// The search field, focused when the browser opens
     249              :     searchRef?: React.RefObject<HTMLInputElement | null>;
     250              :     /// Apply the text of a snippet
     251              :     onApply: (text: string) => void;
     252              :     /// Close the browser
     253              :     onClose: () => void;
     254              : }
     255              : 
     256              : /// The content of the snippet overlay, mounted while the overlay is open
     257            1 : export function SnippetBrowser(props: SnippetBrowserProps): React.ReactElement {
     258            1 :     const bundled = useBundledSnippets();
     259            1 :     const registry = props.registry ?? bundled ?? EMPTY_REGISTRY;
     260            1 :     const titleId = React.useId();
     261            1 :     const bodyRef = React.useRef<HTMLDivElement>(null);
     262            1 :     const listWidth = useListWidth(bodyRef);
     263            1 :     const [selectedId, setSelectedId] = React.useState<string | null>(null);
     264            1 :     const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(new Set());
     265            1 :     const [searchText, setSearchText] = React.useState('');
     266            1 :     const searchQuery = searchText.trim();
     267            1 :     const matches = useSnippetMatches(registry, props.connectorType, searchQuery);
     268              : 
     269              :     // Only the snippets that run on the connection of the script and match the search
     270            1 :     const snippets = React.useMemo(() => {
     271            1 :         const all = snippetsForConnector(registry, props.connectorType);
     272            1 :         return matches.ids == null ? all : all.filter(snippet => matches.ids!.has(snippet.id));
     273            1 :     }, [registry, props.connectorType, matches.ids]);
     274              :     // The folders with matches stay open while searching
     275            1 :     const searching = matches.ids != null;
     276            1 :     const tree = React.useMemo(() => buildSnippetTree(snippets), [snippets]);
     277              :     // The keys move through the snippets of the expanded folders
     278            1 :     const isExpanded = React.useCallback((path: string) => searching || !collapsed.has(path), [searching, collapsed]);
     279            1 :     const visible = React.useMemo(() => expandedSnippets(tree, isExpanded), [tree, isExpanded]);
     280            1 :     const selected = snippets.find(snippet => snippet.id === selectedId) ?? visible[0] ?? null;
     281            1 :     const toggle = React.useCallback((path: string) => setCollapsed(current => {
     282            1 :         const next = new Set(current);
     283            1 :         if (!next.delete(path)) next.add(path);
     284            1 :         return next;
     285            1 :     }), []);
     286            1 :     const text = React.useMemo(() => selected == null ? '' : snippetText(selected, props.connectorType),
     287            1 :         [selected, props.connectorType]);
     288              :     // The preview formats the text to the width of the view, the applied text stays as written
     289            1 :     const keywordCase = getKeywordCase(useAppConfig()?.settings);
     290            1 :     const [formatWidth, setFormatWidth] = React.useState<number | null>(null);
     291            1 :     const onPreviewResize = React.useCallback((view: EditorView) => setFormatWidth(measureScriptFormatWidth(view)), []);
     292            1 :     const formatConfig = React.useMemo(() => formatWidth == null ? null : createScriptFormatConfig(
     293            1 :         dashql.buffers.formatting.FormattingMode.PRETTY, false, formatWidth, keywordCase, formattingDialectForConnector(props.connectorType),
     294            1 :     ), [formatWidth, keywordCase, props.connectorType]);
     295            1 :     const preview = useProjectedSqlText(text, 'snippets', formatConfig);
     296              : 
     297            1 :     const apply = React.useCallback(() => {
     298            1 :         if (selected != null) props.onApply(text);
     299            1 :     }, [selected, text, props.onApply]);
     300              : 
     301              :     // Arrow keys move the selection, Enter applies it. In the list, the focus follows the selection.
     302            1 :     const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLElement>, focusItem: boolean) => {
     303            1 :         if (event.key === 'Enter') {
     304            1 :             event.preventDefault();
     305            1 :             apply();
     306            1 :             return;
     307            1 :         }
     308            1 :         if ((event.key !== 'ArrowDown' && event.key !== 'ArrowUp') || visible.length === 0) return;
     309            1 :         event.preventDefault();
     310            1 :         const index = selected == null ? -1 : visible.indexOf(selected);
     311            1 :         const next = visible[Math.min(Math.max(index + (event.key === 'ArrowDown' ? 1 : -1), 0), visible.length - 1)];
     312            1 :         setSelectedId(next.id);
     313            1 :         const item = props.listRef.current?.querySelector<HTMLElement>(`[data-snippet-id="${next.id}"]`);
     314            1 :         if (focusItem) item?.focus();
     315            1 :         item?.scrollIntoView({ block: 'nearest' });
     316            1 :     }, [apply, visible, selected, props.listRef]);
     317              : 
     318            1 :     const renderEntries = (parent: SnippetFolder, depth: number): React.ReactNode => (
     319            1 :         <>
     320            1 :             {parent.folders.map(folder => {
     321            1 :                 const expanded = isExpanded(folder.path);
     322            1 :                 return (
     323            1 :                     <React.Fragment key={folder.path}>
     324            1 :                         <TreeEntry
     325            1 :                             depth={depth}
     326            1 :                             expanded={expanded}
     327              :                             // The datasets load the data that the snippets of the other folders use
     328            1 :                             icon={folder.path === SNIPPET_DATASETS_FOLDER ? <DatabaseIcon size={14} /> : <FileDirectoryIcon size={14} />}
     329            1 :                             label={<TreeEntryName title={folder.path}>{folder.name}</TreeEntryName>}
     330            1 :                             data-testid="snippet-folder"
     331            1 :                             data-folder={folder.path}
     332            1 :                             onClick={() => toggle(folder.path)}
     333            1 :                         />
     334            1 :                         {expanded && renderEntries(folder, depth + 1)}
     335            1 :                     </React.Fragment>
     336              :                 );
     337            1 :             })}
     338            1 :             {parent.snippets.map(snippet => {
     339            1 :                 const path = snippet.paths.get(props.connectorType)!;
     340            1 :                 return (
     341            1 :                     <TreeEntry
     342            1 :                         key={snippet.id}
     343            1 :                         depth={depth}
     344            1 :                         icon={<FileIcon size={14} />}
     345            1 :                         label={<TreeEntryName title={path}>{path.slice(path.lastIndexOf('/') + 1)}</TreeEntryName>}
     346            1 :                         selected={snippet === selected}
     347            1 :                         data-testid="snippet"
     348            1 :                         data-snippet-id={snippet.id}
     349            1 :                         onClick={() => setSelectedId(snippet.id)}
     350            1 :                         onDoubleClick={() => props.onApply(snippetText(snippet, props.connectorType))}
     351            1 :                     />
     352              :                 );
     353            1 :             })}
     354            1 :         </>
     355              :     );
     356              : 
     357            1 :     return (
     358            1 :         <section className={styles.overlay} role="dialog" aria-labelledby={titleId} data-testid="snippets-overlay">
     359            1 :             <header className={styles.header}>
     360            1 :                 <h2 id={titleId}>Snippets</h2>
     361            1 :                 <IconButton
     362            1 :                     variant={ButtonVariant.Invisible}
     363            1 :                     size={ButtonSize.Small}
     364            1 :                     aria-label="Close snippets"
     365            1 :                     data-testid="snippets-close"
     366            1 :                     onClick={props.onClose}
     367              :                 >
     368            1 :                     <XIcon size={16} />
     369            1 :                 </IconButton>
     370            1 :             </header>
     371            1 :             <div ref={bodyRef} className={styles.body} style={{ gridTemplateColumns: `${listWidth.width}px minmax(0, 1fr)` }}>
     372            1 :                 <div className={styles.sidebar}>
     373            1 :                     <div
     374            1 :                         className={styles.resize_handle}
     375            1 :                         role="separator"
     376            1 :                         aria-orientation="vertical"
     377            1 :                         aria-label="Resize the snippet list"
     378            1 :                         aria-valuenow={listWidth.width}
     379            1 :                         tabIndex={0}
     380            1 :                         data-testid="snippet-list-resize"
     381            1 :                         onPointerDown={listWidth.onPointerDown}
     382            1 :                         onKeyDown={listWidth.onKeyDown}
     383            1 :                     />
     384            1 :                     <div className={styles.search}>
     385            1 :                         <TextInput
     386            1 :                             ref={props.searchRef}
     387            1 :                             block
     388            1 :                             type="search"
     389            1 :                             placeholder="Search snippets"
     390            1 :                             aria-label="Search snippets"
     391            1 :                             leadingVisual={SearchIcon}
     392            1 :                             value={searchText}
     393            1 :                             data-testid="snippet-search"
     394            1 :                             onChange={event => setSearchText(event.target.value)}
     395            1 :                             onKeyDown={event => onKeyDown(event, false)}
     396            1 :                         />
     397            1 :                     </div>
     398            1 :                     <div ref={props.listRef} className={styles.list} data-testid="snippet-list" tabIndex={-1} onKeyDown={event => onKeyDown(event, true)}>
     399            1 :                         {matches.error != null ? (
     400            1 :                             <div className={styles.empty} data-testid="snippets-search-error">{matches.error}</div>
     401            1 :                         ) : snippets.length === 0 ? (
     402            1 :                             <div className={styles.empty} data-testid="snippets-empty">
     403            1 :                                 {searching ? 'No snippets match the search.' : 'No snippets for this connection.'}
     404            1 :                             </div>
     405              :                         ) : (
     406            1 :                             <div className={styles.tree}>{renderEntries(tree, 0)}</div>
     407              :                         )}
     408            1 :                     </div>
     409            1 :                 </div>
     410            1 :                 <div className={styles.preview} data-testid="snippet-preview" data-snippet-id={selected?.id}>
     411            1 :                     {selected != null && (
     412            1 :                         <>
     413            1 :                             <div className={styles.preview_header}>
     414            1 :                                 <Button variant={ButtonVariant.Primary} size={ButtonSize.Small} data-testid="snippet-apply" onClick={apply}>
     415              :                                     Use
     416            1 :                                 </Button>
     417            1 :                             </div>
     418            1 :                             <div className={styles.preview_text} data-testid="snippet-text">
     419            1 :                                 <ReadonlySqlView text={preview.text} editorUpdate={preview.editorUpdate} onResize={onPreviewResize} />
     420            1 :                             </div>
     421            1 :                         </>
     422              :                     )}
     423            1 :                 </div>
     424            1 :             </div>
     425            1 :         </section>
     426              :     );
     427            1 : }
        

Generated by: LCOV version 2.0-1