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, CommandEmpty, } from "@/components/command"; import { BookOpen, Bug, Clock, FlaskConical, Folder, Globe, KeyRound, MessagesSquare, Pencil, Plus, Search, Server, Settings, ShieldCheck, } 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 { 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 { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url"; import { isPrerelease } from "@/plugins/plugin-model"; import { shell } from "@/plugin-host/shell-bridge"; import { getLiveHostStatus } from "@/lib/ServerStatusContext"; import { docsUrl } from "@/lib/docs"; import { parseServerTime } from "@/lib/server-time"; interface CommandPaletteProps { /** Opens settings, optionally at one section. */ onOpenSettings?: (section?: string) => void; /** Opens a host in the editor. */ onEditHost?: (host: Host) => void; 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, onOpenSettings, onEditHost, }: 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 query = search.trim().toLowerCase(); const searching = query.length > 0; // Without a query the list is a short starting point, not every host. const filteredHosts = searching ? hosts.filter( (h) => h.name.toLowerCase().includes(query) || h.ip.toLowerCase().includes(query) || h.username.toLowerCase().includes(query) || h.folder?.toLowerCase().includes(query) || h.tags?.some((tag) => tag.toLowerCase().includes(query)), ) : hosts.slice(0, 6); // Group hosts by folder; ungrouped hosts appear first. const groupedHosts: { folder: string | null; hosts: Host[] }[] = []; const folderMap = new Map(); const ungrouped: Host[] = []; for (const h of filteredHosts) { if (h.folder && searching) { 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]; setSelectedValue(searching && firstHost ? `host-${firstHost.id}` : ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, search]); const activeTargetTab = terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0]; const handleAction = (action: () => void) => { action(); setIsOpen(false); }; const matches = (...texts: (string | undefined)[]) => !searching || texts.some((text) => text?.toLowerCase().includes(query)); const destinations = railItems.filter((item) => matches(t(item.labelKey))); const quickActions = [ { id: "quick-action-add-host", label: t("commandPalette.addNewHost"), icon: Plus, run: () => onOpenTab("host-manager", undefined, "host-manager:add-host"), }, { id: "quick-action-add-credential", label: t("commandPalette.addCredential"), icon: KeyRound, run: () => onOpenTab("host-manager", undefined, "host-manager:add-credential"), }, { id: "quick-action-settings", label: t("nav.settings"), icon: Settings, run: () => onOpenSettings ? onOpenSettings() : onOpenTab("user-profile"), }, { id: "quick-action-admin-settings", label: t("commandPalette.adminSettings"), icon: ShieldCheck, run: () => onOpenSettings ? onOpenSettings("admin-general") : onOpenTab("admin-settings"), }, ].filter((action) => matches(action.label)); const globalEntries = paletteEntriesFor(paletteEntries, "global").filter( (entry) => matches(t(entry.titleKey), ...(entry.keywords ?? [])), ); const links = [ { id: "link-docs", label: t("commandPalette.documentation"), icon: BookOpen, url: docsUrl(), }, { id: "link-github", label: "GitHub", icon: Globe, url: "https://github.com/Termix-SSH/Termix", }, { id: "link-discord", label: "Discord", icon: MessagesSquare, url: "https://discord.com/invite/jVQGdvHDrf", }, { id: "link-report-bug", label: t("dashboard.reportBug"), icon: Bug, url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined), }, ...(isPrerelease(import.meta.env.VITE_APP_VERSION) ? [ { id: "link-beta-feedback", label: t("newUi.sidebar.userProfile.sendBetaFeedback"), icon: FlaskConical, url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION), }, ] : []), ].filter((link) => matches(link.label)); const rowClass = "group gap-2 rounded-none"; const iconClass = "size-3.5 shrink-0 text-muted-foreground"; const hostButton = "flex size-6 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-accent-brand hover:text-accent-brand"; if (!isOpen) return null; return (
setIsOpen(false)} >
e.stopPropagation()} >
ESC
{t("commandPalette.noHostsFound", { search })} {!searching && recentActivity.length > 0 && ( {recentActivity.slice(0, 4).map((item) => { const Icon = activityTarget(item.type)?.icon ?? Server; return ( handleAction(() => { const target = activityTarget(item.type); if (target) onOpenTab(target.tab, item.hostName); }) } className={rowClass} > {item.hostName} {parseServerTime(item.timestamp).toLocaleDateString()} ); })} )} {filteredHosts.length > 0 && ( {groupedHosts.map(({ folder, hosts: groupHosts }) => (
{folder && (
{folder}
)} {groupHosts.map((host) => { const online = getLiveHostStatus(Number(host.id)) === "online"; return ( handleAction(() => { const action = defaultConnectAction( hostActions, host, ); if (action) runHostAction(action, host, shell); }) } className={rowClass} > {host.name} {host.username}@{host.ip} {host.isShared && ( {t("hosts.sharing.sharedBadge")} )} {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) && ( )} ); })}
))}
)} {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( rowClass, blocked && "pointer-events-none opacity-50", )} > {Icon && } {item.title} {item.description && ( {item.description} )} {(blocked || item.hint) && ( {blocked ? t("commandPalette.noTargetTab") : item.hint} )} ); })} ); })} {onOpenPanel && destinations.length > 0 && ( {destinations.map((item) => { const Icon = item.icon; return ( handleAction(() => item.kind === "tab" ? onOpenTab(item.id as TabType) : onOpenPanel(item.id), ) } className={rowClass} > {t(item.labelKey)} ); })} )} {quickActions.length > 0 && ( {quickActions.map((action) => { const Icon = action.icon; return ( handleAction(action.run)} className={rowClass} > {action.label} ); })} )} {globalEntries.length > 0 && ( {globalEntries.map((entry) => { const Icon = entry.icon; return ( handleAction(() => entry.run(shell))} className={rowClass} > {Icon && } {t(entry.titleKey)} ); })} )} {links.length > 0 && ( {links.map((link) => { const Icon = link.icon; return ( window.open(link.url, "_blank")} className={rowClass} > {link.label} ); })} )}
↑↓ {t("commandPalette.navigate")} ↵ {t("commandPalette.select")} Ctrl + K {t("commandPalette.orShortcut")} ⇧⇧
); }