LCOV - code coverage report
Current view: top level - dashql-app/src/session - session_workbench_sidebar.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 86.7 % 1010 876
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import symbols from '@ankoh/dashql-svg-symbols';
       3              : import {
       4              :     type CollisionDetection,
       5              :     DndContext,
       6              :     type Modifier,
       7              :     type DragEndEvent,
       8              :     type DragMoveEvent,
       9              :     KeyboardSensor,
      10              :     PointerSensor,
      11              :     closestCenter,
      12              :     pointerWithin,
      13              :     useDroppable,
      14              :     useSensor,
      15              :     useSensors,
      16              : } from '@dnd-kit/core';
      17              : import {
      18              :     SortableContext,
      19              :     type SortingStrategy,
      20              :     sortableKeyboardCoordinates,
      21              :     useSortable,
      22              : } from '@dnd-kit/sortable';
      23              : import { CSS } from '@dnd-kit/utilities';
      24              : 
      25              : import { useComputationDispatch } from '../compute/computation_registry.js';
      26              : import { DELETE_COMPUTATION } from '../compute/computation_state.js';
      27              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
      28              : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
      29              : import * as ActionList from '../ui/foundations/action_list.js';
      30              : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
      31              : import {
      32              :     AlertIcon,
      33              :     ChevronDownIcon,
      34              :     ChevronRightIcon,
      35              :     CopyIcon,
      36              :     FileIcon,
      37              :     FileDirectoryIcon,
      38              :     FileDirectoryPlusIcon,
      39              :     LinkIcon,
      40              :     PencilIcon,
      41              :     PlusIcon,
      42              :     StackIcon,
      43              :     StackPlusIcon,
      44              :     TrashIcon,
      45              :     SymbolIcon,
      46              :     UnlinkIcon,
      47              :     UploadIcon,
      48              : } from '../ui/foundations/symbol_icon.js';
      49              : import { classNames } from '../utils/classnames.js';
      50              : import { OPEN_SESSION, SELECT_SESSION, useRouteContext, useRouterNavigate } from '../app/router/router.js';
      51              : import { SessionSetupStatus } from '../app/loading/session_setup_status.js';
      52              : import { ConnectionState, RENAME_CONNECTION } from '../connections/connection_state.js';
      53              : import {
      54              :     findSessionQueries,
      55              :     useConnectionReferences,
      56              :     useConnectionRegistry,
      57              :     useConnectionAllocator,
      58              :     useSessionConnections,
      59              :     useDynamicConnectionDispatch,
      60              : } from '../connections/connection_registry.js';
      61              : import {
      62              :     createConnectionStateFromParams,
      63              :     createAttachedConnectionParamsForConnector,
      64              : } from '../connections/connection_params.js';
      65              : import { useNewSessionConnection } from '../connections/embedded_connection.js';
      66              : import { ATTACHABLE_CONNECTORS } from '../connections/attached_connection.js';
      67              : import { CONNECTOR_INFOS, ConnectorType } from '../connections/connector_info.js';
      68              : import { ConnectionSettingsOverlay } from '../connections/connection_settings_overlay.js';
      69              : import { renderSessionName } from './session_name.js';
      70              : import { SessionConnectionAttentionContext } from './session_connection_attention.js';
      71              : import { VerticalScrollShadows } from '../ui/foundations/vertical_scroll_shadows.js';
      72              : import { VerticalSplit } from '../ui/foundations/vertical_split.js';
      73              : import { displayPath } from '../storage/session_locator.js';
      74              : import { StorageBackendType } from '../storage/storage_backend.js';
      75              : import { SessionStorageOverlay } from './persistence/session_storage_overlay.js';
      76              : import { useStorageReader, useStorageWriter } from '../storage/storage_provider.js';
      77              : import { readSessionBundleFromBrowserFolder } from './persistence/browser_session_folder.js';
      78              : import { cloneSession } from '../storage/session_transfer.js';
      79              : import {
      80              :     mergeRestoredSessionIntoConnections,
      81              :     mergeRestoredSessionIntoScripts,
      82              :     restoreSingleSession,
      83              : } from './persistence/app_state_loader.js';
      84              : import { useSessionImport } from './persistence/session_import_provider.js';
      85              : import { describeSessionValidationError } from '../storage/session_validation.js';
      86              : import { EPHEMERAL_SESSION_NAME, isEphemeralSession, scriptConnectionOf, scriptsOnConnection, SELECT_SCRIPT, SET_PENDING_CONNECTION, type SessionScripts } from './scripts/session_scripts.js';
      87              : import { scriptDisplayName } from './scripts/script_types.js';
      88              : import { renameScript } from './scripts/script_rename.js';
      89              : import {
      90              :     useInvalidSessions,
      91              :     useSessionScripts,
      92              :     useSessionScriptsDeletion,
      93              :     useSessionScriptsRegistry,
      94              : } from './scripts/session_scripts_registry.js';
      95              : import { useEnsureEphemeralSession, useOpenGreeter, useSessionCreation } from './session_creation.js';
      96              : import { SESSION_LIST_CARET_COLUMN, SessionFolderRow, sessionListRowPadding, startsRowDrag } from './session_folder_row.js';
      97              : import {
      98              :     type SessionListDrop,
      99              :     type SessionListRow as SessionListRowOf,
     100              :     TOP_LEVEL_DROP_ID,
     101              :     flattenSessionListRows,
     102              :     resolveSessionListDrop,
     103              : } from './session_list_rows.js';
     104              : import {
     105              :     type SessionTreeFolder,
     106              :     findSessionFolder,
     107              :     flattenSessionTree,
     108              :     folderAt,
     109              :     folderNameError,
     110              :     folderPathKey,
     111              :     freeFolderName,
     112              :     moveSessionInTree,
     113              :     upsertSessionInTree,
     114              : } from '../storage/session_tree.js';
     115              : import { RENAME_SESSION } from './scripts/session_scripts.js';
     116              : import { NameInput } from '../ui/foundations/name_input.js';
     117              : import { type RowContextMenu, useRowContextMenu, useRowContextMenuOpener } from '../ui/foundations/row_context_menu.js';
     118              : import { useMergeConnections } from './session_connection_merge.js';
     119              : import { useReplaceConnection } from './session_connection_replace.js';
     120              : import { useDetachConnection } from './session_connection_detach.js';
     121              : import { useDeleteConnection } from '../connections/connection_deletion.js';
     122              : import { connectionMergeCandidates } from '../connections/connection_merge.js';
     123              : import { connectionReplaceBlocker, connectionReplaceCandidates } from '../connections/connection_replace.js';
     124              : import { connectionLabelParts, connectionLabelText, connectionOrdinals, isHyperWasmConnection } from '../connections/connection_label.js';
     125              : import { useAppFlavor } from '../config/flavor_provider.js';
     126              : import { SessionURLShareOverlay } from './persistence/session_url_share_overlay.js';
     127              : import { SessionFileSaveOverlay } from './persistence/session_file_save_overlay.js';
     128              : import * as styles from './session_workbench_sidebar.module.css';
     129              : import { ConnectionTree, isConnectionInitiallyExpanded, listedConnections, type ConnectionUsage } from '../connections/connection_tree.js';
     130              : import * as actionMenuStyles from '../app/action_menu.module.css';
     131              : import { useDashQLCoreSetup } from '../runtime/core_provider.js';
     132              : import { useLogger } from '../platform/logger/logger_provider.js';
     133              : import { PlatformType, usePlatformType } from '../platform/platform_type.js';
     134              : import { CONNECTOR_ICON_STYLE, useConnectorIcon } from '../connections/connector_icon.js';
     135              : 
     136              : interface SessionItem {
     137              :     sessionId: string;
     138              :     /// The connections of the session, in the order its scripts use them
     139              :     connections: ConnectionState[];
     140              :     scripts: SessionScripts;
     141              :     label: string;
     142              :     path: string;
     143              :     isNative: boolean;
     144              : }
     145              : 
     146              : interface InvalidSessionItem {
     147              :     sessionId: string;
     148              :     label: string;
     149              :     path: string;
     150              :     isNative: boolean;
     151              :     invalidReason: string;
     152              : }
     153              : 
     154              : type SessionListRow = SessionListRowOf<SessionItem>;
     155              : 
     156              : /// The rows stay in place while a row is dragged, the drop target shows where the row lands
     157            1 : const KEEP_ROWS_IN_PLACE: SortingStrategy = () => null;
     158              : 
     159              : /// A dragged row only moves up and down
     160            1 : const VERTICAL_DRAG: Modifier = ({ transform }) => ({ ...transform, x: 0 });
     161              : 
     162              : /// The slim bar between the sessions and the connections
     163            1 : const SECTION_SEPARATOR_SIZE = 7;
     164              : /// The header and about two rows stay visible in a section
     165            1 : const SECTION_MIN_SIZE = 96;
     166              : 
     167              : /// The drop target is the row under the pointer, the keyboard picks the closest row
     168            1 : const SESSION_LIST_COLLISIONS: CollisionDetection = args => {
     169            0 :     const hits = pointerWithin(args);
     170            0 :     return hits.length > 0 ? hits : closestCenter(args);
     171            0 : };
     172              : 
     173              : /// The drop target at the end of the session list. It fills the empty space below the rows and moves a row to the
     174              : /// end of the top level, a targeted drop shows the insertion line at the end of the list.
     175            1 : const TopLevelDropZone: React.FC<{ targeted: boolean }> = ({ targeted }) => {
     176            1 :     const { setNodeRef } = useDroppable({ id: TOP_LEVEL_DROP_ID });
     177            1 :     return (
     178            1 :         <li
     179            1 :             ref={setNodeRef}
     180            1 :             className={classNames(styles.top_level_drop, { [styles.top_level_drop_targeted]: targeted })}
     181            1 :             aria-hidden="true"
     182            1 :             data-testid="session-top-level-drop"
     183            1 :         />
     184              :     );
     185            1 : };
     186              : 
     187              : /// The row of the ephemeral session, the last session of the root. It looks like the other sessions and only opens the
     188              : /// greeter: there is nothing to rename, move or delete until a script promotes the session.
     189            1 : const EphemeralSessionRow: React.FC<{ selected: boolean; onOpen: () => void }> = ({ selected, onOpen }) => (
     190            1 :     <li
     191            1 :         className={classNames(styles.entry_surface, styles.entry_interactive, styles.session_row, {
     192            1 :             [styles.entry_selected]: selected,
     193            1 :         })}
     194            1 :         style={{ paddingLeft: sessionListRowPadding(0) }}
     195            1 :         data-testid="session-ephemeral-row"
     196              :     >
     197            1 :         <button type="button" className={styles.tree_button} aria-current={selected ? 'page' : undefined} onClick={onOpen}>
     198            1 :             <span aria-hidden="true" />
     199            1 :             <StackIcon size={14} aria-hidden="true" />
     200            1 :             <span className={classNames(styles.session_name, styles.session_name_ephemeral)}>{EPHEMERAL_SESSION_NAME}</span>
     201            1 :         </button>
     202            1 :     </li>
     203              : );
     204              : 
     205              : interface Props {
     206              :     sessionScripts: SessionScripts;
     207              :     closeAfterSelection?: () => void;
     208              :     /// The connection whose overview is open
     209              :     openOverviewId?: string | null;
     210              :     /// Open the overview of a connection, or close it with null
     211              :     onOpenOverview?: (connectionId: string | null) => void;
     212              :     /// Show a script of a session that was opened from the sidebar
     213              :     onOpenScript?: (sessionId: string, fileName: string) => void;
     214              :     dependencies?: SessionWorkbenchSidebarDependencies;
     215              : }
     216              : 
     217              : export interface SessionWorkbenchSidebarDependencies {
     218              :     useRouterNavigate: typeof useRouterNavigate;
     219              :     useRouteContext: typeof useRouteContext;
     220              :     useDynamicConnectionDispatch: typeof useDynamicConnectionDispatch;
     221              :     useSessionScriptsRegistry: typeof useSessionScriptsRegistry;
     222              :     useSessionScripts: typeof useSessionScripts;
     223              :     useConnectionRegistry: typeof useConnectionRegistry;
     224              :     useConnectionReferences: typeof useConnectionReferences;
     225              :     useConnectionAllocator: typeof useConnectionAllocator;
     226              :     useSessionCreation: typeof useSessionCreation;
     227              :     useOpenGreeter: typeof useOpenGreeter;
     228              :     useEnsureEphemeralSession: typeof useEnsureEphemeralSession;
     229              :     useMergeConnections: typeof useMergeConnections;
     230              :     useReplaceConnection: typeof useReplaceConnection;
     231              :     useDetachConnection: typeof useDetachConnection;
     232              :     useDeleteConnection: typeof useDeleteConnection;
     233              :     useSessionScriptsDeletion: typeof useSessionScriptsDeletion;
     234              :     useComputationDispatch: typeof useComputationDispatch;
     235              :     useStorageReader: typeof useStorageReader;
     236              :     useStorageWriter: typeof useStorageWriter;
     237              :     useDashQLCoreSetup: typeof useDashQLCoreSetup;
     238              :     useSessionImport: typeof useSessionImport;
     239              :     usePlatformType: typeof usePlatformType;
     240              :     useLogger: typeof useLogger;
     241              :     useInvalidSessions: typeof useInvalidSessions;
     242              :     useSessionConnections: typeof useSessionConnections;
     243              :     useNewSessionConnection: typeof useNewSessionConnection;
     244              :     createConnectionStateFromParams: typeof createConnectionStateFromParams;
     245              :     ConnectionSettingsOverlay: typeof ConnectionSettingsOverlay;
     246              :     SessionStorageOverlay: typeof SessionStorageOverlay;
     247              :     SessionURLShareOverlay: typeof SessionURLShareOverlay;
     248              :     SessionFileSaveOverlay: typeof SessionFileSaveOverlay;
     249              : }
     250              : 
     251            1 : const DEFAULT_SESSION_WORKBENCH_SIDEBAR_DEPENDENCIES: SessionWorkbenchSidebarDependencies = {
     252            1 :     useRouterNavigate,
     253            1 :     useRouteContext,
     254            1 :     useDynamicConnectionDispatch,
     255            1 :     useSessionScriptsRegistry,
     256            1 :     useSessionScripts,
     257            1 :     useConnectionRegistry,
     258            1 :     useConnectionReferences,
     259            1 :     useConnectionAllocator,
     260            1 :     useSessionCreation,
     261            1 :     useOpenGreeter,
     262            1 :     useEnsureEphemeralSession,
     263            1 :     useMergeConnections,
     264            1 :     useReplaceConnection,
     265            1 :     useDetachConnection,
     266            1 :     useDeleteConnection,
     267            1 :     useSessionScriptsDeletion,
     268            1 :     useComputationDispatch,
     269            1 :     useStorageReader,
     270            1 :     useStorageWriter,
     271            1 :     useDashQLCoreSetup,
     272            1 :     useSessionImport,
     273            1 :     usePlatformType,
     274            1 :     useLogger,
     275            1 :     useInvalidSessions,
     276            1 :     useSessionConnections,
     277            1 :     useNewSessionConnection,
     278            1 :     createConnectionStateFromParams,
     279            1 :     ConnectionSettingsOverlay,
     280            1 :     SessionStorageOverlay,
     281            1 :     SessionURLShareOverlay,
     282            1 :     SessionFileSaveOverlay,
     283            1 : };
     284              : 
     285            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
     286            1 : const FileZipIcon = SymbolIcon('file_zip_16');
     287              : 
     288              : interface SessionRowMenuProps {
     289              :     item: SessionItem;
     290              :     onRename: () => void;
     291              :     onDuplicate: () => void;
     292              :     onDelete: () => void;
     293              :     /// The right click of the row opens the menu
     294              :     contextMenu: RowContextMenu;
     295              :     dependencies: SessionWorkbenchSidebarDependencies;
     296              : }
     297              : 
     298            1 : const SessionRowMenu: React.FC<SessionRowMenuProps> = ({ item, onRename, onDuplicate, onDelete, contextMenu, dependencies }) => {
     299            1 :     const [open, setOpen] = React.useState(false);
     300            1 :     useRowContextMenuOpener(contextMenu, () => setOpen(true));
     301            1 :     const [shareOpen, setShareOpen] = React.useState(false);
     302            1 :     const [exportOpen, setExportOpen] = React.useState(false);
     303            1 :     const archiveExport = useAppFlavor().features.sessionArchiveExport;
     304            1 :     const [storageOpen, setStorageOpen] = React.useState(false);
     305            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     306              : 
     307            1 :     return (
     308            1 :         <AnchoredOverlay
     309            1 :             open={open}
     310            1 :             onOpen={() => setOpen(true)}
     311            1 :             onClose={() => setOpen(false)}
     312            1 :             anchorRef={triggerRef}
     313            1 :             returnFocusRef={triggerRef}
     314            1 :             side={AnchorSide.OutsideRight}
     315            1 :             align={AnchorAlignment.Start}
     316            1 :             anchorOffset={4}
     317            1 :             renderAnchor={(anchorProps) => (
     318            1 :                 <IconButton
     319            1 :                     {...anchorProps}
     320            1 :                     ref={triggerRef}
     321            1 :                     size={ButtonSize.Small}
     322            1 :                     variant={ButtonVariant.Invisible}
     323            1 :                     className={styles.session_menu_button}
     324            1 :                     data-testid="session-row-menu"
     325            1 :                     aria-label={`More actions for ${item.label}`}
     326            1 :                     onClick={(event) => {
     327            1 :                         event.stopPropagation();
     328            1 :                         anchorProps.onClick?.(event);
     329            1 :                     }}
     330              :                 >
     331            1 :                     <MoreIcon size={14} />
     332            1 :                 </IconButton>
     333              :             )}
     334              :         >
     335            1 :             <div className={actionMenuStyles.menu} role="dialog" aria-label={`Actions for ${item.label}`}>
     336            1 :                 <ActionList.List aria-label={`Actions for ${item.label}`}>
     337            1 :                     <dependencies.SessionStorageOverlay
     338            1 :                         sessionId={item.sessionId}
     339            1 :                         isOpen={storageOpen}
     340            1 :                         onClose={() => setStorageOpen(false)}
     341            1 :                         side={AnchorSide.OutsideRight}
     342            1 :                         align={AnchorAlignment.Start}
     343            1 :                         renderAnchor={(anchorProps) => (
     344            1 :                             <ActionList.ListItem
     345            1 :                                 {...anchorProps}
     346            1 :                                 data-testid="session-menu-item"
     347            1 :                                 data-action="storage"
     348            1 :                                 onClick={(event) => {
     349            1 :                                     event.stopPropagation();
     350            1 :                                     setStorageOpen(true);
     351            1 :                                 }}
     352              :                             >
     353            1 :                                 <ActionList.Leading><FileDirectoryIcon size={16} /></ActionList.Leading>
     354            1 :                                 <ActionList.ItemText>Storage</ActionList.ItemText>
     355            1 :                             </ActionList.ListItem>
     356              :                         )}
     357            1 :                     />
     358            1 :                     <ActionList.ListItem data-testid="session-menu-item" data-action="rename" onClick={(event) => {
     359            1 :                         event.stopPropagation();
     360            1 :                         setOpen(false);
     361            1 :                         onRename();
     362            1 :                     }}>
     363            1 :                         <ActionList.Leading><PencilIcon size={16} /></ActionList.Leading>
     364            1 :                         <ActionList.ItemText>Rename</ActionList.ItemText>
     365            1 :                     </ActionList.ListItem>
     366            1 :                     <ActionList.ListItem data-testid="session-menu-item" data-action="duplicate" onClick={() => { setOpen(false); onDuplicate(); }}>
     367            1 :                         <ActionList.Leading><CopyIcon size={16} /></ActionList.Leading>
     368            1 :                         <ActionList.ItemText>Duplicate</ActionList.ItemText>
     369            1 :                     </ActionList.ListItem>
     370            1 :                     <ActionList.ListItem data-testid="session-menu-item" data-action="share-url" onClick={(event) => {
     371            1 :                         event.stopPropagation();
     372            1 :                         setShareOpen(value => !value);
     373            1 :                     }}>
     374            1 :                         <ActionList.Leading><LinkIcon size={16} /></ActionList.Leading>
     375            1 :                         <ActionList.ItemText>
     376              :                             Share as URL
     377            1 :                             <dependencies.SessionURLShareOverlay
     378            1 :                                 sessionId={item.sessionId}
     379            1 :                                 isOpen={shareOpen}
     380            1 :                                 setIsOpen={setShareOpen}
     381            1 :                             />
     382            1 :                         </ActionList.ItemText>
     383            1 :                     </ActionList.ListItem>
     384              :                     {/* The list takes elements only, the fragment wraps the optional item */}
     385            1 :                     <>{archiveExport && (
     386            1 :                         <ActionList.ListItem data-testid="session-menu-item" data-action="export" onClick={(event) => {
     387            1 :                             event.stopPropagation();
     388            1 :                             setExportOpen(value => !value);
     389            1 :                         }}>
     390            1 :                             <ActionList.Leading><FileZipIcon size={16} /></ActionList.Leading>
     391            1 :                             <ActionList.ItemText>
     392              :                                 Export .dashql
     393            1 :                                 <dependencies.SessionFileSaveOverlay
     394            1 :                                     isOpen={exportOpen}
     395            1 :                                     setIsOpen={setExportOpen}
     396            1 :                                     sessionScripts={item.scripts}
     397            1 :                                     fileName={item.scripts.name ?? item.scripts.sessionMetadata.originalFileName ?? 'session'}
     398            1 :                                 />
     399            1 :                             </ActionList.ItemText>
     400            1 :                         </ActionList.ListItem>
     401            1 :                     )}</>
     402            1 :                     <ActionList.ListItem data-testid="session-menu-item" data-action="delete" className={actionMenuStyles.delete_action} onClick={() => { setOpen(false); onDelete(); }}>
     403            1 :                         <ActionList.Leading>{item.isNative ? <UnlinkIcon size={16} /> : <TrashIcon size={16} />}</ActionList.Leading>
     404            1 :                         <ActionList.ItemText>{item.isNative ? 'Unlink' : 'Delete'}</ActionList.ItemText>
     405            1 :                     </ActionList.ListItem>
     406            1 :                 </ActionList.List>
     407            1 :             </div>
     408            1 :         </AnchoredOverlay>
     409              :     );
     410            1 : };
     411              : 
     412              : interface SessionRowProps {
     413              :     item: SessionItem;
     414              :     /// The depth of the session in the session tree, the sessions of the root are at depth 0
     415              :     depth: number;
     416              :     selected: boolean;
     417              :     onOpen: (anchor: HTMLButtonElement) => void;
     418              :     onDuplicate: () => void;
     419              :     onDelete: () => void;
     420              :     /// Where a dragged session lands next to this one, null without a drag
     421              :     dropPosition: 'before' | 'after' | null;
     422              :     /// Are the scripts of the session listed below it?
     423              :     expanded: boolean;
     424              :     /// Show or hide the scripts of the session
     425              :     onToggle: () => void;
     426              :     /// Open a script of the session
     427              :     onOpenScript: (fileName: string) => void;
     428              :     dependencies: SessionWorkbenchSidebarDependencies;
     429              : }
     430              : 
     431            1 : const SessionRow: React.FC<SessionRowProps> = ({ item, depth, selected, onOpen, onDuplicate, onDelete, dropPosition, expanded, onToggle, onOpenScript, dependencies }) => {
     432            1 :     const title = item.scripts.name?.trim() || null;
     433            1 :     const [, modifySessionScripts] = dependencies.useSessionScripts(item.sessionId);
     434              :     // The rename action swaps the name for an input until the edit is committed or abandoned
     435            1 :     const [renaming, setRenaming] = React.useState(false);
     436            1 :     const renameInputRef = React.useRef<HTMLInputElement>(null);
     437            1 :     React.useEffect(() => {
     438            1 :         if (!renaming) return;
     439            1 :         renameInputRef.current?.focus();
     440            1 :         renameInputRef.current?.select();
     441            1 :     }, [renaming]);
     442            1 :     const commitRename = React.useCallback((name: string) => {
     443            1 :         setRenaming(false);
     444            1 :         void modifySessionScripts({ type: RENAME_SESSION, value: name });
     445            1 :     }, [modifySessionScripts]);
     446            1 :     const cancelRename = React.useCallback(() => setRenaming(false), []);
     447            1 :     const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: item.sessionId });
     448            1 :     const rowRef = React.useCallback((node: HTMLLIElement | null) => {
     449            1 :         setNodeRef(node);
     450              :         // The keyboard sensor only lifts the row when the row itself has the focus
     451            1 :         setActivatorNodeRef(node);
     452            1 :     }, [setNodeRef, setActivatorNodeRef]);
     453            1 :     const rowListeners = React.useMemo(() => listeners == null ? undefined : Object.fromEntries(
     454            1 :         Object.entries(listeners).map(([eventName, handler]) => [eventName, (event: React.SyntheticEvent<HTMLElement>) => {
     455            1 :             if (startsRowDrag(event)) handler(event);
     456            1 :         }]),
     457            1 :     ), [listeners]);
     458            1 :     const contextMenu = useRowContextMenu();
     459            1 :     const scriptNames = item.scripts.scriptOrder ?? [];
     460            1 :     const openScript = (fileName: string) => {
     461            1 :         void modifySessionScripts({ type: SELECT_SCRIPT, value: fileName });
     462            1 :         onOpenScript(fileName);
     463            1 :     };
     464            1 :     const Caret = expanded ? ChevronDownIcon : ChevronRightIcon;
     465            1 :     const caret = scriptNames.length > 0
     466            1 :         ? <span className={styles.tree_caret} aria-hidden="true"><Caret size={12} /></span>
     467            1 :         : <span aria-hidden="true" />;
     468              :     // A click opens the session and lists its scripts, a click on the open session shows or hides them like a folder.
     469              :     // Renaming the session is in the row menu.
     470            1 :     const clickName = (anchor: HTMLButtonElement) => {
     471            1 :         if (!selected) {
     472            1 :             onOpen(anchor);
     473            1 :             if (!expanded && scriptNames.length > 0) onToggle();
     474            1 :         } else if (scriptNames.length > 0) {
     475            1 :             onToggle();
     476              :         }
     477            1 :     };
     478            1 :     return (
     479            1 :         <>
     480            1 :         <li
     481            1 :             ref={rowRef}
     482            1 :             className={classNames(
     483            1 :                 styles.entry_surface,
     484            1 :                 styles.entry_interactive,
     485            1 :                 styles.session_row,
     486            1 :                 {
     487            1 :                     [styles.entry_selected]: selected,
     488            1 :                     [styles.session_row_dragging]: isDragging,
     489            1 :                     [styles.drop_before]: dropPosition === 'before',
     490            1 :                     [styles.drop_after]: dropPosition === 'after',
     491            1 :                 },
     492            1 :             )}
     493            1 :             style={{
     494            1 :                 transform: CSS.Translate.toString(transform),
     495            1 :                 transition,
     496            1 :                 paddingLeft: sessionListRowPadding(depth),
     497              :                 // The insertion line of a drop starts at the icon of the session
     498            1 :                 ['--session_row_indent' as string]: `${sessionListRowPadding(depth) + SESSION_LIST_CARET_COLUMN}px`,
     499            1 :             }}
     500            1 :             {...attributes}
     501            1 :             {...rowListeners}
     502            1 :             onContextMenu={contextMenu.onContextMenu}
     503              :         >
     504            1 :             {renaming ? (
     505            1 :                 <div className={styles.tree_rename}>
     506            1 :                     {caret}
     507            1 :                     <StackIcon size={14} aria-hidden="true" />
     508            1 :                     <NameInput
     509            1 :                         ref={renameInputRef}
     510            1 :                         className={styles.session_rename_input}
     511            1 :                         name={title}
     512            1 :                         onCommit={commitRename}
     513            1 :                         onCancel={cancelRename}
     514            1 :                         ariaLabel="Session name"
     515            1 :                         placeholder="Name this session"
     516            1 :                     />
     517            1 :                 </div>
     518              :             ) : (
     519            1 :                 <button
     520            1 :                     type="button"
     521            1 :                     className={styles.tree_button}
     522            1 :                     aria-current={selected ? 'page' : undefined}
     523            1 :                     aria-expanded={scriptNames.length > 0 ? expanded : undefined}
     524            1 :                     title={item.path}
     525            1 :                     onClick={event => clickName(event.currentTarget)}
     526              :                 >
     527            1 :                     {caret}
     528            1 :                     <StackIcon size={14} aria-hidden="true" />
     529            1 :                     <span className={title ? styles.session_name : styles.session_path}>{title ?? item.path}</span>
     530            1 :                 </button>
     531              :             )}
     532            1 :             <SessionRowMenu item={item} onRename={() => setRenaming(true)} onDuplicate={onDuplicate} onDelete={onDelete} contextMenu={contextMenu} dependencies={dependencies} />
     533            1 :         </li>
     534            1 :         {expanded && scriptNames.map(fileName => (
     535            1 :             <SessionScriptRow
     536            1 :                 key={`${item.sessionId}/${fileName}`}
     537            1 :                 fileName={fileName}
     538            1 :                 depth={depth}
     539            1 :                 onOpen={() => openScript(fileName)}
     540            1 :                 onRename={name => { void renameScript(item.scripts, modifySessionScripts, fileName, name, null); }}
     541            1 :             />
     542            1 :         ))}
     543            1 :         </>
     544              :     );
     545            1 : };
     546              : 
     547              : interface SessionScriptRowProps {
     548              :     fileName: string;
     549              :     /// The depth of the session of the script
     550              :     depth: number;
     551              :     /// Open the script
     552              :     onOpen: () => void;
     553              :     /// Rename the script, the name is a display name
     554              :     onRename: (name: string) => void;
     555              : }
     556              : 
     557              : /// A script of an expanded session. A double-click on the name or the rename action of the menu edits the name.
     558            1 : const SessionScriptRow: React.FC<SessionScriptRowProps> = ({ fileName, depth, onOpen, onRename }) => {
     559            1 :     const displayName = scriptDisplayName(fileName);
     560            1 :     const [renaming, setRenaming] = React.useState(false);
     561            1 :     const renameInputRef = React.useRef<HTMLInputElement>(null);
     562            1 :     React.useEffect(() => {
     563            1 :         if (!renaming) return;
     564            1 :         renameInputRef.current?.focus();
     565            1 :         renameInputRef.current?.select();
     566            1 :     }, [renaming]);
     567            1 :     const commitRename = React.useCallback((name: string) => {
     568            1 :         setRenaming(false);
     569            1 :         if (name.trim() !== displayName) onRename(name);
     570            1 :     }, [displayName, onRename]);
     571            1 :     const cancelRename = React.useCallback(() => setRenaming(false), []);
     572            1 :     const contextMenu = useRowContextMenu();
     573            1 :     return (
     574            1 :         <li
     575            1 :             className={classNames(styles.entry_surface, styles.entry_interactive, styles.script_row)}
     576              :             // The file icons of the scripts sit directly below the icon of their session
     577            1 :             style={{ paddingLeft: sessionListRowPadding(depth) + SESSION_LIST_CARET_COLUMN }}
     578            1 :             data-testid="session-script-row"
     579            1 :             onContextMenu={contextMenu.onContextMenu}
     580              :         >
     581            1 :             {renaming ? (
     582            1 :                 <div className={styles.script_rename}>
     583            1 :                     <FileIcon size={14} aria-hidden="true" />
     584            1 :                     <NameInput
     585            1 :                         ref={renameInputRef}
     586            1 :                         className={classNames(styles.session_rename_input, styles.script_rename_input)}
     587            1 :                         name={displayName}
     588            1 :                         onCommit={commitRename}
     589            1 :                         onCancel={cancelRename}
     590            1 :                         ariaLabel="Script name"
     591            1 :                         placeholder="Name this script"
     592            1 :                     />
     593            1 :                 </div>
     594              :             ) : (
     595            1 :                 <button
     596            1 :                     type="button"
     597            1 :                     className={styles.session_button}
     598            1 :                     title={fileName}
     599            1 :                     onClick={onOpen}
     600            1 :                     onDoubleClick={() => setRenaming(true)}
     601              :                 >
     602            1 :                     <FileIcon size={14} aria-hidden="true" />
     603            1 :                     <span className={styles.script_name}>{displayName}</span>
     604            1 :                 </button>
     605              :             )}
     606            1 :             <ScriptRowMenu name={displayName} onRename={() => setRenaming(true)} contextMenu={contextMenu} />
     607            1 :         </li>
     608              :     );
     609            1 : };
     610              : 
     611            1 : const ScriptRowMenu: React.FC<{ name: string; onRename: () => void; contextMenu: RowContextMenu }> = ({ name, onRename, contextMenu }) => {
     612            1 :     const [open, setOpen] = React.useState(false);
     613            1 :     useRowContextMenuOpener(contextMenu, () => setOpen(true));
     614            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     615            1 :     return (
     616            1 :         <AnchoredOverlay
     617            1 :             open={open}
     618            1 :             onOpen={() => setOpen(true)}
     619            1 :             onClose={() => setOpen(false)}
     620            1 :             anchorRef={triggerRef}
     621            1 :             returnFocusRef={triggerRef}
     622            1 :             side={AnchorSide.OutsideRight}
     623            1 :             align={AnchorAlignment.Start}
     624            1 :             anchorOffset={4}
     625            1 :             renderAnchor={(anchorProps) => (
     626            1 :                 <IconButton
     627            1 :                     {...anchorProps}
     628            1 :                     ref={triggerRef}
     629            1 :                     size={ButtonSize.Small}
     630            1 :                     variant={ButtonVariant.Invisible}
     631            1 :                     className={styles.session_menu_button}
     632            1 :                     data-testid="script-row-menu"
     633            1 :                     aria-label={`More actions for script ${name}`}
     634            1 :                     onClick={(event) => {
     635            1 :                         event.stopPropagation();
     636            1 :                         anchorProps.onClick?.(event);
     637            1 :                     }}
     638              :                 >
     639            1 :                     <MoreIcon size={14} />
     640            1 :                 </IconButton>
     641              :             )}
     642              :         >
     643            1 :             <div className={actionMenuStyles.menu} role="dialog" aria-label={`Actions for script ${name}`}>
     644            1 :                 <ActionList.List aria-label={`Actions for script ${name}`}>
     645            1 :                     <ActionList.ListItem data-testid="script-menu-item" data-action="rename" onClick={(event) => {
     646            1 :                         event.stopPropagation();
     647            1 :                         setOpen(false);
     648            1 :                         onRename();
     649            1 :                     }}>
     650            1 :                         <ActionList.Leading><PencilIcon size={16} /></ActionList.Leading>
     651            1 :                         <ActionList.ItemText>Rename</ActionList.ItemText>
     652            1 :                     </ActionList.ListItem>
     653            1 :                 </ActionList.List>
     654            1 :             </div>
     655            1 :         </AnchoredOverlay>
     656              :     );
     657            1 : };
     658              : 
     659            1 : const InvalidSessionRow: React.FC<{ item: InvalidSessionItem; onDelete: () => void }> = ({ item, onDelete }) => (
     660            0 :     <li className={classNames(styles.entry_surface, styles.session_row)}>
     661            0 :         <div
     662            0 :             className={classNames(styles.session_button, styles.session_button_invalid)}
     663            0 :             title={item.invalidReason}
     664              :         >
     665            0 :             <AlertIcon size={14} aria-hidden="true" />
     666            0 :             <span>
     667            0 :                 <span className={styles.session_name}>{item.label}</span>
     668            0 :                 <span className={styles.session_error}>{item.invalidReason}</span>
     669            0 :             </span>
     670            0 :         </div>
     671            0 :         <IconButton
     672            0 :             size={ButtonSize.Small}
     673            0 :             variant={ButtonVariant.Invisible}
     674            0 :             className={styles.session_menu_button}
     675            0 :             aria-label={`${item.isNative ? 'Unlink' : 'Delete'} invalid session ${item.label}`}
     676            0 :             onClick={onDelete}
     677              :         >
     678            0 :             {item.isNative ? <UnlinkIcon size={14} /> : <TrashIcon size={14} />}
     679            0 :         </IconButton>
     680            0 :     </li>
     681              : );
     682              : 
     683            1 : export const SessionWorkbenchSidebar: React.FC<Props> = (props) => {
     684            1 :     const connectorIcon = useConnectorIcon();
     685            1 :     const dependencies = props.dependencies ?? DEFAULT_SESSION_WORKBENCH_SIDEBAR_DEPENDENCIES;
     686            1 :     const navigate = dependencies.useRouterNavigate();
     687            1 :     const route = dependencies.useRouteContext();
     688            1 :     const [databaseRegistry, connectionDispatch] = dependencies.useDynamicConnectionDispatch();
     689            1 :     const [scriptsRegistry, setScriptsRegistry] = dependencies.useSessionScriptsRegistry();
     690            1 :     const [, setDatabaseRegistry] = dependencies.useConnectionRegistry();
     691            1 :     const references = dependencies.useConnectionReferences();
     692            1 :     const allocateConnection = dependencies.useConnectionAllocator();
     693            1 :     const createSession = dependencies.useSessionCreation();
     694            1 :     const openGreeterPage = dependencies.useOpenGreeter();
     695            1 :     const ensureEphemeralSession = dependencies.useEnsureEphemeralSession();
     696            1 :     const resolveNewSessionConnection = dependencies.useNewSessionConnection();
     697            1 :     const mergeConnections = dependencies.useMergeConnections();
     698            1 :     const replaceConnection = dependencies.useReplaceConnection();
     699            1 :     const detachConnectionFromSessions = dependencies.useDetachConnection();
     700            1 :     const [, modifyCurrentSession] = dependencies.useSessionScripts(props.sessionScripts.sessionId);
     701            1 :     const shutDownAndDeleteConnection = dependencies.useDeleteConnection();
     702            1 :     const deleteSessionScripts = dependencies.useSessionScriptsDeletion();
     703            1 :     const computationDispatch = dependencies.useComputationDispatch();
     704            1 :     const storageReader = dependencies.useStorageReader();
     705            1 :     const storageWriter = dependencies.useStorageWriter();
     706            1 :     const setupCore = dependencies.useDashQLCoreSetup();
     707            1 :     const sessionImport = dependencies.useSessionImport();
     708            1 :     const platform = dependencies.usePlatformType();
     709            1 :     const logger = dependencies.useLogger();
     710            1 :     const { invalidSessions, deleteInvalidSession } = dependencies.useInvalidSessions();
     711            1 :     const attached = dependencies.useSessionConnections(props.sessionScripts.sessionId);
     712            1 :     const connectionAttention = React.useContext(SessionConnectionAttentionContext);
     713            1 :     const sessionConnectionRows = React.useMemo(() => attached ?? [], [attached]);
     714            1 :     const [connectionOverlayDatabaseId, setConnectionOverlayDatabaseId] = React.useState<string | null>(null);
     715            1 :     const [createConnectionOpen, setCreateConnectionOpen] = React.useState(false);
     716            1 :     const createConnectionButtonRef = React.useRef<HTMLButtonElement>(null);
     717            1 :     const createButtonRef = React.useRef<HTMLButtonElement>(null);
     718            1 :     const connectionSettingsAnchorRef = React.useRef<HTMLButtonElement>(null);
     719            1 :     const folderButtonRef = React.useRef<HTMLButtonElement>(null);
     720            1 :     const folderInputRef = React.useRef<HTMLInputElement>(null);
     721              :     // The folders and sessions of the sidebar, a change shows right away and is reconciled with the storage after its write
     722            1 :     const [sessionTree, setSessionTree] = React.useState(() => storageReader.getSessionTree());
     723            1 :     const treeWriteRef = React.useRef(Promise.resolve());
     724              :     // The collapsed folders, by folder path key
     725            1 :     const [collapsedFolders, setCollapsedFolders] = React.useState<ReadonlySet<string>>(() => new Set());
     726              :     // The sessions whose scripts are listed, all are collapsed at first
     727            1 :     const [expandedSessions, setExpandedSessions] = React.useState<ReadonlySet<string>>(() => new Set());
     728              :     // The folder whose name is being edited, by folder path key
     729            1 :     const [renamingFolder, setRenamingFolder] = React.useState<string | null>(null);
     730              :     // Where would the dragged row land?
     731            1 :     const [drop, setDrop] = React.useState<SessionListDrop | null>(null);
     732              : 
     733            1 :     React.useEffect(() => {
     734            1 :         if (route.sessionSetupStatus !== SessionSetupStatus.CONFIGURING
     735            1 :             || route.sessionId !== props.sessionScripts.sessionId) return;
     736            1 :         const connectionId = references.connectionsOf(route.sessionId)[0] ?? null;
     737            1 :         if (connectionId != null) {
     738            1 :             connectionSettingsAnchorRef.current = document.querySelector('[aria-label="Create session"]');
     739            1 :             setConnectionOverlayDatabaseId(connectionId);
     740            1 :         }
     741            1 :     }, [references, scriptsRegistry, props.sessionScripts.sessionId, route.sessionId, route.sessionSetupStatus]);
     742              : 
     743            1 :     const sessionItems = React.useMemo<Map<string, SessionItem>>(() => {
     744            1 :         const items = new Map<string, SessionItem>();
     745            1 :         for (const [sessionId, scripts] of scriptsRegistry.sessionScriptsMap) {
     746              :             // The ephemeral session has its own row at the end of the list
     747            1 :             if (isEphemeralSession(scripts)) continue;
     748            1 :             const connections = references.connectionsOf(sessionId)
     749            1 :                 .map(id => databaseRegistry.connections.get(id))
     750            1 :                 .filter((connection): connection is ConnectionState => connection != null);
     751            1 :             const location = storageReader.getSessionLocation(sessionId);
     752            1 :             const path = displayPath(sessionId, location);
     753            1 :             items.set(sessionId, {
     754            1 :                 sessionId,
     755            1 :                 connections,
     756            1 :                 scripts,
     757            1 :                 path,
     758            1 :                 label: scripts.name?.trim() || path,
     759            1 :                 isNative: location.type === StorageBackendType.Native,
     760            1 :             });
     761            1 :         }
     762            1 :         return items;
     763            1 :     }, [databaseRegistry, references, scriptsRegistry, storageReader]);
     764              : 
     765              :     // The sessions that the tree doesn't list yet, e.g. new ones before their first write, show where their
     766              :     // first write registers them, at the end of the root
     767            1 :     const displayedTree = React.useMemo<SessionTreeFolder>(() => {
     768            1 :         const listed = new Set(flattenSessionTree(sessionTree).map(entry => entry.path));
     769            1 :         return [...sessionItems.keys()]
     770            1 :             .filter(id => !listed.has(id))
     771            1 :             .reduce((tree, path) => upsertSessionInTree(tree, { path }), sessionTree);
     772            1 :     }, [sessionItems, sessionTree]);
     773              : 
     774              :     // Opening a session expands the folders on the way to it
     775            1 :     const currentSessionFolderKey = folderPathKey(findSessionFolder(displayedTree, props.sessionScripts.sessionId) ?? []);
     776            1 :     React.useEffect(() => {
     777            1 :         const path = JSON.parse(currentSessionFolderKey) as string[];
     778            1 :         setCollapsedFolders(current => {
     779            1 :             const ancestors = path.map((_, index) => folderPathKey(path.slice(0, index + 1))).filter(key => current.has(key));
     780            1 :             if (ancestors.length === 0) return current;
     781            1 :             const next = new Set(current);
     782            1 :             for (const key of ancestors) next.delete(key);
     783            1 :             return next;
     784            1 :         });
     785            1 :     }, [currentSessionFolderKey]);
     786            1 :     const rows = React.useMemo<SessionListRow[]>(() => flattenSessionListRows(
     787            1 :         displayedTree,
     788            1 :         sessionItems,
     789            1 :         folder => !collapsedFolders.has(folderPathKey(folder.path)),
     790            1 :     ), [collapsedFolders, displayedTree, sessionItems]);
     791            1 :     const sessions = React.useMemo(
     792            1 :         () => flattenSessionTree(displayedTree).flatMap(entry => sessionItems.get(entry.path) ?? []),
     793            1 :         [displayedTree, sessionItems],
     794            1 :     );
     795              : 
     796            1 :     const invalidItems = React.useMemo<InvalidSessionItem[]>(() => [...invalidSessions.values()].map(invalid => {
     797            0 :         const location = storageReader.getSessionLocation(invalid.sessionId);
     798            0 :         return {
     799            0 :             sessionId: invalid.sessionId,
     800            0 :             label: invalid.title,
     801            0 :             path: displayPath(invalid.sessionId, location),
     802            0 :             isNative: location.type === StorageBackendType.Native,
     803            0 :             invalidReason: describeSessionValidationError(invalid.error),
     804            0 :         };
     805            1 :     }), [invalidSessions, storageReader]);
     806            1 :     const sessionCount = sessions.length + invalidItems.length;
     807              :     // The references follow the scripts registry
     808            1 :     const connectionRows = React.useMemo(
     809            1 :         () => listedConnections(databaseRegistry, references, sessionConnectionRows),
     810            1 :         [databaseRegistry, references, scriptsRegistry, sessionConnectionRows],
     811            1 :     );
     812            1 :     const sessionConnectionIds = React.useMemo(
     813            1 :         () => new Set(sessionConnectionRows.map(database => database.connectionId)),
     814            1 :         [sessionConnectionRows],
     815            1 :     );
     816            1 :     const ordinals = React.useMemo(
     817            1 :         () => connectionOrdinals(databaseRegistry, references),
     818            1 :         [databaseRegistry, references, scriptsRegistry],
     819            1 :     );
     820            1 :     const labelOf = React.useCallback(
     821            1 :         (database: ConnectionState) => connectionLabelParts(database, ordinals.get(database.connectionId) ?? null),
     822            1 :         [ordinals],
     823            1 :     );
     824            1 :     const connectionCount = connectionRows.length;
     825            1 :     const describeUsage = React.useCallback((connectionId: string): ConnectionUsage => {
     826            1 :         const sessionIds = references.sessionsOf(connectionId).filter(sessionId => {
     827            1 :             const scripts = scriptsRegistry.sessionScriptsMap.get(sessionId);
     828            1 :             return scripts == null || !isEphemeralSession(scripts);
     829            1 :         });
     830            1 :         const database = databaseRegistry.connections.get(connectionId);
     831            1 :         const scriptsOf = (sessionId: string) => {
     832            1 :             const scripts = scriptsRegistry.sessionScriptsMap.get(sessionId);
     833            1 :             return scripts == null ? 0 : scriptsOnConnection(scripts, connectionId);
     834            1 :         };
     835            1 :         return {
     836            1 :             sessionCount: sessionIds.length,
     837              :             // The sessions fall back to the embedded Hyper, every session that uses it keeps it
     838            1 :             blockingSessionCount: database != null && isHyperWasmConnection(database)
     839            1 :                 ? sessionIds.length
     840            1 :                 : sessionIds.filter(sessionId => scriptsOf(sessionId) > 0).length,
     841            1 :             usedHere: sessionConnectionRows.some(database => database.connectionId === connectionId),
     842            1 :         };
     843            1 :     }, [databaseRegistry, props.sessionScripts.sessionId, sessionConnectionRows, references, scriptsRegistry]);
     844            1 :     const initiallyExpanded = React.useCallback((database: ConnectionState, _count: number) => (
     845            1 :         sessionConnectionRows.includes(database)
     846            1 :         && isConnectionInitiallyExpanded(database, sessionConnectionRows.length)
     847            1 :     ), [sessionConnectionRows]);
     848              : 
     849            1 :     const dndSensors = useSensors(
     850            1 :         useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
     851            1 :         useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
     852            1 :     );
     853              :     /// Change the session tree: the change shows right away, the write follows the earlier ones, then the
     854              :     /// sidebar shows the tree as it is stored. A write that fails, e.g. a taken folder name, is logged.
     855            1 :     const changeSessionTree = React.useCallback((
     856            1 :         what: string,
     857            1 :         write: (folders: NonNullable<typeof storageReader.sessionFolders>) => Promise<void>,
     858            1 :         optimistic?: (tree: SessionTreeFolder) => SessionTreeFolder,
     859            1 :     ) => {
     860            1 :         const sessionFolders = storageReader.sessionFolders;
     861            1 :         if (sessionFolders == null) return;
     862            1 :         if (optimistic != null) setSessionTree(optimistic);
     863            1 :         treeWriteRef.current = treeWriteRef.current
     864            1 :             .catch(() => {})
     865            1 :             .then(() => write(sessionFolders))
     866            1 :             .catch(error => logger.warn(`failed to ${what}`, { error: String(error) }, 'session_workbench'))
     867            1 :             .then(() => setSessionTree(sessionFolders.getSessionTree()));
     868            1 :     }, [logger, storageReader.sessionFolders]);
     869              : 
     870            1 :     const moveSession = React.useCallback((sessionId: string, folderPath: string[], index: number) => {
     871            1 :         changeSessionTree(
     872            1 :             'move the session',
     873            1 :             folders => folders.moveSession(sessionId, folderPath, index),
     874            1 :             tree => moveSessionInTree(tree, sessionId, folderPath, index),
     875            1 :         );
     876            1 :     }, [changeSessionTree]);
     877              : 
     878              :     /// Where the dragged row would land
     879            1 :     const dropOf = React.useCallback((event: DragMoveEvent | DragEndEvent): SessionListDrop | null => {
     880            0 :         if (event.over == null) return null;
     881            0 :         const dragged = event.active.rect.current.translated;
     882            0 :         const middle = event.over.rect.top + event.over.rect.height / 2;
     883            0 :         const aboveMiddle = dragged != null && dragged.top + dragged.height / 2 < middle;
     884            0 :         return resolveSessionListDrop(rows, displayedTree, String(event.active.id), String(event.over.id), aboveMiddle);
     885            1 :     }, [displayedTree, rows]);
     886              : 
     887            1 :     const showDrop = React.useCallback((event: DragMoveEvent) => {
     888            0 :         const next = dropOf(event);
     889            0 :         setDrop(current => current?.targetId === next?.targetId && current?.position === next?.position ? current : next);
     890            1 :     }, [dropOf]);
     891              : 
     892            1 :     const endDrag = React.useCallback(() => setDrop(null), []);
     893              : 
     894            1 :     const dropRow = React.useCallback((event: DragEndEvent) => {
     895            0 :         endDrag();
     896            0 :         const target = dropOf(event);
     897            0 :         const active = rows.find(row => row.id === event.active.id);
     898            0 :         if (target == null || active == null) return;
     899            0 :         if (active.kind === 'session') {
     900            0 :             moveSession(active.item.sessionId, target.folderPath, target.index);
     901            0 :         } else {
     902            0 :             const path = active.folder.path;
     903            0 :             changeSessionTree('move the folder', folders => folders.moveFolder(path, target.folderPath));
     904            0 :         }
     905            1 :     }, [changeSessionTree, dropOf, endDrag, moveSession, rows]);
     906              : 
     907            1 :     const toggleFolder = React.useCallback((folder: SessionTreeFolder) => {
     908            1 :         const key = folderPathKey(folder.path);
     909            1 :         setCollapsedFolders(current => {
     910            1 :             const next = new Set(current);
     911            1 :             if (current.has(key)) next.delete(key); else next.add(key);
     912            1 :             return next;
     913            1 :         });
     914            1 :     }, []);
     915              : 
     916            1 :     const toggleSession = React.useCallback((sessionId: string) => {
     917            1 :         setExpandedSessions(current => {
     918            1 :             const next = new Set(current);
     919            1 :             if (current.has(sessionId)) next.delete(sessionId); else next.add(sessionId);
     920            1 :             return next;
     921            1 :         });
     922            1 :     }, []);
     923              : 
     924              :     /// Create a folder with a free name and edit its name
     925            1 :     const createFolder = React.useCallback((parentPath: string[]) => {
     926            1 :         const parent = folderAt(sessionTree, parentPath);
     927            1 :         if (parent == null) return;
     928            1 :         const path = [...parentPath, freeFolderName(parent.folders.map(folder => folder.name))];
     929            1 :         setCollapsedFolders(current => {
     930            1 :             const next = new Set(current);
     931            1 :             next.delete(folderPathKey(parentPath));
     932            1 :             return next;
     933            1 :         });
     934            1 :         setRenamingFolder(folderPathKey(path));
     935            1 :         changeSessionTree('create the folder', folders => folders.createFolder(path));
     936            1 :     }, [changeSessionTree, sessionTree]);
     937              : 
     938            1 :     const renameFolder = React.useCallback((folder: SessionTreeFolder, input: string) => {
     939            1 :         setRenamingFolder(null);
     940            1 :         const name = input.trim();
     941            1 :         if (name === folder.name) return;
     942            1 :         const parentPath = folder.path.slice(0, -1);
     943            1 :         const siblings = (folderAt(sessionTree, parentPath)?.folders ?? [])
     944            1 :             .map(sibling => sibling.name)
     945            1 :             .filter(sibling => sibling !== folder.name);
     946            1 :         const error = folderNameError(name, siblings);
     947            1 :         if (error != null) {
     948            0 :             logger.warn('failed to rename the folder', { error }, 'session_workbench');
     949            0 :             return;
     950            0 :         }
     951            1 :         changeSessionTree('rename the folder', folders => folders.moveFolder(folder.path, parentPath, name));
     952            1 :     }, [changeSessionTree, logger, sessionTree]);
     953              : 
     954            1 :     const deleteFolder = React.useCallback((folder: SessionTreeFolder) => {
     955            1 :         const count = flattenSessionTree(folder).length;
     956            1 :         const into = folder.path.length > 1 ? `"${folder.path[folder.path.length - 2]}"` : 'the top level';
     957            1 :         const sessionsNote = count === 0 ? '' : ` Its ${count === 1 ? 'session moves' : `${count} sessions move`} to ${into}.`;
     958            1 :         if (!window.confirm(`Delete the folder "${folder.name}"?${sessionsNote}`)) return;
     959            1 :         changeSessionTree('delete the folder', folders => folders.deleteFolder(folder.path));
     960            1 :     }, [changeSessionTree]);
     961              : 
     962              :     /// Open the greeter, its first script creates the session
     963            1 :     const openGreeter = React.useCallback(() => {
     964            1 :         openGreeterPage();
     965            1 :         props.onOpenOverview?.(null);
     966            1 :         props.closeAfterSelection?.();
     967            1 :     }, [openGreeterPage, props.closeAfterSelection, props.onOpenOverview]);
     968              : 
     969              :     /// Create a session in a folder on the embedded Hyper, or on the Hyper of the link that the app was opened with.
     970              :     /// Its scripts attach other connections.
     971            1 :     const createSessionInFolder = React.useCallback((folderPath: string[]) => {
     972            1 :         const { sessionId } = createSession(resolveNewSessionConnection(props.sessionScripts.instance));
     973            1 :         moveSession(sessionId, folderPath, Number.MAX_SAFE_INTEGER);
     974            1 :         navigate({ type: SELECT_SESSION, value: sessionId });
     975            1 :         props.closeAfterSelection?.();
     976            1 :     }, [createSession, moveSession, navigate, props.closeAfterSelection, props.sessionScripts.instance, resolveNewSessionConnection]);
     977              : 
     978              :     // A promoted session moves to the end of the root like a new session, its entry in the tree may be older than the
     979              :     // others. The next ephemeral session takes over the row. The aside and the drawer may both do this: the second move
     980              :     // changes nothing and the second session is found.
     981            1 :     const ephemeralSession = React.useMemo(
     982            1 :         () => [...scriptsRegistry.sessionScriptsMap.values()].find(isEphemeralSession) ?? null,
     983            1 :         [scriptsRegistry],
     984            1 :     );
     985            1 :     const ephemeralSessionId = ephemeralSession?.sessionId ?? null;
     986              :     // An open connection overview replaces the session
     987            1 :     const ephemeralSessionSelected = props.openOverviewId == null && ephemeralSessionId === props.sessionScripts.sessionId;
     988            1 :     const knownEphemeralSessionId = React.useRef(ephemeralSessionId);
     989            1 :     React.useEffect(() => {
     990            1 :         const known = knownEphemeralSessionId.current;
     991            1 :         knownEphemeralSessionId.current = ephemeralSessionId;
     992            1 :         if (known == null || known === ephemeralSessionId || !scriptsRegistry.sessionScriptsMap.has(known)) return;
     993            1 :         moveSession(known, [], Number.MAX_SAFE_INTEGER);
     994            1 :         ensureEphemeralSession();
     995            1 :     }, [ensureEphemeralSession, ephemeralSessionId, moveSession, scriptsRegistry]);
     996              : 
     997              :     /// Create a connection and open its settings, the next script of the current session runs on it
     998            1 :     const createConnection = React.useCallback((connector: ConnectorType) => {
     999            1 :         setCreateConnectionOpen(false);
    1000            1 :         const params = createAttachedConnectionParamsForConnector(CONNECTOR_INFOS[connector]);
    1001            1 :         const initialDatabase = dependencies.createConnectionStateFromParams(props.sessionScripts.instance, params);
    1002            1 :         const database = allocateConnection({ ...initialDatabase, active: true });
    1003            1 :         void modifyCurrentSession({ type: SET_PENDING_CONNECTION, value: scriptConnectionOf(database) });
    1004            1 :         connectionSettingsAnchorRef.current = createConnectionButtonRef.current;
    1005            1 :         setConnectionOverlayDatabaseId(database.connectionId);
    1006            1 :     }, [allocateConnection, dependencies, modifyCurrentSession, props.sessionScripts.instance]);
    1007              : 
    1008              :     /// Rename a connection, a blank name restores the default label
    1009            1 :     const renameConnection = React.useCallback((connectionId: string, name: string) => {
    1010            0 :         connectionDispatch(connectionId, { type: RENAME_CONNECTION, value: name });
    1011            1 :     }, [connectionDispatch]);
    1012              : 
    1013              :     /// Delete a connection that no script uses, other than the embedded Hyper that sessions fall back to.
    1014              :     /// The sessions whose new scripts default to the connection are detached from it first.
    1015            1 :     const deleteConnection = React.useCallback((connectionId: string) => {
    1016            1 :         const database = databaseRegistry.connections.get(connectionId);
    1017            1 :         if (database == null || isHyperWasmConnection(database) || describeUsage(connectionId).blockingSessionCount > 0) return;
    1018            1 :         if (!window.confirm(`Delete the connection "${connectionLabelText(labelOf(database))}"?`)) return;
    1019            1 :         detachConnectionFromSessions(props.sessionScripts.instance, connectionId)
    1020            1 :             .then(() => shutDownAndDeleteConnection(connectionId))
    1021            1 :             .catch(error => {
    1022            0 :                 logger.warn('failed to delete the connection', { connectionId, error: String(error) }, 'session_workbench');
    1023            1 :             });
    1024            1 :     }, [databaseRegistry, describeUsage, detachConnectionFromSessions, labelOf, logger, props.sessionScripts.instance, shutDownAndDeleteConnection]);
    1025              : 
    1026              :     /// The number of duplicates of a connection
    1027            1 :     const mergeCandidateCount = React.useCallback(
    1028            1 :         (connectionId: string) => connectionMergeCandidates(databaseRegistry, references, connectionId).length,
    1029            1 :         [databaseRegistry, references, scriptsRegistry],
    1030            1 :     );
    1031              :     /// Merge the duplicates of a connection into it, their sessions move to the connection
    1032            1 :     const mergeDuplicates = React.useCallback((connectionId: string) => {
    1033            0 :         const database = databaseRegistry.connections.get(connectionId);
    1034            0 :         const duplicates = connectionMergeCandidates(databaseRegistry, references, connectionId);
    1035            0 :         if (database == null || duplicates.length === 0) return;
    1036            0 :         const what = duplicates.length === 1 ? 'its duplicate' : `its ${duplicates.length} duplicates`;
    1037            0 :         if (!window.confirm(`Merge ${what} into the connection "${connectionLabelText(labelOf(database))}"?`)) return;
    1038            0 :         mergeConnections(connectionId, duplicates.map(duplicate => duplicate.connectionId)).catch(error => {
    1039            0 :             logger.warn('failed to merge connections', { connectionId, error: String(error) }, 'session_workbench');
    1040            0 :         });
    1041            1 :     }, [databaseRegistry, labelOf, logger, mergeConnections, references]);
    1042              : 
    1043              :     /// What keeps a connection from being replaced, the picker offers every other listed connection
    1044            1 :     const replaceBlocker = React.useCallback(
    1045            1 :         (connectionId: string) => connectionReplaceBlocker(databaseRegistry, references, connectionId, sessionId => storageWriter.isSessionPaused(sessionId)),
    1046            1 :         [databaseRegistry, references, scriptsRegistry, storageWriter],
    1047            1 :     );
    1048            1 :     const replaceCandidates = React.useCallback(
    1049            0 :         (connectionId: string) => connectionReplaceCandidates(databaseRegistry, references, connectionId),
    1050            1 :         [databaseRegistry, references, scriptsRegistry],
    1051            1 :     );
    1052              :     /// Replace a connection with another one in every session, the connection is removed afterwards
    1053            1 :     const replaceWith = React.useCallback((connectionId: string, targetId: string) => {
    1054            0 :         replaceConnection(connectionId, targetId)
    1055            0 :             .then(({ removed }) => {
    1056            0 :                 if (!removed) logger.warn('the replaced connection is still in use', { connectionId, target: targetId }, 'session_workbench');
    1057            0 :             })
    1058            0 :             .catch(error => {
    1059            0 :                 logger.warn('failed to replace the connection', { connectionId, target: targetId, error: String(error) }, 'session_workbench');
    1060            0 :             });
    1061            1 :     }, [logger, replaceConnection]);
    1062              : 
    1063            1 :     const closeConnectionOverlay = React.useCallback(() => {
    1064            0 :         setConnectionOverlayDatabaseId(null);
    1065            0 :         if (route.sessionSetupStatus === SessionSetupStatus.CONFIGURING && route.sessionId != null) {
    1066            0 :             navigate({ type: SELECT_SESSION, value: route.sessionId });
    1067              :         }
    1068            1 :     }, [navigate, route.sessionId, route.sessionSetupStatus]);
    1069              : 
    1070            1 :     const duplicateSession = React.useCallback(async (item: SessionItem) => {
    1071            0 :         const newSessionId = crypto.randomUUID();
    1072            0 :         try {
    1073            0 :             const coreInstance = await setupCore('session_duplicate');
    1074            0 :             await cloneSession(item.sessionId, storageReader.backend, storageWriter.backend, newSessionId, logger);
    1075            0 :             const restored = await restoreSingleSession(
    1076            0 :                 coreInstance,
    1077            0 :                 storageWriter.backend,
    1078            0 :                 logger,
    1079            0 :                 newSessionId,
    1080            0 :                 databaseRegistry,
    1081            0 :             );
    1082            0 :             setDatabaseRegistry(registry => mergeRestoredSessionIntoConnections(registry, restored));
    1083            0 :             setScriptsRegistry(registry => mergeRestoredSessionIntoScripts(registry, restored));
    1084            0 :         } catch (error) {
    1085            0 :             logger.warn('failed to duplicate session', {
    1086            0 :                 sessionId: item.sessionId,
    1087            0 :                 error: String(error),
    1088            0 :             }, 'session_workbench');
    1089            0 :         }
    1090            1 :     }, [databaseRegistry, logger, setDatabaseRegistry, setScriptsRegistry, setupCore, storageReader.backend, storageWriter.backend]);
    1091              : 
    1092            1 :     const openSessionFolder = React.useCallback(async () => {
    1093            0 :         if (storageReader.sessionFolders == null) return;
    1094            0 :         if (platform === PlatformType.WEB) {
    1095            0 :             folderInputRef.current?.click();
    1096            0 :             return;
    1097            0 :         }
    1098            0 :         const folder = await globalThis.dashqlElectron?.openDirectory('Open an existing session folder');
    1099            0 :         if (folder == null) return;
    1100            0 :         try {
    1101            0 :             await sessionImport.importNativeFolder(folder, {
    1102            0 :                 mode: 'anchored',
    1103            0 :                 anchorRef: folderButtonRef,
    1104            0 :                 returnFocusRef: folderButtonRef,
    1105            0 :             });
    1106            0 :         } catch (error) {
    1107            0 :             logger.error('adding native session from folder failed', { error: String(error) }, 'session_workbench');
    1108            0 :         }
    1109            1 :     }, [logger, sessionImport, platform, storageReader.sessionFolders]);
    1110              : 
    1111            1 :     const openBrowserSessionFolder = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
    1112            0 :         const input = event.currentTarget;
    1113            0 :         if (input.files == null || input.files.length === 0) return;
    1114            0 :         try {
    1115            0 :             const bundle = await readSessionBundleFromBrowserFolder(input.files);
    1116            0 :             await sessionImport.importPortableBundle(bundle, {
    1117            0 :                 presentation: {
    1118            0 :                     mode: 'anchored',
    1119            0 :                     anchorRef: folderButtonRef,
    1120            0 :                     returnFocusRef: folderButtonRef,
    1121            0 :                 },
    1122            0 :             });
    1123            0 :         } catch (error) {
    1124            0 :             logger.error('adding browser session folder failed', { error: String(error) }, 'session_workbench');
    1125            0 :         } finally {
    1126            0 :             input.value = '';
    1127            0 :         }
    1128            1 :     }, [logger, sessionImport]);
    1129              : 
    1130            1 :     const deleteSession = React.useCallback(async (item: SessionItem) => {
    1131            1 :         const action = item.isNative ? 'Unlink' : 'Delete';
    1132            1 :         if (!window.confirm(`${action} "${item.label}"?${item.isNative ? ' The folder and its files will remain on disk.' : ''}`)) return;
    1133            1 :         try {
    1134            1 :             await storageWriter.deleteSession(item.sessionId);
    1135            1 :         } catch (error) {
    1136            0 :             console.error('Failed to delete session from storage:', error);
    1137            0 :             return;
    1138            0 :         }
    1139            1 :         deleteSessionScripts(item.sessionId);
    1140              :         // Connections outlive the sessions that reference them, only the session's own
    1141              :         // computations are dropped.
    1142            1 :         for (const database of item.connections) {
    1143            0 :             for (const queryId of findSessionQueries(database, item.sessionId)) {
    1144            0 :                 computationDispatch({ type: DELETE_COMPUTATION, value: [queryId] });
    1145              :             }
    1146              :         }
    1147              : 
    1148            1 :         if (item.sessionId === props.sessionScripts.sessionId) {
    1149            1 :             const next = sessions.find(session => session.sessionId !== item.sessionId);
    1150            1 :             if (next != null) navigate({ type: OPEN_SESSION, value: next.sessionId });
    1151            1 :             else openGreeter();
    1152            1 :         }
    1153            1 :     }, [computationDispatch, deleteSessionScripts, navigate, openGreeter, sessions, props.sessionScripts.sessionId, storageWriter]);
    1154              : 
    1155            1 :     const openSession = React.useCallback((item: SessionItem) => {
    1156            1 :         navigate({ type: SELECT_SESSION, value: item.sessionId });
    1157            1 :         props.onOpenOverview?.(null);
    1158            1 :         props.closeAfterSelection?.();
    1159            1 :     }, [navigate, props.closeAfterSelection, props.onOpenOverview]);
    1160              :     /// Open a session at one of its scripts, the session row selects the script
    1161            1 :     const openScript = React.useCallback((item: SessionItem, fileName: string) => {
    1162            1 :         navigate({ type: SELECT_SESSION, value: item.sessionId });
    1163            1 :         props.onOpenOverview?.(null);
    1164            1 :         props.onOpenScript?.(item.sessionId, fileName);
    1165            1 :         props.closeAfterSelection?.();
    1166            1 :     }, [navigate, props.closeAfterSelection, props.onOpenOverview, props.onOpenScript]);
    1167            1 :     const onOpenOverview = props.onOpenOverview;
    1168            1 :     const closeAfterSelection = props.closeAfterSelection;
    1169            1 :     const openOverview = React.useMemo(() => onOpenOverview == null ? undefined : (connectionId: string) => {
    1170            0 :         onOpenOverview(connectionId);
    1171            0 :         closeAfterSelection?.();
    1172            1 :     }, [onOpenOverview, closeAfterSelection]);
    1173            1 :     const sessionListRef = React.useRef<HTMLUListElement>(null);
    1174            1 :     const getSessionScrollElement = React.useCallback(() => sessionListRef.current, []);
    1175              : 
    1176            1 :     return (
    1177            1 :         <nav className={styles.workbench} aria-label="Session workbench" data-electron-drag-region>
    1178            1 :             <VerticalSplit
    1179            1 :                 className={styles.section_split}
    1180            1 :                 separatorClassName={styles.section_separator}
    1181            1 :                 separatorSize={SECTION_SEPARATOR_SIZE}
    1182            1 :                 defaultRatio={0.42}
    1183            1 :                 minFirstSize={SECTION_MIN_SIZE}
    1184            1 :                 minSecondSize={SECTION_MIN_SIZE}
    1185            1 :                 separatorLabel="Resize sessions and connections"
    1186            1 :                 first={(
    1187            1 :                     <section className={styles.section} aria-labelledby="workbench-sessions-heading">
    1188            1 :                         <header className={styles.section_header}>
    1189            1 :                             <div className={styles.section_title}>
    1190            1 :                                 <h2 id="workbench-sessions-heading" className={styles.section_heading}>Sessions</h2>
    1191            1 :                                 <span
    1192            1 :                                     className={styles.section_count}
    1193            1 :                                     data-testid="session-count"
    1194            1 :                                     aria-label={`${sessionCount} ${sessionCount === 1 ? 'session' : 'sessions'}`}
    1195              :                                 >
    1196            1 :                                     {sessionCount}
    1197            1 :                                 </span>
    1198            1 :                             </div>
    1199            1 :                             <div className={styles.section_actions}>
    1200            1 :                                 {storageReader.sessionFolders != null && (
    1201            1 :                                     <>
    1202            1 :                                         <IconButton
    1203            1 :                                             ref={folderButtonRef}
    1204            1 :                                             size={ButtonSize.Small}
    1205            1 :                                             variant={ButtonVariant.Invisible}
    1206            1 :                                             aria-label="Open session folder"
    1207            1 :                                             onClick={openSessionFolder}
    1208              :                                         >
    1209            1 :                                             <UploadIcon size={14} />
    1210            1 :                                         </IconButton>
    1211            1 :                                         {platform === PlatformType.WEB && (
    1212            1 :                                             <input
    1213            1 :                                                 ref={folderInputRef}
    1214            1 :                                                 className={styles.folder_input}
    1215            1 :                                                 type="file"
    1216            1 :                                                 multiple
    1217            1 :                                                 {...({ webkitdirectory: '' } as React.InputHTMLAttributes<HTMLInputElement>)}
    1218            1 :                                                 tabIndex={-1}
    1219            1 :                                                 aria-hidden="true"
    1220            1 :                                                 onChange={openBrowserSessionFolder}
    1221            1 :                                             />
    1222              :                                         )}
    1223            1 :                                     </>
    1224              :                                 )}
    1225            1 :                                 {storageReader.sessionFolders != null && (
    1226            1 :                                     <IconButton
    1227            1 :                                         data-testid="create-folder"
    1228            1 :                                         size={ButtonSize.Small}
    1229            1 :                                         variant={ButtonVariant.Invisible}
    1230            1 :                                         aria-label="Create folder"
    1231            1 :                                         onClick={() => createFolder([])}
    1232            1 :                                     ><FileDirectoryPlusIcon size={14} /></IconButton>
    1233              :                                 )}
    1234            1 :                                 <IconButton
    1235            1 :                                     ref={createButtonRef}
    1236            1 :                                     data-testid="create-session"
    1237            1 :                                     size={ButtonSize.Small}
    1238            1 :                                     variant={ButtonVariant.Invisible}
    1239            1 :                                     aria-label="Create session"
    1240            1 :                                     disabled={ephemeralSessionSelected}
    1241            1 :                                     onClick={openGreeter}
    1242            1 :                                 ><StackPlusIcon size={14} /></IconButton>
    1243            1 :                             </div>
    1244            1 :                         </header>
    1245            1 :                         <div className={styles.scroll_shadow_container}>
    1246            1 :                             <DndContext
    1247            1 :                                 sensors={dndSensors}
    1248            1 :                                 collisionDetection={SESSION_LIST_COLLISIONS}
    1249            1 :                                 modifiers={[VERTICAL_DRAG]}
    1250            1 :                                 onDragMove={showDrop}
    1251            1 :                                 onDragEnd={dropRow}
    1252            1 :                                 onDragCancel={endDrag}
    1253              :                             >
    1254            1 :                                 <SortableContext items={rows.map(row => row.id)} strategy={KEEP_ROWS_IN_PLACE}>
    1255            1 :                                     <ul ref={sessionListRef} className={styles.session_list} aria-label="Sessions" data-electron-drag-region="false">
    1256            1 :                                         {rows.map(row => row.kind === 'folder' ? (
    1257            1 :                                             <SessionFolderRow
    1258            1 :                                                 key={row.id}
    1259            1 :                                                 rowId={row.id}
    1260            1 :                                                 folder={row.folder}
    1261            1 :                                                 depth={row.depth}
    1262            1 :                                                 expanded={row.expanded}
    1263            1 :                                                 renaming={renamingFolder === folderPathKey(row.folder.path)}
    1264            1 :                                                 onToggle={() => toggleFolder(row.folder)}
    1265            1 :                                                 onStartRename={() => setRenamingFolder(folderPathKey(row.folder.path))}
    1266            1 :                                                 onRename={name => renameFolder(row.folder, name)}
    1267            1 :                                                 onCancelRename={() => setRenamingFolder(null)}
    1268            1 :                                                 onNewSession={() => createSessionInFolder(row.folder.path)}
    1269            1 :                                                 onMoveToTopLevel={row.depth === 0 ? null : () => changeSessionTree('move the folder', folders => folders.moveFolder(row.folder.path, []))}
    1270            1 :                                                 onDelete={() => deleteFolder(row.folder)}
    1271            1 :                                                 dropTarget={drop?.targetId === row.id}
    1272            1 :                                             />
    1273              :                                         ) : (
    1274            1 :                                             <SessionRow
    1275            1 :                                                 key={row.id}
    1276            1 :                                                 item={row.item}
    1277            1 :                                                 depth={row.depth}
    1278              :                                                 // An open connection overview replaces the session
    1279            1 :                                                 selected={props.openOverviewId == null && row.item.sessionId === props.sessionScripts.sessionId}
    1280            1 :                                                 onOpen={() => openSession(row.item)}
    1281            1 :                                                 onDuplicate={() => { void duplicateSession(row.item); }}
    1282            1 :                                                 onDelete={() => { void deleteSession(row.item); }}
    1283            1 :                                                 dropPosition={drop?.targetId === row.id && drop.position !== 'into' ? drop.position : null}
    1284            1 :                                                 expanded={expandedSessions.has(row.item.sessionId)}
    1285            1 :                                                 onToggle={() => toggleSession(row.item.sessionId)}
    1286            1 :                                                 onOpenScript={fileName => openScript(row.item, fileName)}
    1287            1 :                                                 dependencies={dependencies}
    1288            1 :                                             />
    1289            1 :                                         ))}
    1290            1 :                                         {invalidItems.map(item => (
    1291            0 :                                             <InvalidSessionRow
    1292            0 :                                                 key={item.sessionId}
    1293            0 :                                                 item={item}
    1294            0 :                                                 onDelete={() => { void deleteInvalidSession(item.sessionId); }}
    1295            0 :                                             />
    1296            1 :                                         ))}
    1297            1 :                                         {ephemeralSession != null && (
    1298            1 :                                             <EphemeralSessionRow selected={ephemeralSessionSelected} onOpen={openGreeter} />
    1299              :                                         )}
    1300            1 :                                         {storageReader.sessionFolders != null && (
    1301            1 :                                             <TopLevelDropZone targeted={drop?.targetId === TOP_LEVEL_DROP_ID} />
    1302              :                                         )}
    1303            1 :                                     </ul>
    1304            1 :                                 </SortableContext>
    1305            1 :                             </DndContext>
    1306            1 :                             <VerticalScrollShadows
    1307            1 :                                 getScrollElement={getSessionScrollElement}
    1308            1 :                                 refreshKey={`${rows.length}:${invalidItems.length}`}
    1309            1 :                             />
    1310            1 :                         </div>
    1311            1 :                     </section>
    1312              :                 )}
    1313            1 :                 second={(
    1314            1 :                     <section className={styles.section} aria-labelledby="workbench-databases-heading">
    1315            1 :                         <header className={styles.section_header}>
    1316            1 :                             <div className={styles.section_title}>
    1317            1 :                                 <h2 id="workbench-databases-heading" className={styles.section_heading}>Connections</h2>
    1318            1 :                                 <span
    1319            1 :                                     className={styles.section_count}
    1320            1 :                                     data-testid="connection-count"
    1321            1 :                                     aria-label={`${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`}
    1322              :                                 >
    1323            1 :                                     {connectionCount}
    1324            1 :                                 </span>
    1325            1 :                             </div>
    1326            1 :                             <AnchoredOverlay
    1327            1 :                                 open={createConnectionOpen}
    1328            1 :                                 onOpen={() => setCreateConnectionOpen(true)}
    1329            1 :                                 onClose={() => setCreateConnectionOpen(false)}
    1330            1 :                                 anchorRef={createConnectionButtonRef}
    1331            1 :                                 returnFocusRef={createConnectionButtonRef}
    1332            1 :                                 side={AnchorSide.OutsideBottom}
    1333            1 :                                 align={AnchorAlignment.End}
    1334            1 :                                 renderAnchor={anchorProps => <IconButton
    1335            1 :                                     {...anchorProps}
    1336            1 :                                     ref={createConnectionButtonRef}
    1337            1 :                                     size={ButtonSize.Small}
    1338            1 :                                     variant={ButtonVariant.Invisible}
    1339            1 :                                     aria-label="Create connection"
    1340            1 :                                 ><PlusIcon size={14} /></IconButton>}
    1341              :                             >
    1342            1 :                                 <div className={actionMenuStyles.menu} role="dialog" aria-label="Choose connection connector">
    1343            1 :                                     <ActionList.List aria-label="Connection connector">
    1344            1 :                                         {ATTACHABLE_CONNECTORS.map(connector => (
    1345            1 :                                             <ActionList.ListItem key={connector} onClick={() => createConnection(connector)}>
    1346            1 :                                                 <ActionList.Leading>
    1347            1 :                                                     <svg width="16px" height="16px" aria-hidden="true">
    1348            1 :                                                         <use xlinkHref={`${symbols}#${connectorIcon(CONNECTOR_INFOS[connector])}`} style={CONNECTOR_ICON_STYLE} />
    1349            1 :                                                     </svg>
    1350            1 :                                                 </ActionList.Leading>
    1351            1 :                                                 <ActionList.ItemText>{CONNECTOR_INFOS[connector].names.displayShort}</ActionList.ItemText>
    1352            1 :                                             </ActionList.ListItem>
    1353            1 :                                         ))}
    1354            1 :                                     </ActionList.List>
    1355            1 :                                 </div>
    1356            1 :                             </AnchoredOverlay>
    1357            1 :                         </header>
    1358            1 :                         <div className={styles.database_body} data-electron-drag-region="false">
    1359            1 :                             {connectionRows.length > 0 && <ConnectionTree
    1360            1 :                                 databases={connectionRows}
    1361            1 :                                 describeUsage={describeUsage}
    1362            1 :                                 onRename={renameConnection}
    1363            1 :                                 onDelete={deleteConnection}
    1364            1 :                                 mergeCandidateCount={mergeCandidateCount}
    1365            1 :                                 onMerge={mergeDuplicates}
    1366            1 :                                 replaceBlocker={replaceBlocker}
    1367            1 :                                 replaceCandidates={replaceCandidates}
    1368            1 :                                 onReplace={replaceWith}
    1369            1 :                                 initiallyExpanded={initiallyExpanded}
    1370            1 :                                 sessionConnectionIds={sessionConnectionIds}
    1371            1 :                                 labelOf={labelOf}
    1372            1 :                                 attention={connectionAttention?.active === true}
    1373            1 :                                 onOpenOverview={openOverview}
    1374            1 :                                 openOverviewId={props.openOverviewId ?? null}
    1375            1 :                                 onOpenSettings={(connectionId, anchor) => {
    1376            1 :                                     connectionSettingsAnchorRef.current = anchor;
    1377            1 :                                     setConnectionOverlayDatabaseId(connectionId);
    1378            1 :                                 }}
    1379            1 :                             />}
    1380            1 :                         </div>
    1381            1 :                     </section>
    1382              :                 )}
    1383            1 :             />
    1384            1 :             <dependencies.ConnectionSettingsOverlay
    1385            1 :                 connectionId={connectionOverlayDatabaseId}
    1386            1 :                 isOpen={connectionOverlayDatabaseId != null}
    1387            1 :                 onClose={closeConnectionOverlay}
    1388            1 :                 anchorRef={connectionSettingsAnchorRef}
    1389            1 :                 renderSessionName={renderSessionName}
    1390            1 :             />
    1391            1 :         </nav>
    1392              :     );
    1393            1 : };
        

Generated by: LCOV version 2.0-1