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, Play, Clipboard, } from "lucide-react"; import { getRecentActivity, getSnippets, type RecentActivityItem, } from "@/main-axios"; import type { Host, TabType, Tab, Snippet } from "@/types/ui-types"; import { canEditHost } from "@/sidebar/host-permissions"; import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items"; import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx"; import { defaultConnectAction, hostActionsFor, runHostAction, useHostActions, } from "@/sidebar/host-contributions"; import { paletteEntriesFor, usePaletteEntries } 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 [snippets, setSnippets] = useState([]); const [selectedValue, setSelectedValue] = useState(""); const { runSnippet, dialog: runSnippetDialog } = useSnippetRunner(); useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50); setSearch(""); getRecentActivity(5) .then(setRecentActivity) .catch(() => {}); getSnippets() .then((data) => setSnippets((data ?? []) as unknown as Snippet[])) .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) => { 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 }); } const filteredSnippets = search.trim() ? snippets.filter((s) => { const query = search.toLowerCase(); return ( s.name.toLowerCase().includes(query) || s.content.toLowerCase().includes(query) ); }) : []; 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; // Closing the palette unmounts this component (AppShell only renders it // while commandPaletteOpen is true), so keep rendering just the variables // dialog after close if a snippet run is still pending its inputs. if (!isOpen) return runSnippetDialog; 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)}
); })}
)} {filteredSnippets.length > 0 && ( <> {filteredSnippets.map((snippet) => ( { if (!activeTargetTab) return; handleAction(() => runSnippet(snippet, [activeTargetTab]), ); }} className={cn( "group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer", !activeTargetTab && "pointer-events-none opacity-50", )} >
{snippet.isNote ? ( ) : ( )}
{snippet.name} {snippet.content}
{activeTargetTab ? t("commandPalette.runSnippetDesc") : t("newUi.sidebar.snippets.noTerminalTabsOpen")}
))}
)} {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" > Support
↑↓ {t("commandPalette.navigate")}
ENTER {t("commandPalette.select")}
{t("commandPalette.toggleWith")} Ctrl + K {t("commandPalette.orShortcut")} Shift + Shift
{runSnippetDialog}
); }