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