mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: add host context menu actions (#1315)
This commit is contained in:
@@ -331,6 +331,10 @@ export function HostItem({
|
|||||||
const [authOverrideOpen, setAuthOverrideOpen] = useState(false);
|
const [authOverrideOpen, setAuthOverrideOpen] = useState(false);
|
||||||
const [parentDragOver, setParentDragOver] = useState(false);
|
const [parentDragOver, setParentDragOver] = useState(false);
|
||||||
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
|
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
|
||||||
|
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.isElectron) return;
|
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;
|
if (query && !hostMatchesQuery(host, query)) return null;
|
||||||
|
|
||||||
const depthStyle =
|
const depthStyle =
|
||||||
@@ -504,15 +518,7 @@ export function HostItem({
|
|||||||
{host.macAddress && (
|
{host.macAddress && (
|
||||||
<button
|
<button
|
||||||
title={t("hosts.wakeOnLanAction")}
|
title={t("hosts.wakeOnLanAction")}
|
||||||
onClick={async (e) => {
|
onClick={handleWakeOnLan}
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await wakeOnLan(Number(host.id));
|
|
||||||
toast.success(t("hosts.wakeOnLanSuccess", { name: host.name }));
|
|
||||||
} catch {
|
|
||||||
toast.error(t("hosts.wakeOnLanError"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={trayButtonClass}
|
className={trayButtonClass}
|
||||||
>
|
>
|
||||||
<Zap className="size-3.5" />
|
<Zap className="size-3.5" />
|
||||||
@@ -582,12 +588,29 @@ export function HostItem({
|
|||||||
<Boxes className="size-3.5" />
|
<Boxes className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<DropdownMenu
|
||||||
|
open={isMenuOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setContextMenuPosition(null);
|
||||||
|
onMenuOpenChange?.(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
title={t("hosts.moreOptions")}
|
title={t("hosts.moreOptions")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
className={trayButtonClass}
|
e.stopPropagation();
|
||||||
|
setContextMenuPosition(null);
|
||||||
|
}}
|
||||||
|
className={`${trayButtonClass} ${contextMenuPosition ? "fixed z-50 size-px opacity-0 pointer-events-none" : ""}`}
|
||||||
|
style={
|
||||||
|
contextMenuPosition
|
||||||
|
? {
|
||||||
|
left: contextMenuPosition.x,
|
||||||
|
top: contextMenuPosition.y,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-3.5" />
|
<MoreHorizontal className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -596,6 +619,100 @@ export function HostItem({
|
|||||||
align="start"
|
align="start"
|
||||||
className="text-xs w-auto min-w-44 max-w-72 whitespace-nowrap"
|
className="text-xs w-auto min-w-44 max-w-72 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Terminal className="size-3.5 mr-2" />
|
||||||
|
{t("common.connect")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{sshActions.map(({ type, icon: Icon, label }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={type}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openHostTab(type);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5 mr-2" />
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{host.enableRdp && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openHostTab("rdp");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Monitor className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.connectRdp")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{host.enableVnc && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openHostTab("vnc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.connectVnc")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{host.enableTelnet && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openHostTab("telnet");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessagesSquare className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.connectTelnet")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{onEditHost && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEditHost();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.editHostAction")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onShareHost && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShareHost();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.shareHost")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{host.enableProxmox && onProxmoxDiscover && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onProxmoxDiscover();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Boxes className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.proxmoxDiscoverAction")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{host.macAddress && (
|
||||||
|
<DropdownMenuItem onClick={handleWakeOnLan}>
|
||||||
|
<Zap className="size-3.5 mr-2" />
|
||||||
|
{t("hosts.wakeOnLanAction")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -929,6 +1046,13 @@ export function HostItem({
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={() => onHoverChange?.(true)}
|
onMouseEnter={() => onHoverChange?.(true)}
|
||||||
onMouseLeave={() => onHoverChange?.(false)}
|
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 ${
|
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
|
||||||
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
|
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
|
||||||
} ${
|
} ${
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
|
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",
|
"exposes the Copy Link submenu trigger in %s density",
|
||||||
(density) => {
|
(density) => {
|
||||||
renderHostItem(density, { menuOpen: true });
|
renderHostItem(density, { menuOpen: true });
|
||||||
|
expect(screen.getByText("common.connect")).toBeTruthy();
|
||||||
expect(screen.getByText("hosts.copyLink")).toBeTruthy();
|
expect(screen.getByText("hosts.copyLink")).toBeTruthy();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("opens the complete host menu on right click", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<HostItem
|
||||||
|
host={baseHost}
|
||||||
|
onOpenTab={noop}
|
||||||
|
onEditHost={noop}
|
||||||
|
onShareHost={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
onDuplicate={noop}
|
||||||
|
isMenuOpen={menuOpen}
|
||||||
|
onMenuOpenChange={setMenuOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
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)(
|
it.each(["comfortable", "compact"] as const)(
|
||||||
"exposes edit, share, and more-options actions in %s density",
|
"exposes edit, share, and more-options actions in %s density",
|
||||||
(density) => {
|
(density) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user