LCOV - code coverage report
Current view: top level - dashql-app/src/connections - connection_tree.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.7 % 692 690
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as core from '@ankoh/dashql-core';
       3              : import symbols from '@ankoh/dashql-svg-symbols';
       4              : import { List, type RowComponentProps, useListRef } from 'react-window';
       5              : 
       6              : import * as ActionList from '../ui/foundations/action_list.js';
       7              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
       8              : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
       9              : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
      10              : import { IndicatorStatus, StatusIndicator } from '../ui/foundations/status_indicator.js';
      11              : import { observeSize } from '../ui/foundations/size_observer.js';
      12              : import { VerticalScrollShadows } from '../ui/foundations/vertical_scroll_shadows.js';
      13              : import { NameInput } from '../ui/foundations/name_input.js';
      14              : import { type RowContextMenu, useRowContextMenu, useRowContextMenuOpener } from '../ui/foundations/row_context_menu.js';
      15              : import {
      16              :     ChevronDownIcon,
      17              :     ChevronRightIcon,
      18              :     CpuIcon,
      19              :     DatabaseIcon,
      20              :     FileDirectoryIcon,
      21              :     LinkIcon,
      22              :     PencilIcon,
      23              :     SymbolIcon,
      24              :     SyncIcon,
      25              :     TableIcon,
      26              :     TrashIcon,
      27              : } from '../ui/foundations/symbol_icon.js';
      28              : import { classNames } from '../utils/classnames.js';
      29              : import { ConnectionHealth, type ConnectionState } from './connection_state.js';
      30              : import { isListedConnection, type ConnectionReferences, type ConnectionRegistry } from './connection_registry.js';
      31              : import { useCatalogLoaderQueue } from './catalog/catalog_loader.js';
      32              : import { isCatalogRefreshRunning } from './catalog/catalog_update_state.js';
      33              : import { canRefreshConnection } from './connection_refresh.js';
      34              : import { CONNECTION_HEALTH_COLORS, CONNECTION_HEALTH_NAMES } from './connection_status.js';
      35              : import {
      36              :     connectionLabelParts,
      37              :     connectionLabelText,
      38              :     isHyperWasmConnection,
      39              :     supportsConnectionOverview,
      40              :     type ConnectionLabelParts,
      41              : } from './connection_label.js';
      42              : import { ConnectionLabelView } from './connection_label_view.js';
      43              : import { ConnectionURLShareOverlay } from './connection_url_share_overlay.js';
      44              : import type { ConnectionReplaceCandidates } from './connection_replace.js';
      45              : import { ConnectionReplacePicker } from './connection_replace_picker.js';
      46              : import * as styles from './connection_tree.module.css';
      47              : import * as actionMenuStyles from '../app/action_menu.module.css';
      48              : import { CONNECTOR_ICON_STYLE, useConnectorIcon } from './connector_icon.js';
      49              : 
      50            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
      51            1 : const ColumnsIcon = SymbolIcon('columns_16');
      52            1 : const SettingsIcon = SymbolIcon('settings_16');
      53            1 : const MergeIcon = SymbolIcon('git_merge_16');
      54            1 : const ReplaceIcon = SymbolIcon('arrow_right_16');
      55              : 
      56              : export interface CatalogNode {
      57              :     key: string;
      58              :     name: string;
      59              :     kind: 'database' | 'schema' | 'table' | 'column';
      60              :     index: number;
      61              :     childBegin: number;
      62              :     childCount: number;
      63              : }
      64              : 
      65              : interface CatalogRow extends CatalogNode {
      66              :     type: 'catalog';
      67              :     depth: number;
      68              : }
      69              : 
      70              : interface ConnectionListRow {
      71              :     type: 'connection';
      72              :     key: string;
      73              :     database: ConnectionState;
      74              : }
      75              : 
      76              : /// The overview of a connection, below the connection
      77              : interface ConnectionOverviewRow {
      78              :     /// The row type
      79              :     type: 'connection-overview';
      80              :     /// The row key
      81              :     key: string;
      82              :     /// The connection
      83              :     database: ConnectionState;
      84              : }
      85              : 
      86              : /// The catalog of a connection, below the connection
      87              : interface ConnectionCatalogRow {
      88              :     /// The row type
      89              :     type: 'connection-catalog';
      90              :     /// The row key
      91              :     key: string;
      92              :     /// The connection
      93              :     database: ConnectionState;
      94              : }
      95              : 
      96              : /// The heading of a group of connections
      97              : interface ConnectionGroupRow {
      98              :     /// The row type
      99              :     type: 'connection-group';
     100              :     /// The row key
     101              :     key: string;
     102              :     /// The group
     103              :     group: ConnectionGroupKind;
     104              :     /// The heading
     105              :     label: string;
     106              : }
     107              : 
     108              : type ConnectionTreeRow = ConnectionGroupRow | ConnectionListRow | ConnectionOverviewRow | ConnectionCatalogRow | CatalogRow;
     109              : 
     110              : /// The heights of the rows with their spacing, see the heights of the entries in connection_tree.module.css
     111            1 : const CONNECTION_ROW_HEIGHT = 30;
     112            1 : const CATALOG_ROW_HEIGHT = 26;
     113              : /// The overview and catalog rows below a connection
     114            1 : const SECTION_ROW_HEIGHT = 28;
     115            1 : const CONNECTION_GROUP_ROW_HEIGHT = 26;
     116              : /// The extra space above a group label that follows another group
     117            1 : const CONNECTION_GROUP_GAP = 16;
     118              : /// The extra space above the first group label
     119            1 : const CONNECTION_GROUP_FIRST_GAP = 10;
     120              : 
     121            1 : export function isVisibleCatalogNode(kind: CatalogNode['kind'], name: string): boolean {
     122            1 :     return (kind !== 'database' && kind !== 'schema') || name.trim().length > 0;
     123            1 : }
     124              : 
     125            1 : function readCatalogNode(
     126            1 :     reader: core.DashQLCatalogSnapshotReader,
     127            1 :     level: CatalogNode['kind'],
     128            1 :     index: number,
     129            1 :     parentKey: string,
     130            1 : ): CatalogNode | null {
     131            1 :     const catalog = reader.catalogReader;
     132            1 :     const entry = new core.buffers.catalog.FlatCatalogEntry();
     133            1 :     const value = level === 'database'
     134            1 :         ? catalog.databases(index, entry)
     135            1 :         : level === 'schema'
     136            1 :             ? catalog.schemas(index, entry)
     137            1 :             : level === 'table'
     138            1 :                 ? catalog.tables(index, entry)
     139            1 :                 : catalog.columns(index, entry);
     140            1 :     if (value == null) return null;
     141              : 
     142            1 :     const name = reader.readName(value.nameId());
     143            1 :     if (!isVisibleCatalogNode(level, name)) return null;
     144            1 :     const key = `${parentKey}/${level}:${index}:${name}`;
     145            1 :     return {
     146            1 :         key,
     147            1 :         name,
     148            1 :         kind: level,
     149            1 :         index,
     150            1 :         childBegin: value.childBegin(),
     151            1 :         childCount: value.childCount(),
     152            1 :     };
     153            1 : }
     154              : 
     155            1 : function catalogLevelLength(reader: core.DashQLCatalogSnapshotReader, level: CatalogNode['kind']): number {
     156            1 :     const catalog = reader.catalogReader;
     157            1 :     switch (level) {
     158            1 :         case 'database': return catalog.databasesLength();
     159            1 :         case 'schema': return catalog.schemasLength();
     160            1 :         case 'table': return catalog.tablesLength();
     161            1 :         case 'column': return catalog.columnsLength();
     162            1 :     }
     163            1 : }
     164              : 
     165            1 : function nextCatalogLevel(level: CatalogNode['kind']): CatalogNode['kind'] | null {
     166            1 :     switch (level) {
     167            1 :         case 'database': return 'schema';
     168            1 :         case 'schema': return 'table';
     169            1 :         case 'table': return 'column';
     170            1 :         case 'column': return null;
     171            1 :     }
     172            1 : }
     173              : 
     174            1 : export function createCatalogTree(reader: core.DashQLCatalogSnapshotReader, parentKey = 'catalog'): CatalogNode[] {
     175            1 :     const nodes: CatalogNode[] = [];
     176            1 :     for (let index = 0; index < reader.catalogReader.databasesLength(); ++index) {
     177            1 :         const node = readCatalogNode(reader, 'database', index, parentKey);
     178            1 :         if (node != null) nodes.push(node);
     179            1 :     }
     180            1 :     return nodes;
     181            1 : }
     182              : 
     183            1 : export function flattenCatalogRows(
     184            1 :     reader: core.DashQLCatalogSnapshotReader,
     185            1 :     roots: CatalogNode[],
     186            1 :     expanded: ReadonlySet<string>,
     187            1 : ): CatalogRow[] {
     188            1 :     const rows: CatalogRow[] = [];
     189            1 :     const append = (node: CatalogNode, depth: number) => {
     190            1 :         rows.push({ ...node, type: 'catalog', depth });
     191            1 :         const nextLevel = nextCatalogLevel(node.kind);
     192            1 :         if (nextLevel == null || node.childCount === 0 || !expanded.has(node.key)) return;
     193            1 :         const end = Math.min(node.childBegin + node.childCount, catalogLevelLength(reader, nextLevel));
     194            1 :         for (let index = node.childBegin; index < end; ++index) {
     195            1 :             const child = readCatalogNode(reader, nextLevel, index, node.key);
     196            1 :             if (child != null) append(child, depth + 1);
     197            1 :         }
     198            1 :     };
     199            1 :     for (const root of roots) append(root, 0);
     200            1 :     return rows;
     201            1 : }
     202              : 
     203            1 : function catalogIcon(kind: CatalogNode['kind']): React.ReactElement {
     204            1 :     switch (kind) {
     205            1 :         case 'database': return <DatabaseIcon size={14} />;
     206            1 :         case 'schema': return <FileDirectoryIcon size={14} />;
     207            1 :         case 'table': return <TableIcon size={14} />;
     208            1 :         case 'column': return <ColumnsIcon size={14} />;
     209            1 :     }
     210            1 : }
     211              : 
     212              : interface ConnectionTreeEntryProps {
     213              :     name: string;
     214              :     /// The rendered name, the plain name by default
     215              :     label?: React.ReactNode;
     216              :     icon: React.ReactNode;
     217              :     depth: number;
     218              :     expanded?: boolean;
     219              :     onToggle?: () => void;
     220              :     /// Select the entry, only for entries that do not collapse
     221              :     onSelect?: () => void;
     222              :     /// Is the entry selected?
     223              :     selected?: boolean;
     224              :     children?: React.ReactNode;
     225              :     className?: string;
     226              :     /// The test id of the main element
     227              :     testId?: string;
     228              :     /// Double-click the entry, only for entries that collapse
     229              :     onDoubleClick?: () => void;
     230              :     /// An editor that replaces the name, e.g. while renaming
     231              :     editor?: React.ReactNode;
     232              : }
     233              : 
     234              : /// The left padding of an entry: the overview and catalog sit a little deeper than their connection, the catalog
     235              : /// entries one step per level
     236            1 : function connectionTreeEntryPadding(depth: number): number {
     237            1 :     return depth === 0 ? 8 : 16 + (depth - 1) * 12;
     238            1 : }
     239              : 
     240            1 : const ConnectionTreeEntry: React.FC<ConnectionTreeEntryProps> = ({
     241            1 :     name,
     242            1 :     label,
     243            1 :     icon,
     244            1 :     depth,
     245            1 :     expanded,
     246            1 :     onToggle,
     247            1 :     onSelect,
     248            1 :     selected,
     249            1 :     children,
     250            1 :     className,
     251            1 :     testId,
     252            1 :     onDoubleClick,
     253            1 :     editor,
     254            1 : }) => {
     255            1 :     const collapsible = onToggle != null;
     256            1 :     return (
     257            1 :         <div
     258            1 :             className={classNames(
     259            1 :                 styles.entry_surface,
     260            1 :                 (collapsible || onSelect != null) && styles.entry_interactive,
     261            1 :                 selected && styles.entry_selected,
     262            1 :                 styles.tree_entry,
     263            1 :                 className,
     264            1 :             )}
     265            1 :             style={{ paddingLeft: `${connectionTreeEntryPadding(depth)}px` }}
     266              :         >
     267            1 :             {editor != null ? (
     268            1 :                 <div className={styles.tree_entry_main} data-testid={testId}>
     269            1 :                     {collapsible
     270            1 :                         ? (expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />)
     271            1 :                         : <span className={styles.tree_entry_spacer} />}
     272            1 :                     <span className={styles.tree_entry_icon} aria-hidden="true">{icon}</span>
     273            1 :                     {editor}
     274            1 :                 </div>
     275            1 :             ) : collapsible ? (
     276            1 :                 <button
     277            1 :                     type="button"
     278            1 :                     className={classNames(styles.tree_entry_main, styles.tree_entry_toggle)}
     279            1 :                     data-testid={testId}
     280            1 :                     aria-expanded={expanded}
     281            1 :                     aria-label={`${expanded ? 'Collapse' : 'Expand'} ${name}`}
     282            1 :                     onClick={onToggle}
     283            1 :                     onDoubleClick={onDoubleClick}
     284              :                 >
     285            1 :                     {expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
     286            1 :                     <span className={styles.tree_entry_icon} aria-hidden="true">{icon}</span>
     287            1 :                     <span className={styles.tree_entry_name}>{label ?? name}</span>
     288            1 :                 </button>
     289            1 :             ) : onSelect != null ? (
     290            1 :                 <button
     291            1 :                     type="button"
     292            1 :                     className={classNames(styles.tree_entry_main, styles.tree_entry_toggle)}
     293            1 :                     data-testid={testId}
     294            1 :                     aria-label={name}
     295            1 :                     aria-current={selected ? 'page' : undefined}
     296            1 :                     onClick={onSelect}
     297              :                 >
     298            1 :                     <span className={styles.tree_entry_spacer} />
     299            1 :                     <span className={styles.tree_entry_icon} aria-hidden="true">{icon}</span>
     300            1 :                     <span className={styles.tree_entry_name}>{label ?? name}</span>
     301            1 :                 </button>
     302              :             ) : (
     303            1 :                 <div className={styles.tree_entry_main} data-testid={testId}>
     304            1 :                     <span className={styles.tree_entry_spacer} />
     305            1 :                     <span className={styles.tree_entry_icon} aria-hidden="true">{icon}</span>
     306            1 :                     <span className={styles.tree_entry_name}>{label ?? name}</span>
     307            1 :                 </div>
     308              :             )}
     309            1 :             {children != null && <span className={styles.tree_entry_children}>{children}</span>}
     310            1 :         </div>
     311              :     );
     312            1 : };
     313              : 
     314              : interface ConnectionRenameInputProps {
     315              :     /// The renamed connection
     316              :     database: ConnectionState;
     317              :     /// The placeholder while the connection has no name
     318              :     placeholder: string;
     319              :     /// Commit the name, blank input means no name
     320              :     onCommit: (name: string) => void;
     321              :     /// Abandon the rename
     322              :     onCancel: () => void;
     323              : }
     324              : 
     325              : /// The name of a connection as an input, focused and selected when it appears
     326            1 : const ConnectionRenameInput: React.FC<ConnectionRenameInputProps> = ({ database, placeholder, onCommit, onCancel }) => {
     327            1 :     const inputRef = React.useRef<HTMLInputElement>(null);
     328            1 :     React.useEffect(() => {
     329            1 :         inputRef.current?.focus();
     330            1 :         inputRef.current?.select();
     331            1 :     }, []);
     332            1 :     return (
     333            1 :         <NameInput
     334            1 :             ref={inputRef}
     335            1 :             className={styles.connection_rename_input}
     336            1 :             name={database.name ?? null}
     337            1 :             onCommit={onCommit}
     338            1 :             onCancel={onCancel}
     339            1 :             ariaLabel="Connection name"
     340            1 :             placeholder={placeholder}
     341            1 :         />
     342              :     );
     343            1 : };
     344              : 
     345              : interface ConnectionTreeRowProps {
     346              :     rows: ConnectionTreeRow[];
     347              :     expanded: ReadonlySet<string>;
     348              :     onToggle: (key: string) => void;
     349              :     onOpenSettings: (connectionId: string, anchor: HTMLButtonElement) => void;
     350              :     describeUsage?: (connectionId: string) => ConnectionUsage;
     351              :     onDelete?: (connectionId: string) => void;
     352              :     mergeCandidateCount?: (connectionId: string) => number;
     353              :     onMerge?: (connectionId: string) => void;
     354              :     replaceBlocker?: (connectionId: string) => string | null;
     355              :     replaceCandidates?: (connectionId: string) => ConnectionReplaceCandidates;
     356              :     onReplace?: (connectionId: string, targetId: string) => void;
     357              :     labelOf: (database: ConnectionState) => ConnectionLabelParts;
     358              :     attention: boolean;
     359              :     /// Open the overview of a connection
     360              :     onOpenOverview?: (connectionId: string) => void;
     361              :     /// The connection whose overview is open
     362              :     openOverviewId: string | null;
     363              :     /// Rename a connection, connections can't be renamed without it
     364              :     onRename?: (connectionId: string, name: string) => void;
     365              :     /// The connection that is being renamed
     366              :     renamingId: string | null;
     367              :     /// Start or stop renaming a connection
     368              :     setRenamingId: (connectionId: string | null) => void;
     369              : }
     370              : 
     371              : /// How sessions use a connection
     372              : export interface ConnectionUsage {
     373              :     /// The number of sessions that use the connection
     374              :     sessionCount: number;
     375              :     /// The number of sessions that keep the connection from being deleted.
     376              :     /// A session whose new scripts merely default to the connection lets go of it.
     377              :     blockingSessionCount: number;
     378              :     /// Does the current session use the connection?
     379              :     usedHere: boolean;
     380              : }
     381              : 
     382            1 : export function connectionTreeRowHeight(index: number, props: Pick<ConnectionTreeRowProps, 'rows'>): number {
     383            1 :     switch (props.rows[index]?.type) {
     384            1 :         case 'connection-group':
     385            1 :             return CONNECTION_GROUP_ROW_HEIGHT + (index === 0 ? CONNECTION_GROUP_FIRST_GAP : CONNECTION_GROUP_GAP);
     386            1 :         case 'connection':
     387            1 :             return CONNECTION_ROW_HEIGHT + (index === 0 ? 8 : 0);
     388            1 :         case 'connection-overview':
     389            1 :         case 'connection-catalog':
     390            1 :             return SECTION_ROW_HEIGHT;
     391            1 :         default:
     392            1 :             return CATALOG_ROW_HEIGHT;
     393            1 :     }
     394            1 : }
     395              : 
     396            1 : const ConnectionTreeRow = (props: RowComponentProps<ConnectionTreeRowProps>) => {
     397            1 :     const connectorIcon = useConnectorIcon();
     398            1 :     const contextMenu = useRowContextMenu();
     399            1 :     const row = props.rows[props.index];
     400            1 :     if (row == null) return <div style={props.style} />;
     401            1 :     if (row.type === 'connection-group') {
     402            1 :         return (
     403            1 :             <div
     404            1 :                 style={props.style}
     405            1 :                 className={classNames(styles.connection_group_row, props.index === 0 && styles.connection_group_row_first)}
     406            1 :                 role="heading"
     407            1 :                 aria-level={3}
     408            1 :                 data-testid="connection-group"
     409            1 :                 data-group={row.group}
     410              :             >
     411            1 :                 {row.label}
     412            1 :             </div>
     413              :         );
     414              :     }
     415            1 :     if (row.type === 'connection') {
     416            1 :         const expanded = props.expanded.has(row.key);
     417            1 :         const labelParts = props.labelOf(row.database);
     418            1 :         const label = connectionLabelText(labelParts);
     419            1 :         const usage = props.describeUsage?.(row.database.connectionId) ?? null;
     420            1 :         const connectionId = row.database.connectionId;
     421            1 :         const onRename = props.onRename;
     422            1 :         const startRename = onRename == null ? undefined : () => props.setRenamingId(connectionId);
     423            1 :         return (
     424            1 :             <div
     425            1 :                 style={props.style}
     426            1 :                 className={classNames(
     427            1 :                     styles.tree_row_container,
     428            1 :                     styles.database_row_container,
     429            1 :                     props.index === 0 && styles.database_row_container_first,
     430            1 :                 )}
     431            1 :                 data-connection-id={row.database.connectionId}
     432            1 :                 onContextMenu={contextMenu.onContextMenu}
     433              :             >
     434            1 :                 <ConnectionTreeEntry
     435            1 :                     className={styles.database_row}
     436            1 :                     testId="connection-toggle"
     437            1 :                     name={label}
     438            1 :                     label={<ConnectionLabelView parts={labelParts} />}
     439            1 :                     depth={0}
     440            1 :                     expanded={expanded}
     441            1 :                     onToggle={() => props.onToggle(row.key)}
     442            1 :                     onDoubleClick={startRename}
     443            1 :                     editor={onRename != null && props.renamingId === connectionId
     444              :                         ? (
     445            1 :                             <ConnectionRenameInput
     446            1 :                                 database={row.database}
     447            1 :                                 placeholder={labelParts.connector}
     448            1 :                                 onCommit={name => {
     449            1 :                                     props.setRenamingId(null);
     450            1 :                                     onRename(connectionId, name);
     451            1 :                                 }}
     452            1 :                                 onCancel={() => props.setRenamingId(null)}
     453            1 :                             />
     454              :                         )
     455            1 :                         : undefined}
     456            1 :                     icon={(
     457            1 :                         <svg width="14" height="14">
     458            1 :                             <use xlinkHref={`${symbols}#${connectorIcon(row.database.connectorInfo)}`} style={CONNECTOR_ICON_STYLE} />
     459            1 :                         </svg>
     460              :                     )}
     461              :                 >
     462            1 :                     <ConnectionStatusButton
     463            1 :                         database={row.database}
     464            1 :                         label={label}
     465            1 :                         usedHere={usage?.usedHere ?? false}
     466            1 :                         attention={props.attention}
     467            1 :                         onOpenSettings={anchor => props.onOpenSettings(row.database.connectionId, anchor)}
     468            1 :                     />
     469            1 :                     <ConnectionRowMenu
     470            1 :                         database={row.database}
     471            1 :                         label={label}
     472            1 :                         contextMenu={contextMenu}
     473            1 :                         onOpenSettings={anchor => props.onOpenSettings(row.database.connectionId, anchor)}
     474            1 :                         onRename={startRename}
     475              :                         // Sessions fall back to the embedded Hyper, it is never deleted
     476            1 :                         onDelete={props.onDelete == null || usage == null || isHyperWasmConnection(row.database) ? undefined : () => props.onDelete!(row.database.connectionId)}
     477            1 :                         onMerge={props.onMerge == null ? undefined : () => props.onMerge!(row.database.connectionId)}
     478            1 :                         mergeCandidateCount={props.mergeCandidateCount?.(row.database.connectionId)}
     479            1 :                         onReplace={props.onReplace == null || props.replaceCandidates == null
     480            1 :                             ? undefined
     481            0 :                             : targetId => props.onReplace!(connectionId, targetId)}
     482            1 :                         replaceBlocker={props.replaceBlocker == null ? undefined : () => props.replaceBlocker!(connectionId)}
     483            1 :                         replaceCandidates={props.replaceCandidates == null ? undefined : () => props.replaceCandidates!(connectionId)}
     484            1 :                         labelOf={props.labelOf}
     485            1 :                         sessionCount={usage?.blockingSessionCount}
     486            1 :                     />
     487            1 :                 </ConnectionTreeEntry>
     488            1 :             </div>
     489              :         );
     490            1 :     }
     491            1 :     if (row.type === 'connection-overview') {
     492            1 :         const label = connectionLabelText(props.labelOf(row.database));
     493            1 :         return (
     494            1 :             <div style={props.style} className={styles.tree_row_container} data-connection-id={row.database.connectionId}>
     495            1 :                 <ConnectionTreeEntry
     496            1 :                     className={styles.section_row}
     497            1 :                     testId="connection-overview"
     498            1 :                     name={`Overview of ${label}`}
     499            1 :                     label="Overview"
     500            1 :                     icon={<CpuIcon size={14} />}
     501            1 :                     depth={1}
     502            1 :                     selected={props.openOverviewId === row.database.connectionId}
     503            1 :                     onSelect={() => props.onOpenOverview?.(row.database.connectionId)}
     504            1 :                 />
     505            1 :             </div>
     506              :         );
     507            1 :     }
     508            1 :     if (row.type === 'connection-catalog') {
     509            1 :         const label = connectionLabelText(props.labelOf(row.database));
     510            1 :         return (
     511            1 :             <div style={props.style} className={styles.tree_row_container} data-connection-id={row.database.connectionId}>
     512            1 :                 <ConnectionTreeEntry
     513            1 :                     className={styles.section_row}
     514            1 :                     testId="connection-catalog-toggle"
     515            1 :                     name={`Catalog of ${label}`}
     516            1 :                     label="Catalog"
     517            1 :                     icon={<DatabaseIcon size={14} />}
     518            1 :                     depth={1}
     519            1 :                     expanded={props.expanded.has(row.key)}
     520            1 :                     onToggle={() => props.onToggle(row.key)}
     521              :                 >
     522            1 :                     <ConnectionRefreshButton database={row.database} label={label} />
     523            1 :                 </ConnectionTreeEntry>
     524            1 :             </div>
     525              :         );
     526            1 :     }
     527            1 :     const node = row;
     528            1 :     const expandable = node.childCount > 0;
     529            1 :     const expanded = props.expanded.has(node.key);
     530            1 :     return (
     531            1 :         <div style={props.style} className={styles.tree_row_container}>
     532            1 :             <ConnectionTreeEntry
     533            1 :                 className={styles.catalog_row}
     534            1 :                 testId="catalog-node"
     535            1 :                 name={node.name || 'Unnamed'}
     536            1 :                 icon={catalogIcon(node.kind)}
     537            1 :                 depth={node.depth + 2}
     538            1 :                 expanded={expanded}
     539            1 :                 onToggle={expandable ? () => props.onToggle(node.key) : undefined}
     540              :             >
     541            1 :                 <span className={styles.catalog_node_kind}>{node.kind}</span>
     542            1 :             </ConnectionTreeEntry>
     543            1 :         </div>
     544              :     );
     545            1 : };
     546              : 
     547              : interface ConnectionTreeProps {
     548              :     databases: ConnectionState[];
     549              :     /// Open the settings of a connection
     550              :     onOpenSettings: (connectionId: string, anchor: HTMLButtonElement) => void;
     551              :     /// Describe how sessions use a connection
     552              :     describeUsage?: (connectionId: string) => ConnectionUsage;
     553              :     /// Delete a connection that no session uses
     554              :     onDelete?: (connectionId: string) => void;
     555              :     /// The number of connections that can be merged into a connection
     556              :     mergeCandidateCount?: (connectionId: string) => number;
     557              :     /// Merge other connections into a connection
     558              :     onMerge?: (connectionId: string) => void;
     559              :     /// What keeps a connection from being replaced, null if it can be replaced
     560              :     replaceBlocker?: (connectionId: string) => string | null;
     561              :     /// The connections that can replace a connection
     562              :     replaceCandidates?: (connectionId: string) => ConnectionReplaceCandidates;
     563              :     /// Replace a connection with another one in every session
     564              :     onReplace?: (connectionId: string, targetId: string) => void;
     565              :     /// Is a connection expanded initially?
     566              :     initiallyExpanded?: (database: ConnectionState, databaseCount: number) => boolean;
     567              :     /// The connections of the current session, they are grouped apart from the other connections and the unused ones
     568              :     sessionConnectionIds?: ReadonlySet<string>;
     569              :     /// Label a connection
     570              :     labelOf?: (database: ConnectionState) => ConnectionLabelParts;
     571              :     /// Does the current session ask for its connections to execute?
     572              :     attention?: boolean;
     573              :     /// Open the overview of a connection, connections offer no overview without it
     574              :     onOpenOverview?: (connectionId: string) => void;
     575              :     /// The connection whose overview is open
     576              :     openOverviewId?: string | null;
     577              :     /// Rename a connection, a blank name restores the default label. Connections can't be renamed without it
     578              :     onRename?: (connectionId: string, name: string) => void;
     579              : }
     580              : 
     581            1 : const connectionKey = (connectionId: string) => `connection:${connectionId}`;
     582            1 : const catalogKey = (connectionId: string) => `${connectionKey(connectionId)}/catalog`;
     583            1 : const overviewKey = (connectionId: string) => `${connectionKey(connectionId)}/overview`;
     584              : 
     585              : /// The groups of the connection tree
     586              : type ConnectionGroupKind = 'session' | 'other' | 'unused';
     587              : 
     588              : /// The order of the groups of the connection tree
     589            1 : const CONNECTION_GROUP_ORDER: ConnectionGroupKind[] = ['session', 'other', 'unused'];
     590              : 
     591              : /// The headings of the groups of the connection tree
     592            1 : const CONNECTION_GROUP_LABELS: Record<ConnectionGroupKind, string> = {
     593            1 :     session: 'This session',
     594            1 :     other: 'Other connections',
     595            1 :     unused: 'Unused connections',
     596            1 : };
     597              : 
     598            1 : const defaultConnectionLabelOf = (database: ConnectionState) => connectionLabelParts(database);
     599              : 
     600              : /// Keep the identity of an array as long as its elements are identical
     601            1 : function useShallowStableArray<T>(values: T[]): T[] {
     602            1 :     const ref = React.useRef(values);
     603            1 :     if (ref.current.length !== values.length || ref.current.some((value, i) => value !== values[i])) {
     604            1 :         ref.current = values;
     605              :     }
     606            1 :     return ref.current;
     607            1 : }
     608              : 
     609            1 : export const ConnectionTree: React.FC<ConnectionTreeProps> = ({
     610            1 :     databases,
     611            1 :     onOpenSettings,
     612            1 :     describeUsage,
     613            1 :     onDelete,
     614            1 :     mergeCandidateCount,
     615            1 :     onMerge,
     616            1 :     replaceBlocker,
     617            1 :     replaceCandidates,
     618            1 :     onReplace,
     619            1 :     initiallyExpanded = isConnectionInitiallyExpanded,
     620            1 :     sessionConnectionIds,
     621            1 :     labelOf = defaultConnectionLabelOf,
     622            1 :     attention = false,
     623            1 :     onOpenOverview,
     624            1 :     openOverviewId = null,
     625            1 :     onRename,
     626            1 : }) => {
     627              :     // The database states change with every query event while the catalogs rarely change.
     628              :     // A catalog caches its snapshot until its version changes, so the snapshot identity tells us when the trees
     629              :     // must be rebuilt.
     630            1 :     const connectionIds = useShallowStableArray(databases.map(database => database.connectionId));
     631            1 :     const snapshots = useShallowStableArray(databases.map(database => database.catalog.createSnapshot()));
     632            1 :     const catalogTrees = React.useMemo(() => connectionIds.map((connectionId, i) => {
     633            1 :         const reader = snapshots[i].read();
     634            1 :         return {
     635            1 :             connectionId,
     636            1 :             reader,
     637            1 :             roots: createCatalogTree(reader, catalogKey(connectionId)),
     638            1 :         };
     639            1 :     }), [connectionIds, snapshots]);
     640            1 :     const [expanded, setExpanded] = React.useState<ReadonlySet<string>>(() => {
     641            1 :         const initial = new Set<string>();
     642            1 :         catalogTrees.forEach((tree, i) => {
     643            1 :             if (!initiallyExpanded(databases[i], databases.length)) return;
     644            1 :             initial.add(connectionKey(tree.connectionId));
     645            1 :             initial.add(catalogKey(tree.connectionId));
     646            1 :             for (const root of tree.roots) initial.add(root.key);
     647            1 :         });
     648            1 :         return initial;
     649            1 :     });
     650            1 :     const knownConnectionIds = React.useRef(new Set(connectionIds));
     651            1 :     React.useEffect(() => {
     652            1 :         const nextConnectionIds = new Set(connectionIds);
     653            1 :         setExpanded(current => {
     654            1 :             const next = new Set([...current].filter(key => {
     655            1 :                 const connectionId = key.split(':', 2)[1]?.split('/', 1)[0];
     656            1 :                 return connectionId == null || nextConnectionIds.has(connectionId);
     657            1 :             }));
     658            1 :             catalogTrees.forEach((tree, i) => {
     659            1 :                 if (knownConnectionIds.current.has(tree.connectionId)) return;
     660            1 :                 if (initiallyExpanded(databases[i], databases.length)) {
     661            1 :                     next.add(connectionKey(tree.connectionId));
     662            1 :                     next.add(catalogKey(tree.connectionId));
     663            1 :                     for (const root of tree.roots) next.add(root.key);
     664            1 :                 }
     665            1 :             });
     666            1 :             return next;
     667            1 :         });
     668            1 :         knownConnectionIds.current = nextConnectionIds;
     669            1 :     }, [connectionIds]);
     670            1 :     const catalogRows = React.useMemo(() => catalogTrees.map(tree => (
     671            1 :         expanded.has(connectionKey(tree.connectionId)) && expanded.has(catalogKey(tree.connectionId))
     672            1 :             ? flattenCatalogRows(tree.reader, tree.roots, expanded)
     673            1 :             : []
     674            1 :     )), [catalogTrees, expanded]);
     675            1 :     const rows = React.useMemo(() => {
     676            1 :         const next: ConnectionTreeRow[] = [];
     677            1 :         const pushConnection = (database: ConnectionState, i: number) => {
     678            1 :             next.push({ type: 'connection', key: connectionKey(database.connectionId), database });
     679            1 :             if (!expanded.has(connectionKey(database.connectionId))) return;
     680            1 :             if (onOpenOverview != null && supportsConnectionOverview(database)) {
     681            1 :                 next.push({ type: 'connection-overview', key: overviewKey(database.connectionId), database });
     682              :             }
     683            1 :             next.push({ type: 'connection-catalog', key: catalogKey(database.connectionId), database });
     684            1 :             next.push(...catalogRows[i]);
     685            1 :         };
     686            1 :         if (sessionConnectionIds == null) {
     687            1 :             databases.forEach(pushConnection);
     688            1 :             return next;
     689            1 :         }
     690            1 :         const groupOf = (database: ConnectionState): ConnectionGroupKind => {
     691            1 :             if (sessionConnectionIds.has(database.connectionId)) return 'session';
     692            1 :             return describeUsage?.(database.connectionId).sessionCount === 0 ? 'unused' : 'other';
     693            1 :         };
     694            1 :         const groups = databases.map(groupOf);
     695            1 :         for (const group of CONNECTION_GROUP_ORDER) {
     696            1 :             if (!groups.includes(group)) continue;
     697            1 :             next.push({ type: 'connection-group', key: `connection-group:${group}`, group, label: CONNECTION_GROUP_LABELS[group] });
     698            1 :             databases.forEach((database, i) => {
     699            1 :                 if (groups[i] === group) pushConnection(database, i);
     700            1 :             });
     701            1 :         }
     702            1 :         return next;
     703            1 :     }, [databases, catalogRows, describeUsage, expanded, onOpenOverview, sessionConnectionIds]);
     704              :     // The rows are virtualized, the tree remembers the renamed connection while its row scrolls out of view
     705            1 :     const [renamingId, setRenamingId] = React.useState<string | null>(null);
     706            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
     707            1 :     const size = observeSize(containerRef);
     708            1 :     const listRef = useListRef(null);
     709            1 :     const getScrollElement = React.useCallback(() => listRef.current?.element ?? null, [listRef]);
     710            1 :     const toggle = React.useCallback((key: string) => {
     711            1 :         setExpanded(current => {
     712            1 :             const next = new Set(current);
     713            1 :             if (next.has(key)) next.delete(key);
     714            0 :             else next.add(key);
     715            1 :             return next;
     716            1 :         });
     717            1 :     }, []);
     718            1 :     const rowProps = React.useMemo<ConnectionTreeRowProps>(() => ({
     719            1 :         rows,
     720            1 :         expanded,
     721            1 :         onToggle: toggle,
     722            1 :         onOpenSettings,
     723            1 :         describeUsage,
     724            1 :         onDelete,
     725            1 :         mergeCandidateCount,
     726            1 :         onMerge,
     727            1 :         replaceBlocker,
     728            1 :         replaceCandidates,
     729            1 :         onReplace,
     730            1 :         labelOf,
     731            1 :         attention,
     732            1 :         onOpenOverview,
     733            1 :         openOverviewId,
     734            1 :         onRename,
     735            1 :         renamingId,
     736            1 :         setRenamingId,
     737            1 :     }), [attention, describeUsage, expanded, labelOf, mergeCandidateCount, onDelete, onMerge, onOpenOverview, onOpenSettings, onRename, onReplace, openOverviewId, renamingId, replaceBlocker, replaceCandidates, rows, toggle]);
     738            1 :     return (
     739            1 :         <div ref={containerRef} className={styles.database_list} aria-label="Connections and catalogs">
     740            1 :             <List
     741            1 :                 listRef={listRef}
     742            1 :                 style={{ width: size?.width ?? 200, height: size?.height ?? 200 }}
     743            1 :                 rowCount={rows.length}
     744            1 :                 rowHeight={connectionTreeRowHeight}
     745            1 :                 rowComponent={ConnectionTreeRow}
     746            1 :                 rowProps={rowProps}
     747            1 :             />
     748            1 :             <VerticalScrollShadows getScrollElement={getScrollElement} refreshKey={rows.length} />
     749            1 :         </div>
     750              :     );
     751            1 : };
     752              : 
     753              : /// The connections shown next to the sessions.
     754              : /// The connections of the current session come first, followed by the connections of other
     755              : /// sessions and active connections that no session uses.
     756            1 : export function listedConnections(
     757            1 :     registry: Pick<ConnectionRegistry, 'connections'>,
     758            1 :     references: ConnectionReferences,
     759            1 :     currentSessionConnections: readonly ConnectionState[],
     760            1 : ): ConnectionState[] {
     761            1 :     const listed = [...currentSessionConnections];
     762            1 :     const seen = new Set(listed.map(database => database.connectionId));
     763            1 :     for (const [connectionId, database] of registry.connections) {
     764            1 :         if (!seen.has(connectionId) && isListedConnection(registry, references, connectionId)) {
     765            1 :             listed.push(database);
     766              :         }
     767              :     }
     768            1 :     return listed;
     769            1 : }
     770              : 
     771            1 : export function isConnectionInitiallyExpanded(database: ConnectionState, databaseCount: number): boolean {
     772            1 :     return databaseCount === 1 || !isHyperWasmConnection(database);
     773            1 : }
     774              : 
     775              : interface ConnectionRowMenuProps {
     776              :     database: ConnectionState;
     777              :     label: string;
     778              :     onOpenSettings: (anchor: HTMLButtonElement) => void;
     779              :     /// Rename the connection
     780              :     onRename?: () => void;
     781              :     /// Delete the connection, only possible if no session keeps it
     782              :     onDelete?: () => void;
     783              :     /// Merge other connections into the connection
     784              :     onMerge?: () => void;
     785              :     /// The number of connections that can be merged into the connection
     786              :     mergeCandidateCount?: number;
     787              :     /// Replace the connection with another one in every session
     788              :     onReplace?: (targetId: string) => void;
     789              :     /// What keeps the connection from being replaced, null if it can be replaced. Read while the menu is open
     790              :     replaceBlocker?: () => string | null;
     791              :     /// The connections that can replace the connection, read when the picker opens
     792              :     replaceCandidates?: () => ConnectionReplaceCandidates;
     793              :     /// Label a connection of the replace picker
     794              :     labelOf?: (database: ConnectionState) => ConnectionLabelParts;
     795              :     /// The number of sessions that keep the connection from being deleted
     796              :     sessionCount?: number;
     797              :     /// The right click of the row that opens the menu
     798              :     contextMenu?: RowContextMenu;
     799              :     dependencies?: ConnectionRefreshDependencies;
     800              : }
     801              : 
     802              : export interface ConnectionRefreshDependencies {
     803              :     useCatalogLoaderQueue: typeof useCatalogLoaderQueue;
     804              : }
     805              : 
     806            1 : const DEFAULT_CONNECTION_REFRESH_DEPENDENCIES: ConnectionRefreshDependencies = {
     807            1 :     useCatalogLoaderQueue,
     808            1 : };
     809              : 
     810            1 : export const ConnectionRowMenu: React.FC<ConnectionRowMenuProps> = ({ database, label, onOpenSettings, onRename, onDelete, onMerge, mergeCandidateCount = 0, onReplace, replaceBlocker: readReplaceBlocker, replaceCandidates, labelOf = defaultConnectionLabelOf, sessionCount = 0, contextMenu, dependencies = DEFAULT_CONNECTION_REFRESH_DEPENDENCIES }) => {
     811            1 :     const [open, setOpen] = React.useState(false);
     812            1 :     useRowContextMenuOpener(contextMenu, () => setOpen(true));
     813              :     // The menu turns into the picker of the connection that replaces this one
     814            1 :     const [replacing, setReplacing] = React.useState<ConnectionReplaceCandidates | null>(null);
     815            1 :     const close = React.useCallback(() => {
     816            1 :         setOpen(false);
     817            1 :         setReplacing(null);
     818            1 :     }, []);
     819            1 :     const replaceBlocker = open && onReplace != null ? readReplaceBlocker?.() ?? null : null;
     820            1 :     const [shareOpen, setShareOpen] = React.useState(false);
     821              :     // The embedded Hyper is local to the app, a link to it would open an empty session on the embedded Hyper of the recipient
     822            1 :     const canShare = !isHyperWasmConnection(database);
     823            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     824            1 :     const refreshCatalog = dependencies.useCatalogLoaderQueue();
     825            1 :     const isRefreshing = isCatalogRefreshRunning(database);
     826            1 :     const canRefresh = canRefreshConnection(database);
     827              : 
     828            1 :     return (
     829            1 :         <AnchoredOverlay
     830            1 :             open={open}
     831            1 :             onOpen={() => setOpen(true)}
     832            1 :             onClose={close}
     833              :             // The picker is taller than the actions, the overlay is placed again when it shows
     834            1 :             positionRevision={replacing == null ? 0 : 1}
     835            1 :             anchorRef={triggerRef}
     836            1 :             returnFocusRef={triggerRef}
     837            1 :             side={AnchorSide.OutsideRight}
     838            1 :             align={AnchorAlignment.Start}
     839            1 :             anchorOffset={4}
     840            1 :             renderAnchor={(anchorProps) => (
     841            1 :                 <IconButton
     842            1 :                     {...anchorProps}
     843            1 :                     ref={triggerRef}
     844            1 :                     size={ButtonSize.Small}
     845            1 :                     variant={ButtonVariant.Invisible}
     846            1 :                     className={styles.database_menu_button}
     847            1 :                     data-testid="connection-row-menu"
     848            1 :                     aria-label={`More actions for ${label}`}
     849            1 :                     onClick={(event) => {
     850            1 :                         event.stopPropagation();
     851            1 :                         anchorProps.onClick?.(event);
     852            1 :                     }}
     853              :                 >
     854            1 :                     <MoreIcon size={14} />
     855            1 :                 </IconButton>
     856              :             )}
     857              :         >
     858            1 :             {replacing != null && onReplace != null ? (
     859            1 :                 <ConnectionReplacePicker
     860            1 :                     source={database}
     861            1 :                     candidates={replacing}
     862            1 :                     labelOf={labelOf}
     863            1 :                     onCancel={() => setReplacing(null)}
     864            1 :                     onConfirm={targetId => {
     865            1 :                         close();
     866            1 :                         onReplace(targetId);
     867            1 :                     }}
     868            1 :                 />
     869            1 :             ) : <div className={actionMenuStyles.menu} role="dialog" aria-label={`Actions for ${label}`}>
     870            1 :                 <ActionList.List aria-label={`Actions for ${label}`}>
     871              :                     {/* The list takes elements only, the fragment wraps the optional item */}
     872            1 :                     <>{onRename && (
     873            1 :                         <ActionList.ListItem data-testid="connection-menu-item" data-action="rename" onClick={() => {
     874            1 :                             setOpen(false);
     875            1 :                             onRename();
     876            1 :                         }}>
     877            1 :                             <ActionList.Leading><PencilIcon size={16} /></ActionList.Leading>
     878            1 :                             <ActionList.ItemText>Rename</ActionList.ItemText>
     879            1 :                         </ActionList.ListItem>
     880            1 :                     )}</>
     881            1 :                     <ActionList.ListItem data-testid="connection-menu-item" data-action="settings" onClick={() => {
     882            1 :                         const anchor = triggerRef.current;
     883            1 :                         setOpen(false);
     884            1 :                         if (anchor != null) onOpenSettings(anchor);
     885            1 :                     }}>
     886            1 :                         <ActionList.Leading><SettingsIcon size={16} /></ActionList.Leading>
     887            1 :                         <ActionList.ItemText>Settings</ActionList.ItemText>
     888            1 :                     </ActionList.ListItem>
     889            1 :                     <ActionList.ListItem
     890            1 :                         data-testid="connection-menu-item"
     891            1 :                         data-action="refresh"
     892            1 :                         disabled={!canRefresh}
     893            1 :                         aria-busy={isRefreshing}
     894            1 :                         onClick={() => {
     895            1 :                             setOpen(false);
     896            1 :                             refreshCatalog(database.connectionId, true);
     897            1 :                         }}
     898              :                     >
     899            1 :                         <ActionList.Leading>
     900            1 :                             {isRefreshing
     901            1 :                                 ? <StatusIndicator status={IndicatorStatus.Running} width="16px" height="16px" />
     902            1 :                                 : <SyncIcon size={16} />}
     903            1 :                         </ActionList.Leading>
     904            1 :                         <ActionList.ItemText>Refresh</ActionList.ItemText>
     905            1 :                     </ActionList.ListItem>
     906              :                     {/* The list takes elements only, the fragment wraps the optional item */}
     907            1 :                     <>{canShare && (
     908            1 :                         <ActionList.ListItem data-testid="connection-menu-item" data-action="share-url" onClick={(event) => {
     909            1 :                             event.stopPropagation();
     910            1 :                             setShareOpen(value => !value);
     911            1 :                         }}>
     912            1 :                             <ActionList.Leading><LinkIcon size={16} /></ActionList.Leading>
     913            1 :                             <ActionList.ItemText>
     914              :                                 Share as URL
     915            1 :                                 <ConnectionURLShareOverlay database={database} isOpen={shareOpen} setIsOpen={setShareOpen} />
     916            1 :                             </ActionList.ItemText>
     917            1 :                         </ActionList.ListItem>
     918            1 :                     )}</>
     919              :                     {/* The list takes elements only, the fragments wrap the optional items */}
     920            1 :                     <>{onMerge && mergeCandidateCount > 0 && (
     921            1 :                         <ActionList.ListItem data-testid="connection-menu-item" data-action="merge" onClick={() => {
     922            1 :                             setOpen(false);
     923            1 :                             onMerge();
     924            1 :                         }}>
     925            1 :                             <ActionList.Leading><MergeIcon size={16} /></ActionList.Leading>
     926            1 :                             <ActionList.ItemText>
     927            1 :                                 <ActionList.ItemTextTitle>Merge…</ActionList.ItemTextTitle>
     928            1 :                                 <ActionList.ItemTextDescription>{`${mergeCandidateCount} ${mergeCandidateCount === 1 ? 'duplicate' : 'duplicates'}`}</ActionList.ItemTextDescription>
     929            1 :                             </ActionList.ItemText>
     930            1 :                         </ActionList.ListItem>
     931            1 :                     )}</>
     932            1 :                     <>{onReplace && replaceCandidates && (
     933            1 :                         <ActionList.ListItem
     934            1 :                             data-testid="connection-menu-item"
     935            1 :                             data-action="replace"
     936            1 :                             disabled={replaceBlocker != null}
     937            1 :                             title={replaceBlocker ?? undefined}
     938            1 :                             onClick={(event) => {
     939              :                                 // The menu stays open, it shows the picker
     940            1 :                                 event.stopPropagation();
     941            1 :                                 setReplacing(replaceCandidates());
     942            1 :                             }}
     943              :                         >
     944            1 :                             <ActionList.Leading><ReplaceIcon size={16} /></ActionList.Leading>
     945            1 :                             <ActionList.ItemText>
     946            1 :                                 <ActionList.ItemTextTitle>Replace with</ActionList.ItemTextTitle>
     947            1 :                                 {replaceBlocker != null
     948            1 :                                     ? <ActionList.ItemTextDescription>{replaceBlocker}</ActionList.ItemTextDescription>
     949            1 :                                     : <></>}
     950            1 :                             </ActionList.ItemText>
     951            1 :                         </ActionList.ListItem>
     952            1 :                     )}</>
     953            1 :                     <>{onDelete && (
     954            1 :                         <ActionList.ListItem
     955            1 :                             className={actionMenuStyles.delete_action}
     956            1 :                             data-testid="connection-menu-item"
     957            1 :                             data-action="delete"
     958            1 :                             disabled={sessionCount > 0}
     959            1 :                             title={sessionCount > 0 ? `Used by ${sessionCount} ${sessionCount === 1 ? 'session' : 'sessions'}` : undefined}
     960            1 :                             onClick={() => {
     961            1 :                                 setOpen(false);
     962            1 :                                 onDelete();
     963            1 :                             }}
     964              :                         >
     965            1 :                             <ActionList.Leading><TrashIcon size={16} /></ActionList.Leading>
     966            1 :                             <ActionList.ItemText>
     967            1 :                                 <ActionList.ItemTextTitle>Delete</ActionList.ItemTextTitle>
     968            1 :                                 {sessionCount > 0
     969            1 :                                     ? <ActionList.ItemTextDescription>{`Used by ${sessionCount} ${sessionCount === 1 ? 'session' : 'sessions'}`}</ActionList.ItemTextDescription>
     970            1 :                                     : <></>}
     971            1 :                             </ActionList.ItemText>
     972            1 :                         </ActionList.ListItem>
     973            1 :                     )}</>
     974            1 :                 </ActionList.List>
     975            1 :             </div>}
     976            1 :         </AnchoredOverlay>
     977              :     );
     978            1 : };
     979              : 
     980              : interface ConnectionRefreshButtonProps {
     981              :     database: ConnectionState;
     982              :     label: string;
     983              :     dependencies?: ConnectionRefreshDependencies;
     984              : }
     985              : 
     986            1 : export const ConnectionRefreshButton: React.FC<ConnectionRefreshButtonProps> = ({ database, label, dependencies = DEFAULT_CONNECTION_REFRESH_DEPENDENCIES }) => {
     987            1 :     const refreshCatalog = dependencies.useCatalogLoaderQueue();
     988            1 :     const isRefreshing = isCatalogRefreshRunning(database);
     989            1 :     const canRefresh = canRefreshConnection(database);
     990              : 
     991            1 :     return (
     992            1 :         <IconButton
     993            1 :             size={ButtonSize.Small}
     994            1 :             variant={ButtonVariant.Invisible}
     995            1 :             className={styles.database_refresh_button}
     996            1 :             data-testid="connection-refresh"
     997            1 :             aria-label={`Refresh catalog for ${label}`}
     998            1 :             aria-busy={isRefreshing}
     999            1 :             disabled={!canRefresh}
    1000            1 :             onClick={() => refreshCatalog(database.connectionId, true)}
    1001              :         >
    1002            1 :             {isRefreshing
    1003            1 :                 ? <StatusIndicator status={IndicatorStatus.Running} width="14px" height="14px" />
    1004            1 :                 : <SyncIcon size={14} />}
    1005            1 :         </IconButton>
    1006              :     );
    1007            1 : };
    1008              : 
    1009              : interface ConnectionStatusButtonProps {
    1010              :     /// The connection
    1011              :     database: ConnectionState;
    1012              :     /// The label of the connection
    1013              :     label: string;
    1014              :     /// Does the current session use the connection?
    1015              :     usedHere: boolean;
    1016              :     /// Does the current session ask for its connections to execute?
    1017              :     attention?: boolean;
    1018              :     /// Open the settings of the connection
    1019              :     onOpenSettings: (anchor: HTMLButtonElement) => void;
    1020              : }
    1021              : 
    1022              : /// The health of a connection, opens its settings.
    1023              : /// Pulses while the current session asks for a connection to execute and the connection is not online.
    1024            1 : export const ConnectionStatusButton: React.FC<ConnectionStatusButtonProps> = ({ database, label, usedHere, attention = false, onOpenSettings }) => {
    1025            1 :     const health = database.connectionHealth;
    1026            1 :     const online = health === ConnectionHealth.ONLINE;
    1027            1 :     const pulse = usedHere && !online && attention;
    1028            1 :     return (
    1029            1 :         <IconButton
    1030            1 :             size={ButtonSize.Small}
    1031            1 :             variant={ButtonVariant.Invisible}
    1032            1 :             className={classNames(styles.database_status_button, pulse && styles.database_status_attention)}
    1033            1 :             data-connection-attention={pulse ? 'true' : undefined}
    1034            1 :             aria-label={`${label}: ${CONNECTION_HEALTH_NAMES[health] ?? 'Disconnected'}`}
    1035            1 :             title={online ? `${label} connection settings` : 'Connect to execute queries'}
    1036            1 :             onClick={event => onOpenSettings(event.currentTarget)}
    1037              :         >
    1038            1 :             {health === ConnectionHealth.CONNECTING
    1039            1 :                 ? <StatusIndicator status={IndicatorStatus.Running} width="14px" height="14px" />
    1040            1 :                 : <svg width="14px" height="14px" viewBox="0 0 16 16" aria-hidden="true" data-testid="connection-health-dot">
    1041            1 :                     <circle cx="8" cy="8" r="4" style={{ fill: CONNECTION_HEALTH_COLORS[health] ?? CONNECTION_HEALTH_COLORS[ConnectionHealth.NOT_STARTED] }} />
    1042            1 :                 </svg>}
    1043            1 :         </IconButton>
    1044              :     );
    1045            1 : };
    1046              : 
        

Generated by: LCOV version 2.0-1