LCOV - code coverage report
Current view: top level - dashql-app/src/connections - connection_tree.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 99.9 % 695 694
Test Date: 2026-11-09 20:22:13

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

Generated by: LCOV version 2.0-1