import React, { useEffect, useRef, useState } from "react"; import { Command as CommandPrimitive } from "cmdk"; 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, Globe, Plus, MessagesSquare, LifeBuoy, Search, User, KeyRound, Clock, Folder, Pencil, } from "lucide-react"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import type { Host, TabType, Tab } from "@/types/ui-types"; import { canEditHost } from "@/sidebar/host-permissions"; import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items"; import { defaultConnectAction, hostActionsFor, runHostAction, useHostActions, } from "@/sidebar/host-contributions"; import { filterPaletteItems, loadPaletteGroup, paletteEntriesFor, usePaletteEntries, usePaletteGroups, type PaletteItemDef, } from "./palette-registry"; import { activityTarget } from "@/lib/activity-types"; import { shell } from "@/plugin-host/shell-bridge"; interface CommandPaletteProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; hosts: Host[]; terminalTabs?: Tab[]; activeTabId?: string; onOpenTab: (type: TabType, label?: string, pendingEvent?: string) => void; /** Opens a sidebar panel. Kept separate from onOpenTab, which is TabType-shaped. */ onOpenPanel?: (view: string) => void; } export function CommandPalette({ isOpen, setIsOpen, hosts, terminalTabs = [], activeTabId = "", onOpenTab, onOpenPanel, }: CommandPaletteProps) { const { t } = useTranslation(); const railItems = useRailItems(); const hostActions = useHostActions(); const paletteEntries = usePaletteEntries(); const inputRef = useRef(null); const [search, setSearch] = useState(""); const [recentActivity, setRecentActivity] = useState( [], ); const paletteGroups = usePaletteGroups(); const [groupItems, setGroupItems] = useState< Record >({}); const [selectedValue, setSelectedValue] = useState(""); useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50); setSearch(""); getRecentActivity(5) .then(setRecentActivity) .catch(() => {}); } }, [isOpen]); useEffect(() => { if (!isOpen) return; let cancelled = false; for (const group of paletteGroups) { void loadPaletteGroup(group).then((items) => { if (!cancelled) { setGroupItems((prev) => ({ ...prev, [group.id]: items })); } }); } return () => { cancelled = true; }; }, [isOpen, paletteGroups]); 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) => { const query = search.toLowerCase(); return ( h.name.toLowerCase().includes(query) || h.ip.toLowerCase().includes(query) || h.username.toLowerCase().includes(query) || h.tags?.some((tag) => tag.toLowerCase().includes(query)) ); }); // 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 }); } useEffect(() => { if (!isOpen) return; const firstHost = filteredHosts[0]; if (search.trim() && firstHost) { setSelectedValue(`host-${firstHost.id}`); return; } setSelectedValue("quick-action-add-host"); }, [filteredHosts, isOpen, search]); const activeTargetTab = terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0]; const handleAction = (action: () => void) => { action(); setIsOpen(false); }; const showHostResultsFirst = search.trim().length > 0; if (!isOpen) return null; return (
setIsOpen(false)} >
e.stopPropagation()} >
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")}
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")}
{onOpenPanel && ( <> {[...railItems, ...RAIL_UTILITY_ITEMS] .filter((item) => item.kind !== "tab") .map((item) => { const Icon = item.icon; return ( handleAction(() => onOpenPanel(item.id)) } className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{t(item.labelKey)}
); })}
)} {paletteGroups.map((group) => { const items = filterPaletteItems( groupItems[group.id] ?? [], search, group.showWhenEmpty, ); if (items.length === 0) return null; return ( {items.map((item) => { const Icon = item.icon; const blocked = !!item.needsTarget && !activeTargetTab; return ( { if (blocked) return; handleAction(() => item.run({ targetTab: activeTargetTab, shell }), ); }} className={cn( "group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer", blocked && "pointer-events-none opacity-50", )} >
{Icon && ( )}
{item.title} {item.description && ( {item.description} )}
{(blocked || item.hint) && ( {blocked ? t("commandPalette.noTargetTab") : item.hint} )}
); })}
); })} {paletteEntriesFor(paletteEntries, "global").length > 0 && ( <> {paletteEntriesFor(paletteEntries, "global").map((entry) => { const Icon = entry.icon; return ( handleAction(() => entry.run(shell))} className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{Icon && ( )}
{t(entry.titleKey)}
); })}
)} {recentActivity.length > 0 && ( <> {recentActivity.map((item) => ( handleAction(() => { const target = activityTarget(item.type); if (target) onOpenTab(target.tab, item.hostName); }) } className="group flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{(() => { const Icon = activityTarget(item.type)?.icon; return Icon ? : null; })()}
{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 action = defaultConnectAction( hostActions, host, ); if (action) runHostAction(action, host, shell); }) } className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" >
{host.name} {host.isShared && ( {t("hosts.sharing.sharedBadge")} )} {host.online && ( )}
{host.username}@{host.ip}
{hostActionsFor(hostActions, host).map((action) => { const Icon = action.icon; const label = action.label?.(host) ?? t(action.titleKey); return ( ); })} {paletteEntriesFor( paletteEntries, "host", host, ).map((entry) => { const Icon = entry.icon; return ( ); })} {canEditHost(host) && ( <>
)}
))}
)) ) : (
{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" > {t("dashboard.support")}
↑↓ {t("commandPalette.navigate")}
ENTER {t("commandPalette.select")}
{t("commandPalette.toggleWith")} Ctrl + K {t("commandPalette.orShortcut")} Shift + Shift
); }