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 :
|