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(); + }} + >