Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './session_shells_page.module.css';
3 : import symbols from '@ankoh/dashql-svg-symbols';
4 :
5 : import { useConnectionRegistry } from '../../connections/connection_registry.js';
6 : import { connectionLabelParts } from '../../connections/connection_label.js';
7 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
8 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
9 : import { ChevronDownIcon } from '../../ui/foundations/symbol_icon.js';
10 : import { ConnectionMenu, HealthDot } from '../scripts/script_connection_picker.js';
11 : import { useConnectionOrdinals } from '../connection_labels.js';
12 : import { SessionShellPage } from './session_shell_page.js';
13 :
14 : export interface SessionShellsPageDependencies {
15 : useConnectionRegistry: typeof useConnectionRegistry;
16 : useConnectionOrdinals: typeof useConnectionOrdinals;
17 : ConnectionMenu: typeof ConnectionMenu;
18 : SessionShellPage: typeof SessionShellPage;
19 : }
20 :
21 1 : const DEFAULT_DEPENDENCIES: SessionShellsPageDependencies = {
22 1 : useConnectionRegistry,
23 1 : useConnectionOrdinals,
24 1 : ConnectionMenu,
25 1 : SessionShellPage,
26 1 : };
27 :
28 : interface Props {
29 : sessionId: string;
30 : sessionName?: string | null;
31 : /// The connection that the visible shell runs on, null while the session has none
32 : connectionId: string | null;
33 : /// Show the shell of another connection
34 : onPickConnection: (connectionId: string) => void;
35 : dependencies?: SessionShellsPageDependencies;
36 : }
37 :
38 : /// The shells of a session, one per connection.
39 : /// The header names the connection of the visible shell and switches to the shells of other connections.
40 : /// A shell lives until the page closes, the shells of other connections are hidden and keep their scrollback.
41 1 : export const SessionShellsPage: React.FC<Props> = (props: Props) => {
42 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
43 1 : const [registry] = dependencies.useConnectionRegistry();
44 1 : const ordinals = dependencies.useConnectionOrdinals();
45 1 : const [open, setOpen] = React.useState(false);
46 1 : const triggerRef = React.useRef<HTMLButtonElement>(null);
47 :
48 : // The connections that got a shell, in the order they were opened
49 1 : const [opened, setOpened] = React.useState<string[]>(() => props.connectionId == null ? [] : [props.connectionId]);
50 1 : if (props.connectionId != null && !opened.includes(props.connectionId)) {
51 1 : setOpened([...opened, props.connectionId]);
52 : }
53 : // The shell of a deleted connection closes
54 1 : const shells = opened.filter(connectionId => registry.connections.has(connectionId));
55 :
56 1 : const connection = props.connectionId == null ? null : registry.connections.get(props.connectionId) ?? null;
57 1 : const labelParts = connection == null ? null : connectionLabelParts(connection, ordinals.get(connection.connectionId) ?? null);
58 1 : const label = labelParts == null ? 'No connection' : `${labelParts.name} ${labelParts.detail}`;
59 :
60 1 : return (
61 1 : <div className={styles.page}>
62 1 : <header className={styles.header} data-electron-drag-region>
63 1 : <AnchoredOverlay
64 1 : open={open}
65 1 : onOpen={() => setOpen(true)}
66 1 : onClose={() => setOpen(false)}
67 1 : side={AnchorSide.OutsideBottom}
68 1 : align={AnchorAlignment.Center}
69 1 : anchorOffset={4}
70 1 : anchorRef={triggerRef}
71 1 : returnFocusRef={triggerRef}
72 1 : renderAnchor={anchorProps => (
73 1 : <button
74 1 : {...anchorProps}
75 1 : ref={triggerRef}
76 1 : type="button"
77 1 : className={styles.trigger}
78 1 : aria-label={`Connection of the shell: ${label}`}
79 1 : data-testid="shell-connection-picker"
80 1 : title={label}
81 : >
82 : {/* The monochrome icon takes the white of the dark header */}
83 1 : {connection != null && (
84 1 : <svg className={styles.trigger_icon} width="16px" height="16px" aria-hidden="true">
85 1 : <use xlinkHref={`${symbols}#${connection.connectorInfo.icons.uncolored}`} />
86 1 : </svg>
87 : )}
88 1 : <span className={styles.trigger_label}>{labelParts?.name ?? label}</span>
89 1 : {labelParts != null && <span className={styles.trigger_detail}>{labelParts.detail}</span>}
90 1 : {connection != null && <span className={styles.trigger_status}><HealthDot health={connection.connectionHealth} /></span>}
91 1 : <ChevronDownIcon size={16} />
92 1 : </button>
93 : )}
94 : >
95 1 : <dependencies.ConnectionMenu
96 1 : sessionId={props.sessionId}
97 1 : label="the shell"
98 1 : connectionId={props.connectionId}
99 1 : onPick={connectionId => {
100 1 : setOpen(false);
101 1 : if (connectionId !== props.connectionId) props.onPickConnection(connectionId);
102 1 : }}
103 1 : />
104 1 : </AnchoredOverlay>
105 1 : </header>
106 1 : <div className={styles.shells}>
107 1 : {shells.map(connectionId => (
108 1 : <div
109 1 : key={connectionId}
110 1 : className={connectionId === props.connectionId ? styles.shell : styles.shell_hidden}
111 1 : data-testid="session-shell-layer"
112 1 : data-connection-id={connectionId}
113 : >
114 1 : <dependencies.SessionShellPage
115 1 : sessionId={props.sessionId}
116 1 : sessionName={props.sessionName}
117 1 : connectionId={connectionId}
118 1 : ordinal={ordinals.get(connectionId) ?? null}
119 1 : active={connectionId === props.connectionId}
120 1 : />
121 1 : </div>
122 1 : ))}
123 1 : </div>
124 1 : </div>
125 : );
126 1 : };
127 :
128 : export default SessionShellsPage;
|