import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Kbd } from "@/components/kbd"; import { Command, CommandItem, CommandList, CommandGroup, CommandSeparator, } from "@/components/command"; import { Server, Settings, Terminal, FolderOpen, FolderSearch, Box, Globe, Plus, MessagesSquare, LifeBuoy, Search, Activity, Network, User, KeyRound, Layers, // --- tmux-monitor --- Monitor, MousePointerClick, Clock, Folder, Pencil, } from "lucide-react"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import type { Host, TabType } from "@/types/ui-types"; interface CommandPaletteProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; hosts: Host[]; onOpenTab: (type: TabType, label?: string, pendingEvent?: string) => void; } const ACTIVITY_ICONS: Record = { terminal: , file_manager: , server_stats: , tunnel: , docker: , telnet: , vnc: , rdp: , }; const ACTIVITY_TAB_TYPE: Record = { terminal: "terminal", file_manager: "files", server_stats: "host-metrics", tunnel: "tunnel", docker: "docker", telnet: "telnet", vnc: "vnc", rdp: "rdp", }; function getSshActions(host: Host): { type: TabType; icon: React.ElementType; label: string; }[] { const metricsEnabled = host.statsConfig?.metricsEnabled !== false; return [ host.enableTerminal !== false && { type: "terminal", icon: Terminal, label: "Terminal", }, host.enableFileManager && { type: "files", icon: FolderSearch, label: "Files", }, host.enableDocker && { type: "docker", icon: Box, label: "Docker" }, // --- tmux-monitor --- opt-in per host, off by default host.enableTerminal !== false && host.enableTmuxMonitor && { type: "tmux_monitor", icon: Layers, label: "Tmux Monitor", }, host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" }, metricsEnabled && { type: "host-metrics", icon: Activity, label: "Host Metrics", }, ].filter(Boolean) as { type: TabType; icon: React.ElementType; label: string; }[]; } export function CommandPalette({ isOpen, setIsOpen, hosts, onOpenTab, }: CommandPaletteProps) { const { t } = useTranslation(); const inputRef = useRef(null); const [search, setSearch] = useState(""); const [recentActivity, setRecentActivity] = useState( [], ); useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50); setSearch(""); getRecentActivity(5) .then(setRecentActivity) .catch(() => {}); } }, [isOpen]); useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setIsOpen(false); } }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); }, [isOpen, setIsOpen]); const filteredHosts = hosts.filter( (h) => h.name.toLowerCase().includes(search.toLowerCase()) || h.ip.toLowerCase().includes(search.toLowerCase()) || h.username.toLowerCase().includes(search.toLowerCase()), ); // Group hosts by folder; ungrouped hosts appear first under an implicit root group const groupedHosts: { folder: string | null; hosts: Host[] }[] = []; const folderMap = new Map(); const ungrouped: Host[] = []; for (const h of filteredHosts) { if (h.folder) { if (!folderMap.has(h.folder)) folderMap.set(h.folder, []); folderMap.get(h.folder)!.push(h); } else { ungrouped.push(h); } } if (ungrouped.length > 0) groupedHosts.push({ folder: null, hosts: ungrouped }); for (const [folder, fhosts] of folderMap) { groupedHosts.push({ folder, hosts: fhosts }); } const handleAction = (action: () => void) => { action(); setIsOpen(false); }; const showHostResultsFirst = search.trim().length > 0; if (!isOpen) return null; return (
setIsOpen(false)} >
e.stopPropagation()} >
setSearch(e.target.value)} placeholder={t("commandPalette.searchPlaceholder")} className="flex-1 h-12 bg-transparent text-sm outline-none placeholder:text-muted-foreground" />
ESC
handleAction(() => onOpenTab( "host-manager", undefined, "host-manager:add-host", ), ) } className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t("commandPalette.addNewHost")} {t("commandPalette.addNewHostDesc")}
handleAction(() => onOpenTab("admin-settings"))} className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t("commandPalette.adminSettings")} {t("commandPalette.adminSettingsDesc")}
handleAction(() => onOpenTab("user-profile"))} className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t("commandPalette.userProfile")} {t("commandPalette.userProfileDesc")}
{/* --- tmux-monitor --- */} handleAction(() => onOpenTab("tmux_monitor"))} className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t("commandPalette.tmuxMonitor")} {t("commandPalette.tmuxMonitorDesc")}
handleAction(() => onOpenTab( "host-manager", undefined, "host-manager:add-credential", ), ) } className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t("commandPalette.addCredential")} {t("commandPalette.addCredentialDesc")}
{recentActivity.length > 0 && ( <> {recentActivity.map((item) => ( handleAction(() => onOpenTab( ACTIVITY_TAB_TYPE[item.type], item.hostName, ), ) } className="group flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{ACTIVITY_ICONS[item.type]}
{item.hostName} {item.type.replace("_", " ")}
{new Date(item.timestamp).toLocaleDateString()}
))}
)}
{!showHostResultsFirst && } {filteredHosts.length > 0 ? ( groupedHosts.map(({ folder, hosts: groupHosts }) => (
{folder && (
{folder}
)} {groupHosts.map((host, i) => ( handleAction(() => { const type = host.enableSsh ? "terminal" : host.enableRdp ? "rdp" : host.enableVnc ? "vnc" : host.enableTelnet ? "telnet" : "terminal"; onOpenTab(type, host.name); }) } className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{host.name} {host.online && ( )}
{host.username}@{host.ip}
{host.enableSsh && getSshActions(host).map( ({ type, icon: Icon, label }) => ( ), )} {host.enableSsh && (host.enableRdp || host.enableVnc || host.enableTelnet) && (
)} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )}
))}
)) ) : (
{t("commandPalette.noHostsFound", { search })}
)}
window.open( "https://github.com/Termix-SSH/Termix", "_blank", ) } className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" > GitHub window.open( "https://discord.com/invite/jVQGdvHDrf", "_blank", ) } className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" > Discord window.open( "https://github.com/Termix-SSH/Support/issues/new", "_blank", ) } className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" > Support
↑↓ {t("commandPalette.navigate")}
ENTER {t("commandPalette.select")}
{t("commandPalette.toggleWith")} Shift + Shift
); }