LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - session_feed_row.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.7 % 384 375
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './session_feed.module.css';
       3              : import * as dashql from '@ankoh/dashql-core';
       4              : import { useSessionExecution } from '../scripts/session_execution_provider.js';
       5              : 
       6              : import type { EditorView } from '@codemirror/view';
       7              : import type { Icon } from '../../ui/foundations/symbol_icon.js';
       8              : import { PlusIcon } from '../../ui/foundations/symbol_icon.js';
       9              : import type { RowComponentProps } from 'react-window';
      10              : import { useSortable } from '@dnd-kit/sortable';
      11              : import { CSS } from '@dnd-kit/utilities';
      12              : 
      13              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
      14              : import { ButtonGroup } from '../../ui/foundations/button_group.js';
      15              : import { IndicatorStatus } from '../../ui/foundations/status_indicator.js';
      16              : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      17              : import { ConnectionHealth, type ConnectionState } from '../../connections/connection_state.js';
      18              : import { ConnectorType } from '../../connections/connector_info.js';
      19              : import { QueryExecutionStatus } from '../../query/query_execution_state.js';
      20              : import { useCancelQuery, useQueryState } from '../../connections/query_executor.js';
      21              : import type { StorageReader } from '../../storage/storage_provider.js';
      22              : import { getScriptRefs, type SessionScripts, type ScriptData } from '../scripts/session_scripts.js';
      23              : import { scriptDisplayName } from '../scripts/script_types.js';
      24              : import { deriveEntryStatus, EntryStatusKind } from '../entry_status_model.js';
      25              : import { EntryStatusBar } from '../entry_status_bar.js';
      26              : import { CachedResultBean, QueryResultRerunButton } from '../persistence/query_result_cache_controls.js';
      27              : import { ScriptDiagnosticsButton } from '../scripts/script_diagnostics.js';
      28              : import { ScriptEditor } from '../scripts/script_editor.js';
      29              : import { ScriptStatisticsBar } from '../scripts/script_statistics_bar.js';
      30              : import { formatScriptEditor, formattingDialectForConnector, isScriptFormattable } from '../scripts/script_format.js';
      31              : import { FeedEntryFooter, FeedEntryTabStrip, useFeedEntryTabs } from './feed_entry_footer.js';
      32              : import type { ScriptStorage } from '../scripts/script_action_menu.js';
      33              : import { displayPath } from '../../storage/session_locator.js';
      34              : import { STORAGE_SCRIPTS_FOLDER } from '../../storage/storage_backend.js';
      35              : import type { ScriptConnectionView } from '../scripts/script_connection_picker.js';
      36              : import { ChartBuilderOverlay } from '../chart_builder/chart_builder_overlay.js';
      37              : import { ResultTableOverlay } from '../../views/query_result/result_table_overlay.js';
      38              : import { FeedEntryHeader } from './feed_entry_header.js';
      39              : import { FEED_TOP_PADDING } from './session_feed_layout.js';
      40              : import type { ScriptReferenceReport } from '../scripts/script_reference_check.js';
      41              : 
      42            1 : const DragHandleIcon: Icon = SymbolIcon('drag_handle_16');
      43              : 
      44              : export interface ScriptCardProps {
      45              :     sessionId: string;
      46              :     /// The connection fields of the card, the connection the script runs on.
      47              :     /// The connection state itself changes with every query event of the session.
      48              :     connectionId: string | null;
      49              :     connectionHealth: ConnectionHealth | null;
      50              :     connectionDetails: ConnectionState['details'] | null;
      51              :     connectorType: ConnectorType;
      52              :     /// The connection the script runs on, null while it is not registered
      53              :     connection: ScriptConnectionView | null;
      54              :     /// The cache key of the result of a script, the key that a run of the script reads and writes
      55              :     computeCacheKey: (scriptKey: number) => Promise<string | null>;
      56              :     /// Is a cache key cached in the query result cache of the app?
      57              :     /// The time the result of a cache key was written, null if the key is not cached
      58              :     lookupCachedResult: (cacheKey: string) => Promise<number | null>;
      59              :     storageReader: StorageReader;
      60              :     isFocused: boolean;
      61              :     scriptData: ScriptData | undefined;
      62              :     scriptFileName: string;
      63              :     scriptDebugMode: boolean;
      64              :     formattingDebugMode: boolean;
      65              :     keywordCase: dashql.buffers.formatting.KeywordCase;
      66              :     canExecute: boolean;
      67              :     active: boolean;
      68              :     canMoveUp: boolean;
      69              :     canMoveDown: boolean;
      70              :     /// The position of the script in the feed
      71              :     index: number;
      72              :     /// Add a script at a position of the feed
      73              :     onCreate: (index: number) => void;
      74              :     onFocus: (fileName: string) => void;
      75              :     onDelete: (fileName: string) => void;
      76              :     onRename: (oldFileName: string, newFileName: string) => void;
      77              :     /// Run the script on another connection
      78              :     onPickConnection: (fileName: string, connectionId: string) => void;
      79              :     onMoveUp: (fileName: string) => void;
      80              :     onMoveDown: (fileName: string) => void;
      81              :     onExecute: (fileName: string) => void;
      82              :     onRerun: (fileName: string) => void;
      83              :     onFormat: (scriptKey: number, text: string) => void;
      84              :     /// Replace the text of the script with the text of a snippet
      85              :     onApplySnippet: (scriptKey: number, text: string) => void;
      86              :     /// Write a Vega-Lite spec of the chart builder into the `@vis vegalite` tag of the script
      87              :     onSetVisSpec: (scriptKey: number, spec: string) => void;
      88              :     /// Can a question of the composer target the script?
      89              :     canTarget: boolean;
      90              :     /// The checked script references of the script
      91              :     scriptReferences?: ScriptReferenceReport | null;
      92              :     /// Reference the script in the question of the composer
      93              :     onTarget: (fileName: string) => void;
      94              :     onAcceptDiff: (scriptKey: number) => void;
      95              :     onRejectDiff: (scriptKey: number) => void;
      96              :     resultExpanded: boolean;
      97              :     onToggleResultExpanded: (scriptKey: number) => void;
      98              :     onAutoCollapseResult: (scriptKey: number, queryId: number) => void;
      99              :     onResetAutoCollapsedResult: (scriptKey: number, queryId: number | null) => void;
     100              :     onEditorView: (scriptKey: number, view: EditorView) => void;
     101              :     dependencies?: ScriptCardDependencies;
     102              : }
     103              : 
     104              : export interface ScriptCardDependencies {
     105              :     useQueryState: typeof useQueryState;
     106              :     useCancelQuery: typeof useCancelQuery;
     107              :     ScriptEditor: typeof ScriptEditor;
     108              :     ScriptDiagnosticsButton: typeof ScriptDiagnosticsButton;
     109              :     ScriptStatisticsBar: typeof ScriptStatisticsBar;
     110              :     formatScriptEditor: typeof formatScriptEditor;
     111              :     isScriptFormattable: typeof isScriptFormattable;
     112              :     CachedResultBean: typeof CachedResultBean;
     113              :     QueryResultRerunButton: typeof QueryResultRerunButton;
     114              :     EntryStatusBar: typeof EntryStatusBar;
     115              :     FeedEntryFooter: typeof FeedEntryFooter;
     116              :     FeedEntryTabStrip: typeof FeedEntryTabStrip;
     117              :     ChartBuilderOverlay: typeof ChartBuilderOverlay;
     118              :     ResultTableOverlay: typeof ResultTableOverlay;
     119              : }
     120              : 
     121            1 : const DEFAULT_SCRIPT_CARD_DEPENDENCIES: ScriptCardDependencies = {
     122            1 :     useQueryState,
     123            1 :     useCancelQuery,
     124            1 :     ScriptEditor,
     125            1 :     ScriptDiagnosticsButton,
     126            1 :     ScriptStatisticsBar,
     127            1 :     formatScriptEditor,
     128            1 :     isScriptFormattable,
     129            1 :     CachedResultBean,
     130            1 :     QueryResultRerunButton,
     131            1 :     EntryStatusBar,
     132            1 :     FeedEntryFooter,
     133            1 :     FeedEntryTabStrip,
     134            1 :     ChartBuilderOverlay,
     135            1 :     ResultTableOverlay,
     136            1 : };
     137              : 
     138              : // Memoized since every row of the feed re-renders whenever any script changes
     139            1 : export const ScriptCard: React.FC<ScriptCardProps> = React.memo((props: ScriptCardProps) => {
     140            1 :     const sessionExecution = useSessionExecution(props.sessionId);
     141            1 :     const dependencies = props.dependencies ?? DEFAULT_SCRIPT_CARD_DEPENDENCIES;
     142            1 :     const CheckIcon: Icon = SymbolIcon('check_16');
     143            1 :     const CrossIcon: Icon = SymbolIcon('x_16');
     144              : 
     145            1 :     const queryState = dependencies.useQueryState(props.sessionId, props.scriptData?.latestQueryId ?? null);
     146            1 :     const cancelQuery = dependencies.useCancelQuery();
     147              : 
     148              :     // A staged rewrite waiting to be accepted/rejected. The editable editor renders the
     149              :     // in-place diff and these actions provide the same explicit accept/reject alternatives.
     150            1 :     const hasPendingDiff = props.scriptData?.pendingDiff != null;
     151              : 
     152              :     // Is the script cached?
     153              :     // Planning the script is a full pass over the scripts it needs, only re-check when the text, the catalog,
     154              :     // the queries or their cache entries changed (and not on every cursor move).
     155              :     // The time the cached result of a run of the script was written, null if it is not cached
     156            1 :     const [cachedAtMs, setCachedAtMs] = React.useState<number | null>(null);
     157            1 :     React.useEffect(() => {
     158            1 :         const cancel = new AbortController();
     159            1 :         const connectionDetails = props.connectionDetails;
     160            1 :         const run = async () => {
     161              :             // Not script data? Clear cache indicator.
     162            1 :             if (props.scriptData == null || !connectionDetails) {
     163            1 :                 setCachedAtMs(null);
     164            1 :                 return;
     165            1 :             }
     166              :             // Outdated analysis? Keep the last known indicator until the script is re-analyzed.
     167              :             // Every keystroke outdates all scripts of the session, clearing it would make it flicker.
     168            1 :             if (props.scriptData.analysisOutdated) {
     169            1 :                 return;
     170              :             }
     171            1 :             try {
     172              :                 // The key of a run of the script, null if its result is not cacheable
     173            1 :                 const cacheKey = await props.computeCacheKey(props.scriptData.scriptKey);
     174              :                 // Look up the key in the cache of the app
     175            1 :                 const cachedAt = cacheKey != null ? await props.lookupCachedResult(cacheKey) : null;
     176            1 :                 if (!cancel.signal.aborted) {
     177            1 :                     setCachedAtMs(cachedAt);
     178              :                 }
     179            1 :             } catch { }
     180            1 :         };
     181            1 :         run();
     182            1 :         return () => cancel.abort();
     183            1 :     }, [
     184            1 :         props.scriptData?.scriptSession,
     185            1 :         props.scriptData?.analysisOutdated,
     186            1 :         props.scriptData?.editorUpdate?.textVersion,
     187            1 :         props.scriptData?.editorUpdate?.catalogRevision,
     188            1 :         props.scriptData?.latestQueryId,
     189            1 :         props.scriptData?.latestResultQueryId,
     190            1 :         queryState?.cacheKey,
     191            1 :         queryState?.cacheDeleted,
     192            1 :         props.connectionDetails,
     193            1 :         props.computeCacheKey,
     194            1 :         props.lookupCachedResult,
     195            1 :         props.sessionId,
     196            1 :     ]);
     197              : 
     198              :     // The result header persists for active and terminal execution states. It can collapse the
     199              :     // result body without affecting its cancel, cache, and rerun actions.
     200            1 :     const entryStatus = deriveEntryStatus(queryState);
     201            1 :     const scriptKey = props.scriptData?.scriptKey ?? null;
     202              :     // The result tabs live in the status bar, their panel below it
     203            1 :     const resultTabs = useFeedEntryTabs(queryState, props.scriptData?.annotations.visualizeQuery ?? null);
     204              :     // Picking a tab of a collapsed result shows it
     205            1 :     const expandResult = React.useCallback(() => {
     206            0 :         if (scriptKey != null && !props.resultExpanded) props.onToggleResultExpanded(scriptKey);
     207            1 :     }, [scriptKey, props.resultExpanded, props.onToggleResultExpanded]);
     208            1 :     React.useEffect(() => {
     209            1 :         if (scriptKey == null) return;
     210            1 :         if (queryState?.status !== QueryExecutionStatus.SUCCEEDED || queryState.resultTable !== null) {
     211            1 :             props.onResetAutoCollapsedResult(scriptKey, queryState?.queryId ?? props.scriptData?.latestQueryId ?? null);
     212            1 :             return;
     213            1 :         }
     214            1 :         props.onAutoCollapseResult(scriptKey, queryState.queryId);
     215            1 :     }, [props.onAutoCollapseResult, props.onResetAutoCollapsedResult, props.scriptData?.latestQueryId, queryState, scriptKey]);
     216            1 :     const cancelEntryOperation = React.useCallback(() => {
     217            0 :         if (entryStatus?.kind === EntryStatusKind.Query && props.scriptData?.latestQueryId != null) {
     218            0 :             if (sessionExecution.busy) sessionExecution.stop();
     219            0 :             else if (props.connectionId != null) cancelQuery(props.connectionId, props.scriptData.latestQueryId);
     220            0 :         }
     221            1 :     }, [cancelQuery, entryStatus?.kind, props.connectionId, props.scriptData?.latestQueryId, sessionExecution.busy, sessionExecution.stop]);
     222              : 
     223            1 :     const acceptDiff = React.useCallback(() => {
     224            1 :         if (scriptKey != null) props.onAcceptDiff(scriptKey);
     225            1 :     }, [scriptKey, props.onAcceptDiff]);
     226            1 :     const rejectDiff = React.useCallback(() => {
     227            1 :         if (scriptKey != null) props.onRejectDiff(scriptKey);
     228            1 :     }, [scriptKey, props.onRejectDiff]);
     229              : 
     230            1 :     const [editorView, setEditorView] = React.useState<EditorView | null>(null);
     231            1 :     const formattingDialect = formattingDialectForConnector(props.connectorType);
     232              :     // Formattability only depends on the text, cursor moves bump the state revision but not the text version
     233            1 :     const isFormattable = React.useMemo(
     234            1 :         () => dependencies.isScriptFormattable(props.scriptData ?? null, formattingDialect),
     235            1 :         [props.scriptData?.scriptSession, props.scriptData?.editorUpdate?.textVersion, formattingDialect],
     236            1 :     );
     237            1 :     const handleEditorView = React.useCallback((view: EditorView) => {
     238            1 :         setEditorView(view);
     239            1 :         if (props.scriptData != null) props.onEditorView(props.scriptData.scriptKey, view);
     240            1 :     }, [props.onEditorView, props.scriptData?.scriptKey]);
     241            1 :     const handleFormat = React.useCallback((mode: dashql.buffers.formatting.FormattingMode) => {
     242            1 :         dependencies.formatScriptEditor(
     243            1 :             editorView,
     244            1 :             props.scriptData ?? null,
     245            1 :             mode,
     246            1 :             (text) => {
     247            1 :                 if (props.scriptData != null) props.onFormat(props.scriptData.scriptKey, text);
     248            1 :             },
     249            1 :             props.formattingDebugMode,
     250            1 :             props.keywordCase,
     251            1 :             formattingDialect,
     252            1 :         );
     253            1 :     }, [editorView, props.formattingDebugMode, props.keywordCase, formattingDialect, props.onFormat, props.scriptData]);
     254              : 
     255              :     // Labels use the display name (the file name without ".sql"); the raw scriptFileName remains the
     256              :     // identity passed to handlers and to RENAME_SCRIPT.
     257            1 :     const applySnippet = React.useCallback((text: string) => {
     258            0 :         if (scriptKey != null) props.onApplySnippet(scriptKey, text);
     259            1 :     }, [scriptKey, props.onApplySnippet]);
     260            1 :     const setVisSpec = React.useCallback((spec: string) => {
     261            0 :         if (scriptKey != null) props.onSetVisSpec(scriptKey, spec);
     262            1 :     }, [scriptKey, props.onSetVisSpec]);
     263              : 
     264            1 :     const displayName = scriptDisplayName(props.scriptFileName);
     265            1 :     const storage = React.useMemo<ScriptStorage>(() => ({
     266            1 :         fileName: props.scriptFileName,
     267            1 :         resolvePath: () => `${displayPath(props.sessionId, props.storageReader.getSessionLocation(props.sessionId))}/${STORAGE_SCRIPTS_FOLDER}/${props.scriptFileName}`,
     268            1 :         onRename: name => props.onRename(props.scriptFileName, name),
     269            1 :     }), [props.scriptFileName, props.sessionId, props.storageReader, props.onRename]);
     270              : 
     271              :     // The full result table opens in a card over the feed
     272            1 :     const [tableOverlayOpen, setTableOverlayOpen] = React.useState(false);
     273            1 :     const showTable = React.useCallback(() => setTableOverlayOpen(true), []);
     274            1 :     const closeTableOverlay = React.useCallback(() => setTableOverlayOpen(false), []);
     275              : 
     276            1 :     const resultContentId = props.scriptData != null ? `feed-result-${props.scriptData.scriptKey}` : undefined;
     277            1 :     const hasFooterContent = entryStatus.kind !== EntryStatusKind.Idle;
     278            1 :     return (
     279            1 :         <article
     280            1 :             className={styles.feed_entry_pair}
     281            1 :             data-electron-drag-region="false"
     282            1 :             onPointerEnter={() => props.onFocus(props.scriptFileName)}
     283            1 :             aria-label={`${displayName} script cell`}
     284            1 :             data-testid="script-cell"
     285              :         >
     286            1 :             <div className={styles.feed_entry_cell} data-result-card={hasFooterContent ? '' : undefined}>
     287            1 :                 <FeedEntryHeader
     288            1 :                     sessionId={props.sessionId}
     289            1 :                     scriptData={props.scriptData}
     290            1 :                     scriptFileName={props.scriptFileName}
     291            1 :                     connection={props.connection}
     292            1 :                     connectorType={props.connectorType}
     293            1 :                     queryState={queryState}
     294            1 :                     focused={props.isFocused}
     295            1 :                     canExecute={props.canExecute}
     296            1 :                     scriptDebugMode={props.scriptDebugMode}
     297            1 :                     isFormattable={isFormattable}
     298            1 :                     canFormat={editorView != null}
     299            1 :                     scriptReferences={props.scriptReferences ?? null}
     300            1 :                     canTarget={props.canTarget}
     301            1 :                     onTarget={() => props.onTarget(props.scriptFileName)}
     302            1 :                     onExecute={() => props.onExecute(props.scriptFileName)}
     303            1 :                     onPickConnection={connectionId => props.onPickConnection(props.scriptFileName, connectionId)}
     304            1 :                     onRename={name => props.onRename(props.scriptFileName, name)}
     305            1 :                     onDelete={() => props.onDelete(props.scriptFileName)}
     306            1 :                     onFormat={handleFormat}
     307            1 :                     onApplySnippet={applySnippet}
     308            1 :                     onSetVisSpec={setVisSpec}
     309            1 :                     storage={storage}
     310            1 :                     onAddAbove={() => props.onCreate(props.index)}
     311            1 :                     onAddBelow={() => props.onCreate(props.index + 1)}
     312            1 :                     onMoveUp={() => props.onMoveUp(props.scriptFileName)}
     313            1 :                     onMoveDown={() => props.onMoveDown(props.scriptFileName)}
     314            1 :                     canMoveUp={props.canMoveUp}
     315            1 :                     canMoveDown={props.canMoveDown}
     316            1 :                     dependencies={dependencies}
     317            1 :                 />
     318            1 :                 <div className={styles.feed_body}>
     319            1 :                     <div className={styles.feed_editor_container}>
     320            1 :                         {props.scriptData == null || !props.active ? null : (
     321            1 :                             <dependencies.ScriptEditor
     322            1 :                                 sessionId={props.sessionId}
     323            1 :                                 scriptKey={props.scriptData.scriptKey}
     324            1 :                                 className={styles.feed_script_editor}
     325            1 :                                 autoHeight
     326            1 :                                 onFocus={() => props.onFocus(props.scriptFileName)}
     327            1 :                                 setView={handleEditorView}
     328            1 :                             />
     329              :                         )}
     330            1 :                         {hasPendingDiff && (
     331            1 :                             <div className={styles.feed_body_overlays}>
     332            1 :                                 <div data-diff-actions>
     333            1 :                                     <ButtonGroup>
     334            1 :                                         <IconButton
     335            1 :                                             variant={ButtonVariant.Default}
     336            1 :                                             size={ButtonSize.Small}
     337            1 :                                             onClick={acceptDiff}
     338            1 :                                             aria-label="Accept rewrite"
     339            1 :                                             data-testid="diff-accept"
     340              :                                         >
     341            1 :                                             <CheckIcon size={14} />
     342            1 :                                         </IconButton>
     343            1 :                                         <IconButton
     344            1 :                                             variant={ButtonVariant.Default}
     345            1 :                                             size={ButtonSize.Small}
     346            1 :                                             onClick={rejectDiff}
     347            1 :                                             aria-label="Reject rewrite"
     348            1 :                                             data-testid="diff-reject"
     349              :                                         >
     350            1 :                                             <CrossIcon size={14} />
     351            1 :                                         </IconButton>
     352            1 :                                     </ButtonGroup>
     353            1 :                                 </div>
     354            1 :                             </div>
     355              :                         )}
     356            1 :                     </div>
     357            1 :                 </div>
     358            1 :                 {hasFooterContent && (
     359            1 :                     <div className={styles.feed_entry_footer} data-feed-result="">
     360            1 :                         <dependencies.EntryStatusBar
     361            1 :                             status={entryStatus}
     362            1 :                             onToggleExpanded={() => {
     363            0 :                                 if (scriptKey != null) props.onToggleResultExpanded(scriptKey);
     364            0 :                             }}
     365            1 :                             expanded={props.resultExpanded}
     366            1 :                             controls={resultContentId}
     367            1 :                             onCancel={entryStatus.indicator === IndicatorStatus.Running ? cancelEntryOperation : undefined}
     368            1 :                             cancelLabel="Cancel query"
     369            1 :                             compact
     370            1 :                             tabs={
     371            1 :                                 <dependencies.FeedEntryTabStrip
     372            1 :                                     tabs={resultTabs}
     373            1 :                                     expanded={props.resultExpanded}
     374            1 :                                     onSelect={expandResult}
     375            1 :                                 />
     376              :                             }
     377            1 :                             actions={
     378            1 :                                 <>
     379            1 :                                     {cachedAtMs != null && <dependencies.CachedResultBean cachedAtMs={cachedAtMs} />}
     380            1 :                                     <dependencies.QueryResultRerunButton
     381            1 :                                         query={queryState}
     382            1 :                                         onRerun={props.connectionHealth === ConnectionHealth.ONLINE && !sessionExecution.runActive
     383            1 :                                             ? () => props.onRerun(props.scriptFileName) : undefined}
     384            1 :                                     />
     385            1 :                                 </>
     386              :                             }
     387            1 :                         />
     388            1 :                         {queryState != null ? (
     389            1 :                             <div id={resultContentId} hidden={!props.resultExpanded}>
     390            1 :                                 <dependencies.FeedEntryFooter
     391            1 :                                     sessionId={props.sessionId}
     392            1 :                                     queryState={queryState}
     393            1 :                                     visualizeQuery={props.scriptData?.annotations.visualizeQuery ?? null}
     394            1 :                                     selectedTab={resultTabs.selectedTab}
     395            1 :                                     onShowTable={showTable}
     396            1 :                                 />
     397            1 :                             </div>
     398            1 :                         ) : null}
     399            1 :                         {tableOverlayOpen && queryState != null && (
     400            1 :                             <dependencies.ResultTableOverlay
     401            1 :                                 scriptName={displayName}
     402            1 :                                 query={queryState}
     403            1 :                                 onClose={closeTableOverlay}
     404            1 :                             />
     405              :                         )}
     406            1 :                     </div>
     407              :                 )}
     408            1 :             </div>
     409            1 :         </article>
     410              :     );
     411            1 : });
     412              : 
     413              : export interface ScriptFeedRowProps {
     414              :     sessionId: string;
     415              :     /// The connections that the scripts run on
     416              :     connections: ReadonlyMap<string, ScriptConnectionView>;
     417              :     /// The cache key of the result of a script, the key that a run of the script reads and writes
     418              :     computeCacheKey: (scriptKey: number) => Promise<string | null>;
     419              :     /// Is a cache key cached in the query result cache of the app?
     420              :     /// The time the result of a cache key was written, null if the key is not cached
     421              :     lookupCachedResult: (cacheKey: string) => Promise<number | null>;
     422              :     storageReader: StorageReader;
     423              :     entries: ReturnType<typeof getScriptRefs>;
     424              :     scripts: SessionScripts['scripts'];
     425              :     scriptDebugMode: boolean;
     426              :     formattingDebugMode: boolean;
     427              :     keywordCase: dashql.buffers.formatting.KeywordCase;
     428              :     focusedFileName: string;
     429              :     active: boolean;
     430              :     onFocus: (fileName: string) => void;
     431              :     onDelete: (fileName: string) => void;
     432              :     onRename: (oldFileName: string, newFileName: string) => void;
     433              :     onPickConnection: (fileName: string, connectionId: string) => void;
     434              :     onMoveUp: (fileName: string) => void;
     435              :     onMoveDown: (fileName: string) => void;
     436              :     onExecute: (fileName: string) => void;
     437              :     onRerun: (fileName: string) => void;
     438              :     onFormat: (scriptKey: number, text: string) => void;
     439              :     /// Replace the text of a script with the text of a snippet
     440              :     onApplySnippet: (scriptKey: number, text: string) => void;
     441              :     /// Write a Vega-Lite spec of the chart builder into the `@vis vegalite` tag of a script
     442              :     onSetVisSpec: (scriptKey: number, spec: string) => void;
     443              :     /// Can a question of the composer target the scripts?
     444              :     /// The checked script references of the scripts, by script key
     445              :     scriptReferences?: ReadonlyMap<number, ScriptReferenceReport>;
     446              :     canTarget: boolean;
     447              :     /// Reference a script in the question of the composer
     448              :     onTarget: (fileName: string) => void;
     449              :     onAcceptDiff: (scriptKey: number) => void;
     450              :     onRejectDiff: (scriptKey: number) => void;
     451              :     collapsedResults: ReadonlyMap<number, number | null>;
     452              :     onToggleResultExpanded: (scriptKey: number) => void;
     453              :     onAutoCollapseResult: (scriptKey: number, queryId: number) => void;
     454              :     onResetAutoCollapsedResult: (scriptKey: number, queryId: number | null) => void;
     455              :     onCreate: (index: number) => void;
     456              :     onEditorView: (scriptKey: number, view: EditorView) => void;
     457              :     onRowHeightChange: (index: number, height: number) => void;
     458              :     dependencies?: ScriptFeedRowDependencies;
     459              : }
     460              : 
     461              : export interface ScriptFeedRowDependencies {
     462              :     useSortable: typeof useSortable;
     463              :     translateTransform: typeof CSS.Translate.toString;
     464              :     scriptCard: ScriptCardDependencies;
     465              : }
     466              : 
     467            1 : const DEFAULT_SCRIPT_FEED_ROW_DEPENDENCIES: ScriptFeedRowDependencies = {
     468            1 :     useSortable,
     469            1 :     translateTransform: CSS.Translate.toString,
     470            1 :     scriptCard: DEFAULT_SCRIPT_CARD_DEPENDENCIES,
     471            1 : };
     472              : 
     473            1 : export function ScriptFeedRow(props: RowComponentProps<ScriptFeedRowProps>) {
     474            1 :     const dependencies = props.dependencies ?? DEFAULT_SCRIPT_FEED_ROW_DEPENDENCIES;
     475            1 :     const entryIndex = props.index;
     476            1 :     const entry = props.entries[entryIndex];
     477            1 :     const scriptData = entry != null ? props.scripts[entry.scriptId] : undefined;
     478            1 :     const scriptFileName = entry?.fileName ?? 'script.sql';
     479            1 :     const displayName = scriptDisplayName(scriptFileName);
     480              :     // Every script runs on its own connection
     481            1 :     const connection = scriptData != null ? props.connections.get(scriptData.connection.connectionId) ?? null : null;
     482              : 
     483              :     // Entries are in feed order, so the index drives the movement buttons
     484            1 :     const canMoveUp = entryIndex > 0;
     485            1 :     const canMoveDown = entryIndex < props.entries.length - 1;
     486            1 :     const {
     487            1 :         attributes,
     488            1 :         listeners,
     489            1 :         setNodeRef,
     490            1 :         transform,
     491            1 :         transition,
     492            1 :         isDragging,
     493            1 :     } = dependencies.useSortable({
     494            1 :         id: entry?.scriptId ?? `missing-${entryIndex}`,
     495              :         // The optimistic order is already the final dropped layout. Suppress dnd-kit's derived
     496              :         // post-drop FLIP transform, which otherwise briefly animates the card back toward its old row.
     497            1 :         animateLayoutChanges: () => false,
     498            1 :     });
     499            1 :     const rowRef = React.useRef<HTMLDivElement | null>(null);
     500            1 :     const setRowRef = React.useCallback((node: HTMLDivElement | null) => {
     501            1 :         rowRef.current = node;
     502            1 :         setNodeRef(node);
     503            1 :     }, [setNodeRef]);
     504              :     // The height cache stores the height of the script at the index of a row.
     505              :     // A row measures again when its index or its script changes, its size may not change and the size observer stays silent.
     506            1 :     const rowScriptId = entry?.scriptId;
     507            1 :     React.useLayoutEffect(() => {
     508            1 :         const row = rowRef.current;
     509            1 :         if (row == null) return;
     510            1 :         const syncHeight = () => {
     511            1 :             const height = Math.ceil(row.getBoundingClientRect().height);
     512            1 :             if (height > 0) props.onRowHeightChange(props.index, height);
     513            1 :         };
     514            1 :         syncHeight();
     515            1 :         const observer = new ResizeObserver(syncHeight);
     516            1 :         observer.observe(row);
     517            1 :         return () => observer.disconnect();
     518            1 :     }, [props.index, rowScriptId, props.onRowHeightChange]);
     519              : 
     520              :     // The row holds the card and the space below it, the first row also the space above the feed.
     521              :     // The add buttons sit in these spaces, a button between two scripts shows while either one is hovered.
     522            1 :     return (
     523            1 :         <div
     524            1 :             className={styles.feed_row}
     525            1 :             style={{ ...props.style, paddingTop: props.index === 0 ? FEED_TOP_PADDING : undefined }}
     526            1 :             data-testid="script-feed-row"
     527              :         >
     528            1 :             <div
     529            1 :                 ref={setRowRef}
     530            1 :                 className={styles.feed_list_item}
     531            1 :                 data-testid="script-feed-item"
     532            1 :                 data-script-key={entry?.scriptId}
     533            1 :                 style={{
     534              :                     // dnd-kit may scale the active item toward a differently sized target row.
     535              :                     // Feed cards have variable heights, so scaling visibly squashes or stretches them.
     536            1 :                     transform: dependencies.translateTransform(transform),
     537            1 :                     transition,
     538            1 :                     zIndex: isDragging ? 1 : undefined,
     539            1 :                 }}
     540              :             >
     541            1 :                 <button
     542            1 :                     type="button"
     543            1 :                     className={styles.feed_entry_insert}
     544            1 :                     data-edge="above"
     545            1 :                     aria-label={`Add script above ${displayName}`}
     546            1 :                     data-testid="script-insert-above"
     547            1 :                     onClick={() => props.onCreate(entryIndex)}
     548              :                 >
     549            1 :                     <PlusIcon size={14} />
     550            1 :                 </button>
     551            1 :                 <button
     552            1 :                     type="button"
     553            1 :                     className={styles.feed_entry_drag_handle}
     554            1 :                     aria-label={`Drag ${displayName} script to reorder`}
     555            1 :                     data-testid="script-drag-handle"
     556            1 :                     {...attributes}
     557            1 :                     {...listeners}
     558              :                 >
     559            1 :                     <DragHandleIcon size={16} />
     560            1 :                 </button>
     561            1 :                 <ScriptCard
     562            1 :                     key={entry?.scriptId}
     563            1 :                     sessionId={props.sessionId}
     564            1 :                     connectionId={scriptData?.connection.connectionId ?? null}
     565            1 :                     connectionHealth={connection?.connectionHealth ?? null}
     566            1 :                     connectionDetails={connection?.details ?? null}
     567            1 :                     connectorType={scriptData?.connection.connectorInfo.connectorType ?? ConnectorType.HYPER}
     568            1 :                     connection={connection}
     569            1 :                     computeCacheKey={props.computeCacheKey}
     570            1 :                     lookupCachedResult={props.lookupCachedResult}
     571            1 :                     storageReader={props.storageReader}
     572            1 :                     isFocused={scriptFileName === props.focusedFileName}
     573            1 :                     scriptData={scriptData}
     574            1 :                     scriptFileName={scriptFileName}
     575            1 :                     scriptDebugMode={props.scriptDebugMode}
     576            1 :                     formattingDebugMode={props.formattingDebugMode}
     577            1 :                     keywordCase={props.keywordCase}
     578            1 :                     canExecute={scriptData != null && connection?.connectionHealth === ConnectionHealth.ONLINE}
     579            1 :                     active={props.active}
     580            1 :                     canMoveUp={canMoveUp}
     581            1 :                     canMoveDown={canMoveDown}
     582            1 :                     index={entryIndex}
     583            1 :                     onCreate={props.onCreate}
     584            1 :                     onFocus={props.onFocus}
     585            1 :                     onDelete={props.onDelete}
     586            1 :                     onRename={props.onRename}
     587            1 :                     onPickConnection={props.onPickConnection}
     588            1 :                     onMoveUp={props.onMoveUp}
     589            1 :                     onMoveDown={props.onMoveDown}
     590            1 :                     onExecute={props.onExecute}
     591            1 :                     onRerun={props.onRerun}
     592            1 :                     onFormat={props.onFormat}
     593            1 :                     onApplySnippet={props.onApplySnippet}
     594            1 :                     onSetVisSpec={props.onSetVisSpec}
     595            1 :                     canTarget={props.canTarget}
     596            1 :                     scriptReferences={scriptData != null ? props.scriptReferences?.get(scriptData.scriptKey) ?? null : null}
     597            1 :                     onTarget={props.onTarget}
     598            1 :                     onAcceptDiff={props.onAcceptDiff}
     599            1 :                     onRejectDiff={props.onRejectDiff}
     600            1 :                     resultExpanded={scriptData == null || !props.collapsedResults.has(scriptData.scriptKey)}
     601            1 :                     onToggleResultExpanded={props.onToggleResultExpanded}
     602            1 :                     onAutoCollapseResult={props.onAutoCollapseResult}
     603            1 :                     onResetAutoCollapsedResult={props.onResetAutoCollapsedResult}
     604            1 :                     onEditorView={props.onEditorView}
     605            1 :                     dependencies={dependencies.scriptCard}
     606            1 :                 />
     607            1 :                 <button
     608            1 :                     type="button"
     609            1 :                     className={styles.feed_entry_insert}
     610            1 :                     data-edge="below"
     611            1 :                     aria-label={`Add script below ${displayName}`}
     612            1 :                     data-testid="script-insert-below"
     613            1 :                     onClick={() => props.onCreate(entryIndex + 1)}
     614              :                 >
     615            1 :                     <PlusIcon size={14} />
     616            1 :                 </button>
     617            1 :             </div>
     618            1 :         </div>
     619              :     );
     620            1 : }
        

Generated by: LCOV version 2.0-1