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