From b3cc66efdf5796640ba77a56a073e6b33e7c42fd Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 24 Aug 2026 05:31:31 +0800 Subject: [PATCH] feat: add host context menu actions (#1315) --- src/ui/sidebar/tree/HostItem/HostItem.tsx | 148 ++++++++++++++++-- .../sidebar/tree/HostItem/HostItem.test.tsx | 32 ++++ 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index d1ce58b2..3e475687 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -331,6 +331,10 @@ export function HostItem({ const [authOverrideOpen, setAuthOverrideOpen] = useState(false); const [parentDragOver, setParentDragOver] = useState(false); const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState<{ + x: number; + y: number; + } | null>(null); useEffect(() => { if (!window.electronAPI?.isElectron) return; @@ -398,6 +402,16 @@ export function HostItem({ } } + async function handleWakeOnLan(e: MouseEvent) { + e.stopPropagation(); + try { + await wakeOnLan(Number(host.id)); + toast.success(t("hosts.wakeOnLanSuccess", { name: host.name })); + } catch { + toast.error(t("hosts.wakeOnLanError")); + } + } + if (query && !hostMatchesQuery(host, query)) return null; const depthStyle = @@ -504,15 +518,7 @@ export function HostItem({ {host.macAddress && ( )} - + { + if (!open) setContextMenuPosition(null); + onMenuOpenChange?.(open); + }} + > @@ -596,6 +619,100 @@ export function HostItem({ align="start" className="text-xs w-auto min-w-44 max-w-72 whitespace-nowrap" > + + + + {t("common.connect")} + + + {sshActions.map(({ type, icon: Icon, label }) => ( + { + e.stopPropagation(); + openHostTab(type); + }} + > + + {label} + + ))} + {host.enableRdp && ( + { + e.stopPropagation(); + openHostTab("rdp"); + }} + > + + {t("hosts.connectRdp")} + + )} + {host.enableVnc && ( + { + e.stopPropagation(); + openHostTab("vnc"); + }} + > + + {t("hosts.connectVnc")} + + )} + {host.enableTelnet && ( + { + e.stopPropagation(); + openHostTab("telnet"); + }} + > + + {t("hosts.connectTelnet")} + + )} + + + + {onEditHost && ( + { + e.stopPropagation(); + onEditHost(); + }} + > + + {t("hosts.editHostAction")} + + )} + {onShareHost && ( + { + e.stopPropagation(); + onShareHost(); + }} + > + + {t("hosts.shareHost")} + + )} + {host.enableProxmox && onProxmoxDiscover && ( + { + e.stopPropagation(); + onProxmoxDiscover(); + }} + > + + {t("hosts.proxmoxDiscoverAction")} + + )} + {host.macAddress && ( + + + {t("hosts.wakeOnLanAction")} + + )} + { e.stopPropagation(); @@ -929,6 +1046,13 @@ export function HostItem({ }} onMouseEnter={() => onHoverChange?.(true)} onMouseLeave={() => onHoverChange?.(false)} + onContextMenu={(event) => { + if (selectionMode || arrangeMode) return; + event.preventDefault(); + event.stopPropagation(); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + onMenuOpenChange?.(true); + }} className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${ canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" } ${ diff --git a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx index 47d56f86..a63e40af 100644 --- a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx +++ b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Host } from "@/types/ui-types"; import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences"; @@ -106,10 +107,41 @@ describe("HostItem density parity", () => { "exposes the Copy Link submenu trigger in %s density", (density) => { renderHostItem(density, { menuOpen: true }); + expect(screen.getByText("common.connect")).toBeTruthy(); expect(screen.getByText("hosts.copyLink")).toBeTruthy(); }, ); + it("opens the complete host menu on right click", () => { + function Harness() { + const [menuOpen, setMenuOpen] = useState(false); + return ( + + ); + } + render(); + + const hostRow = screen.getByText("web-01").closest(".cursor-pointer"); + expect(hostRow).toBeTruthy(); + fireEvent.contextMenu(hostRow!, { clientX: 120, clientY: 80 }); + + expect(screen.getByText("common.connect")).toBeTruthy(); + expect(screen.getAllByText("hosts.editHostAction").length).toBeGreaterThan( + 0, + ); + expect(screen.getAllByText("hosts.shareHost").length).toBeGreaterThan(0); + expect(screen.getByText("hosts.wakeOnLanAction")).toBeTruthy(); + }); + it.each(["comfortable", "compact"] as const)( "exposes edit, share, and more-options actions in %s density", (density) => {