diff --git a/src/components/panel/saved-connections/ConnectionItem.test.tsx b/src/components/panel/saved-connections/ConnectionItem.test.tsx
new file mode 100644
index 00000000..4e2037b5
--- /dev/null
+++ b/src/components/panel/saved-connections/ConnectionItem.test.tsx
@@ -0,0 +1,176 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { SavedConnection } from "@/types/global";
+import ConnectionItem from "./ConnectionItem";
+import {
+ SavedConnectionsContext,
+ type SavedConnectionsContextValue,
+} from "./context";
+
+vi.mock("@/components/ui/context-menu", () => ({
+ ContextMenu: ({ children }: { children: ReactNode }) => <>{children}>,
+ ContextMenuTrigger: ({ children }: { children: ReactNode }) => (
+ <>{children}>
+ ),
+ ContextMenuContent: ({ children }: { children: ReactNode }) => (
+
{children}
+ ),
+ ContextMenuItem: ({
+ children,
+ onClick,
+ }: {
+ children: ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+ ContextMenuSeparator: () => null,
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("./MoveToGroupMenu", () => ({ MoveToGroupContextMenu: () => null }));
+
+const connection: SavedConnection = {
+ id: "ssh-1",
+ name: "Production",
+ type: "ssh",
+ host: "prod.example.com",
+ port: 22,
+ username: "root",
+};
+
+const handleConnectOnlyMock = vi.fn();
+const onEditConnectionMock = vi.fn();
+
+function createContextValue(): SavedConnectionsContextValue {
+ return {
+ isDragEnabled: false,
+ isPointerDragEnabled: false,
+ dragTarget: null,
+ expandedGroups: new Set(),
+ selectedConnectionIds: new Set([connection.id]),
+ keyboardActiveConnectionId: null,
+ savedConnections: [connection],
+ savedGroups: [],
+ toggleGroup: vi.fn(),
+ handleConnect: vi.fn(),
+ handleConnectOnly: handleConnectOnlyMock,
+ handleOpenSftp: vi.fn(),
+ handleConnectSelected: vi.fn(),
+ handleCopyConnection: vi.fn(),
+ requestMoveConnectionToGroup: vi.fn(),
+ requestMoveSelectedConnectionsToGroup: vi.fn(),
+ handleConnectionSelectionStart: vi.fn(),
+ handleConnectionContextMenu: vi.fn(),
+ registerConnectionElement: vi.fn(),
+ onEditConnection: onEditConnectionMock,
+ onNewConnection: vi.fn(),
+ requestDeleteConnection: vi.fn(),
+ setRenamingConn: vi.fn(),
+ setRenameValue: vi.fn(),
+ setDeleteFolderTarget: vi.fn(),
+ openNewFolderDialog: vi.fn(),
+ openRenameFolderDialog: vi.fn(),
+ requestOpenGroupConnections: vi.fn(),
+ handleDragStart: vi.fn(),
+ handleDragEnd: vi.fn(),
+ handleDragEnterItem: vi.fn(),
+ handleDragOverItem: vi.fn(),
+ handleDragLeaveItem: vi.fn(),
+ handleDropItem: vi.fn(),
+ handlePointerDragStart: vi.fn(),
+ handlePointerDragMove: vi.fn(),
+ handlePointerDragEnd: vi.fn(),
+ handlePointerDragCancel: vi.fn(),
+ t: ((key: string) => key) as SavedConnectionsContextValue["t"],
+ };
+}
+
+function renderConnectionItem() {
+ return render(
+
+
+ ,
+ );
+}
+
+function getConnectionTrigger(container: HTMLElement) {
+ const trigger = container.querySelector(
+ `[data-saved-drop-id="${connection.id}"]`,
+ );
+ if (!trigger) throw new Error("Connection trigger not found");
+ return trigger;
+}
+
+describe("ConnectionItem", () => {
+ beforeEach(() => {
+ handleConnectOnlyMock.mockReset();
+ onEditConnectionMock.mockReset();
+ });
+
+ it("connects exactly once when the focused connection receives Enter", async () => {
+ const user = userEvent.setup();
+ const { container } = renderConnectionItem();
+ const trigger = getConnectionTrigger(container);
+
+ trigger.focus();
+ await user.keyboard("{Enter}");
+
+ expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
+ expect(handleConnectOnlyMock).toHaveBeenCalledWith(connection);
+ });
+
+ it("keeps nested action buttons independent from the parent Enter handler", async () => {
+ const user = userEvent.setup();
+ const { container } = renderConnectionItem();
+ const trigger = getConnectionTrigger(container);
+ const connectButton = screen.getAllByRole("button", {
+ name: "savedConnections.connect",
+ })[0];
+ const editButton = screen.getAllByRole("button", {
+ name: "savedConnections.edit",
+ })[0];
+
+ connectButton.focus();
+ await user.keyboard("{Enter}");
+ expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
+
+ handleConnectOnlyMock.mockClear();
+ editButton.focus();
+ await user.keyboard("{Enter}");
+
+ expect(handleConnectOnlyMock).not.toHaveBeenCalled();
+ expect(onEditConnectionMock).toHaveBeenCalledTimes(1);
+ expect(onEditConnectionMock).toHaveBeenCalledWith(connection);
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it("preserves double-click connect and focuses the item before context-menu edit", () => {
+ const { container } = renderConnectionItem();
+ const trigger = getConnectionTrigger(container);
+ const row = trigger.firstElementChild as HTMLElement;
+
+ fireEvent.doubleClick(row);
+ expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
+
+ const editButtons = screen.getAllByRole("button", {
+ name: "savedConnections.edit",
+ });
+ fireEvent.click(editButtons[1]);
+
+ expect(onEditConnectionMock).toHaveBeenCalledTimes(1);
+ expect(onEditConnectionMock).toHaveBeenCalledWith(connection);
+ expect(document.activeElement).toBe(trigger);
+ });
+});
diff --git a/src/components/panel/saved-connections/ConnectionItem.tsx b/src/components/panel/saved-connections/ConnectionItem.tsx
index 80773159..938e56ae 100644
--- a/src/components/panel/saved-connections/ConnectionItem.tsx
+++ b/src/components/panel/saved-connections/ConnectionItem.tsx
@@ -347,8 +347,10 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
const detailsOpenTimerRef = useRef(null);
const detailsCloseTimerRef = useRef(null);
const suppressDetailsUntilLeaveRef = useRef(false);
+ const itemRef = useRef(null);
const registerSelf = useCallback(
(element: HTMLDivElement | null) => {
+ itemRef.current = element;
registerConnectionElement(conn.id, element);
},
[conn.id, registerConnectionElement],
@@ -413,6 +415,12 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
closeDetails(true);
}, [closeDetails]);
+ const handleEditConnection = useCallback(() => {
+ closeAndSuppressDetails();
+ itemRef.current?.focus({ preventScroll: true });
+ onEditConnection(conn);
+ }, [closeAndSuppressDetails, conn, onEditConnection]);
+
useEffect(
() => () => {
clearDetailsOpenTimer();
@@ -428,9 +436,25 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
ref={registerSelf}
data-saved-drop-type="connection"
data-saved-drop-id={conn.id}
+ tabIndex={-1}
className="relative min-w-full w-max"
draggable={isDragEnabled && !isPointerDragEnabled}
onWheel={closeAndSuppressDetails}
+ onKeyDown={(event) => {
+ if (
+ event.target !== event.currentTarget ||
+ event.defaultPrevented ||
+ event.nativeEvent.isComposing ||
+ event.key === "Process" ||
+ event.key !== "Enter"
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ closeAndSuppressDetails();
+ handleConnectOnly(conn);
+ }}
onPointerDown={
isPointerDragEnabled
? (e) => {
@@ -586,8 +610,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
}}
onClick={(e) => {
e.stopPropagation();
- closeAndSuppressDetails();
- onEditConnection(conn);
+ handleEditConnection();
}}
>
@@ -629,8 +652,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
) : null}
{
- closeAndSuppressDetails();
- onEditConnection(conn);
+ handleEditConnection();
}}
>
diff --git a/src/pages/NewSessionPage.test.tsx b/src/pages/NewSessionPage.test.tsx
index d0ff6a80..7de8b118 100644
--- a/src/pages/NewSessionPage.test.tsx
+++ b/src/pages/NewSessionPage.test.tsx
@@ -210,4 +210,37 @@ describe("NewSessionPage", () => {
);
});
});
+
+ it("saves an edited connection when Enter is pressed in a single-line input", async () => {
+ render();
+
+ const nameInput = await screen.findByDisplayValue(rdpConnection.name);
+ invokeMock.mockClear();
+
+ fireEvent.keyDown(nameInput, { key: "Enter" });
+
+ await waitFor(() => {
+ expect(invokeMock).toHaveBeenCalledWith(
+ "save_connection",
+ expect.objectContaining({
+ connection: expect.objectContaining({ id: rdpConnection.id }),
+ }),
+ );
+ expect(closeMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("does not save from a textarea or while Enter is composing text", async () => {
+ render();
+
+ const nameInput = await screen.findByDisplayValue(rdpConnection.name);
+ const description = screen.getByPlaceholderText("dialog.descriptionPlaceholder");
+ invokeMock.mockClear();
+
+ fireEvent.keyDown(description, { key: "Enter" });
+ fireEvent.keyDown(nameInput, { key: "Enter", isComposing: true });
+
+ expect(invokeMock).not.toHaveBeenCalledWith("save_connection", expect.anything());
+ expect(closeMock).not.toHaveBeenCalled();
+ });
});
diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx
index 606fc691..a30d1c21 100644
--- a/src/pages/NewSessionPage.tsx
+++ b/src/pages/NewSessionPage.tsx
@@ -1123,7 +1123,27 @@ export default function NewSessionPage() {
};
return (
-
+
{
+ if (
+ !editId ||
+ saveDisabled ||
+ event.defaultPrevented ||
+ event.nativeEvent.isComposing ||
+ event.key === "Process" ||
+ event.key !== "Enter"
+ ) {
+ return;
+ }
+
+ if (!(event.target instanceof HTMLInputElement)) return;
+ if (!event.currentTarget.contains(event.target)) return;
+
+ event.preventDefault();
+ void handleSave();
+ }}
+ >