diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx
index 80663c9322187..c3914d202adf7 100644
--- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx
@@ -16,16 +16,20 @@
* along with this program. If not, see .
*/
-import { render, screen, userEvent } from 'design/utils/testing';
+import { act, render, screen, userEvent } from 'design/utils/testing';
import Logger, { NullService } from 'teleterm/logger';
import { VnetContextProvider } from 'teleterm/ui/Vnet';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
-
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
+import {
+ DocumentTshNode,
+ Workspace,
+} from 'teleterm/ui/services/workspacesService';
-import { Workspace } from 'teleterm/ui/services/workspacesService';
+import { routing } from 'teleterm/ui/uri';
+import { unique } from 'teleterm/ui/utils';
import { Connections } from './Connections';
@@ -109,7 +113,7 @@ describe('opening a connection', () => {
serverId: 'foo',
login: 'alice',
title: 'alice@foo',
- uri: '/docs/123',
+ uri: routing.getDocUri({ docId: unique() }),
};
appContext.workspacesService.setState(draft => {
draft.workspaces[cluster.uri] = {
@@ -137,3 +141,138 @@ describe('opening a connection', () => {
expect(workspace.location).toEqual(doc.uri);
});
});
+
+test('adding a new conn while the list is open puts the new conn at the top', async () => {
+ const user = userEvent.setup();
+ const appContext = new MockAppContext();
+ const cluster = makeRootCluster();
+ appContext.clustersService.setState(draft => {
+ draft.clusters.set(cluster.uri, cluster);
+ });
+ const doc: DocumentTshNode = {
+ kind: 'doc.terminal_tsh_node',
+ origin: 'search_bar',
+ rootClusterId: cluster.name,
+ leafClusterId: undefined,
+ serverUri: `${cluster.uri}/servers/foo`,
+ serverId: 'foo',
+ login: 'alice',
+ title: 'alice@foo',
+ uri: routing.getDocUri({ docId: unique() }),
+ status: 'connected',
+ };
+ appContext.workspacesService.setState(draft => {
+ draft.workspaces[cluster.uri] = {
+ documents: [doc],
+ location: undefined,
+ } as Workspace;
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await user.click(screen.getByTitle(/Open Connections/));
+ expect(screen.getByText(doc.title)).toBeInTheDocument();
+
+ const newDoc: DocumentTshNode = {
+ kind: 'doc.terminal_tsh_node',
+ origin: 'search_bar',
+ rootClusterId: cluster.name,
+ leafClusterId: undefined,
+ serverUri: `${cluster.uri}/servers/bar`,
+ serverId: 'bar',
+ login: 'alice',
+ title: 'alice@bar',
+ uri: routing.getDocUri({ docId: unique() }),
+ status: 'connected',
+ };
+
+ act(() => {
+ appContext.workspacesService.setState(draft => {
+ draft.workspaces[cluster.uri].documents.push(newDoc);
+ });
+ });
+
+ const oldConn = screen.getByText(doc.title);
+ const newConn = screen.getByText(newDoc.title);
+ // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
+ expect(
+ newConn.compareDocumentPosition(oldConn) & Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+});
+
+test('disconnecting a conn does not update its position in the list', async () => {
+ const user = userEvent.setup();
+ const appContext = new MockAppContext();
+ const cluster = makeRootCluster();
+ appContext.clustersService.setState(draft => {
+ draft.clusters.set(cluster.uri, cluster);
+ });
+ const doc: DocumentTshNode = {
+ kind: 'doc.terminal_tsh_node',
+ origin: 'search_bar',
+ rootClusterId: cluster.name,
+ leafClusterId: undefined,
+ serverUri: `${cluster.uri}/servers/foo`,
+ serverId: 'foo',
+ login: 'alice',
+ title: 'alice@foo',
+ uri: routing.getDocUri({ docId: unique() }),
+ status: 'connected',
+ };
+ const docToBeClosed: DocumentTshNode = {
+ kind: 'doc.terminal_tsh_node',
+ origin: 'search_bar',
+ rootClusterId: cluster.name,
+ leafClusterId: undefined,
+ serverUri: `${cluster.uri}/servers/bar`,
+ serverId: 'bar',
+ login: 'alice',
+ title: 'alice@bar',
+ uri: routing.getDocUri({ docId: unique() }),
+ status: 'connected',
+ };
+ appContext.workspacesService.setState(draft => {
+ draft.workspaces[cluster.uri] = {
+ documents: [docToBeClosed, doc],
+ location: undefined,
+ } as Workspace;
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await user.click(screen.getByTitle(/Open Connections/));
+ const conn = () => screen.getByText(doc.title);
+ const connToBeClosed = () => screen.getByText(docToBeClosed.title);
+
+ // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
+ expect(
+ connToBeClosed().compareDocumentPosition(conn()) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+
+ const disconnectButton = screen.getByTitle(
+ `Disconnect ${docToBeClosed.title}`
+ );
+ await user.click(disconnectButton);
+
+ expect(
+ await screen.findByTitle(`Remove ${docToBeClosed.title}`)
+ ).toBeInTheDocument();
+
+ expect(
+ connToBeClosed().compareDocumentPosition(conn()) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+});
diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx
index deb74aee37475..d55b23eba449e 100644
--- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx
@@ -19,33 +19,27 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import Popover from 'design/Popover';
import { Box, StepSlider } from 'design';
-import { StepComponentProps } from 'design/StepSlider';
import { useKeyboardShortcuts } from 'teleterm/ui/services/keyboardShortcuts';
-import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation';
import { VnetSliderStep, useVnetContext } from 'teleterm/ui/Vnet';
+import { useAppContext } from 'teleterm/ui/appContextProvider';
-import { useConnections } from './useConnections';
import { ConnectionsIcon } from './ConnectionsIcon/ConnectionsIcon';
-import { ConnectionsFilterableList } from './ConnectionsFilterableList/ConnectionsFilterableList';
+import { ConnectionsSliderStep } from './ConnectionsSliderStep';
export function Connections() {
+ const { connectionTracker } = useAppContext();
+ connectionTracker.useState();
const iconRef = useRef();
const [isPopoverOpened, setIsPopoverOpened] = useState(false);
- const connections = useConnections();
const { status: vnetStatus } = useVnetContext();
const isAnyConnectionActive =
- connections.isAnyConnectionActive || vnetStatus === 'running';
+ connectionTracker.getConnections().some(c => c.connected) ||
+ vnetStatus === 'running';
const togglePopover = useCallback(() => {
- setIsPopoverOpened(wasOpened => {
- const isOpened = !wasOpened;
- if (isOpened) {
- connections.updateSorting();
- }
- return isOpened;
- });
- }, [setIsPopoverOpened, connections.updateSorting]);
+ setIsPopoverOpened(wasOpened => !wasOpened);
+ }, []);
useKeyboardShortcuts(
useMemo(
@@ -56,9 +50,8 @@ export function Connections() {
)
);
- function activateItem(id: string): void {
+ function closeConnectionList(): void {
setIsPopoverOpened(false);
- connections.activateItem(id);
}
// TODO(ravicious): Investigate the problem with height getting temporarily reduced when switching
@@ -69,22 +62,6 @@ export function Connections() {
//
// We aim to replace the sliding animation with an expanding animation before the release, so it
// might not be worth the effort.
- const sliderSteps = [
- (props: StepComponentProps) => (
-
-
-
-
-
- ),
- VnetSliderStep,
- ];
return (
<>
@@ -104,9 +81,16 @@ export function Connections() {
padding (so 24px on both sides) and ConnectionsFilterableList had 300px of width.
*/}
-
+
>
);
}
+
+const stepSliderFlows = { default: [ConnectionsSliderStep, VnetSliderStep] };
diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
index bcacab2ebae5b..00ce1cf6402cc 100644
--- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
@@ -32,25 +32,25 @@ export function ConnectionItem(props: {
index: number;
item: ExtendedTrackedConnection;
showClusterName: boolean;
- onActivate(): void;
- onRemove(): void;
- onDisconnect(): void;
+ activate(): void;
+ remove(): void;
+ disconnect(): void;
}) {
const offline = !props.item.connected;
const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({
index: props.index,
- onRun: props.onActivate,
+ onRun: props.activate,
});
const actionIcons = {
disconnect: {
- title: 'Disconnect',
- action: props.onDisconnect,
+ title: `Disconnect ${props.item.title}`,
+ action: props.disconnect,
Icon: Unlink,
},
remove: {
- title: 'Remove',
- action: props.onRemove,
+ title: `Remove ${props.item.title}`,
+ action: props.remove,
Icon: Trash,
},
};
@@ -64,7 +64,7 @@ export function ConnectionItem(props: {
return (
props.onActivateItem(item.id)}
- onRemove={() => props.onRemoveItem(item.id)}
- onDisconnect={() => props.onDisconnectItem(item.id)}
+ activate={() => props.activateItem(item.id)}
+ remove={() => props.removeItem(item.id)}
+ disconnect={() => props.disconnectItem(item.id)}
/>
)
}
diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx
new file mode 100644
index 0000000000000..3314b211098e3
--- /dev/null
+++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx
@@ -0,0 +1,88 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useState } from 'react';
+import { Box } from 'design';
+import { StepComponentProps } from 'design/StepSlider';
+
+import { useAppContext } from 'teleterm/ui/appContextProvider';
+import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation';
+
+import { ConnectionsFilterableList } from './ConnectionsFilterableList/ConnectionsFilterableList';
+
+export const ConnectionsSliderStep = (
+ props: StepComponentProps & { closeConnectionList: () => void }
+) => {
+ const { connectionTracker } = useAppContext();
+ connectionTracker.useState();
+
+ const items = connectionTracker.getConnections();
+ const [sortedIds, setSortedIds] = useState(null);
+
+ // Sorting needs to be updated only once when the component gets first rendered. This is so that
+ // when new connections are added while the list is open, the sorting is _not_ updated and new
+ // items end up at the top of the list.
+ //
+ // This also keeps the list stable when you e.g. disconnect the first connection in the list.
+ // Instead of the item jumping around, it stays as the top until the user closes and then opens
+ // the list again.
+ if (sortedIds === null) {
+ const sorted = items
+ .slice()
+ // New connections are pushed to the list in `connectionTracker`, so we have to reverse them
+ // to get the newest items on the top
+ .reverse()
+ // Connected items first.
+ .sort((a, b) => (a.connected === b.connected ? 0 : a.connected ? -1 : 1))
+ .map(a => a.id);
+ setSortedIds(sorted);
+ return null;
+ }
+
+ const sortedItems =
+ // It is possible that new connections are added when the menu is open.
+ // They will have -1 index and appear on the top.
+ // Items are sorted by insertion order, meaning that if I add A then B,
+ // then close both, open A and close it, it's going to appear after B
+ // even though it was used more recently than B.
+ items
+ .slice()
+ .sort((a, b) => sortedIds.indexOf(a.id) - sortedIds.indexOf(b.id));
+
+ const removeItem = connectionTracker.removeItem.bind(connectionTracker);
+ const disconnectItem =
+ connectionTracker.disconnectItem.bind(connectionTracker);
+ const activateItem = (id: string) => {
+ props.closeConnectionList();
+ connectionTracker.activateItem(id, { origin: 'connection_list' });
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts b/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts
deleted file mode 100644
index 7cbcd4bc32f39..0000000000000
--- a/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Teleport
- * Copyright (C) 2023 Gravitational, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-import { useCallback, useState } from 'react';
-
-import { useAppContext } from 'teleterm/ui/appContextProvider';
-import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker';
-
-export function useConnections() {
- const { connectionTracker } = useAppContext();
-
- connectionTracker.useState();
-
- const items = connectionTracker.getConnections();
- const [sortedIds, setSortedIds] = useState([]);
-
- const getSortedItems = () => {
- const findIndexInSorted = (item: ExtendedTrackedConnection) =>
- sortedIds.indexOf(item.id);
- // It is possible that new connections are added when the menu is open
- // they will have -1 index and appear on the top.
- // Items are sorted by insertion order, meaning that if I add A then B
- // then close both, open A and close it, it's going to appear after B
- // even though it was used more recently than B
- return [...items].sort(
- (a, b) => findIndexInSorted(a) - findIndexInSorted(b)
- );
- };
-
- const serializedItems = items.map(i => `${i.id}${i.connected}`).join(',');
- const updateSorting = useCallback(() => {
- const sorted = [...items]
- // new connections are pushed to the list in `connectionTracker`,
- // so we have to reverse them to get the newest items on the top
- .reverse()
- // connected first
- .sort((a, b) => (a.connected === b.connected ? 0 : a.connected ? -1 : 1))
- .map(a => a.id);
-
- setSortedIds(sorted);
- }, [setSortedIds, serializedItems]);
-
- return {
- isAnyConnectionActive: items.some(c => c.connected),
- removeItem: (id: string) => connectionTracker.removeItem(id),
- activateItem: (id: string) =>
- connectionTracker.activateItem(id, { origin: 'connection_list' }),
- disconnectItem: (id: string) => connectionTracker.disconnectItem(id),
- updateSorting,
- items: getSortedItems(),
- };
-}