import { Folder, FolderLock, LayoutGrid, Plus, Search, Terminal, TerminalSquare, } from "lucide-react"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { Host, TerminalSession, Workspace } from "../types"; import { KeyBinding } from "../domain/models"; import { matchesSearchQuery } from "../lib/searchMatcher"; import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells"; type QuickSwitcherItem = { type: "host" | "tab" | "workspace" | "action" | "shell"; id: string; data?: Host | TerminalSession | Workspace; }; import { DistroAvatar } from "./DistroAvatar"; import { Input } from "./ui/input"; import { ScrollArea } from "./ui/scroll-area"; // Compute once at module level const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); // Memoized host item component to prevent unnecessary re-renders const HostItem = memo(({ host, isSelected, onSelect, onMouseEnter, }: { host: Host; isSelected: boolean; onSelect: (host: Host) => void; onMouseEnter: () => void; }) => (
onSelect(host)} onMouseEnter={onMouseEnter} >
{host.label}
{host.group ? `Personal / ${host.group}` : "Personal"}
)); HostItem.displayName = "HostItem"; interface QuickSwitcherProps { isOpen: boolean; query: string; results: Host[]; sessions: TerminalSession[]; workspaces: Workspace[]; onQueryChange: (value: string) => void; onSelect: (host: Host) => void; onSelectTab: (tabId: string) => void; onClose: () => void; onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void; onCreateWorkspace?: () => void; keyBindings?: KeyBinding[]; showSftpTab: boolean; } const QuickSwitcherInner: React.FC = ({ isOpen, query, results, sessions, workspaces, onQueryChange, onSelect, onSelectTab, onClose, onCreateLocalTerminal, onCreateWorkspace, keyBindings, showSftpTab, }) => { const { t } = useI18n(); const discoveredShells = useDiscoveredShells(); const filteredShells = useMemo(() => { const list = !query.trim() ? discoveredShells : discoveredShells.filter( (s) => matchesSearchQuery(query, s.name, s.id) ); // Default shell first return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1)); }, [discoveredShells, query]); // Get hotkey display strings const getHotkeyLabel = useCallback((actionId: string) => { const binding = keyBindings?.find(k => k.id === actionId); if (!binding) return ''; return IS_MAC ? binding.mac : binding.pc; }, [keyBindings]); const quickSwitchKey = getHotkeyLabel('quick-switch'); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const containerRef = useRef(null); // Reset state when opening useEffect(() => { if (!isOpen) return; const focusTimer = window.setTimeout(() => { inputRef.current?.focus(); }, 50); setSelectedIndex(0); return () => { window.clearTimeout(focusTimer); }; }, [isOpen]); // Handle clicks outside the container useEffect(() => { if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { onClose(); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen, onClose]); // Memoize orphan sessions const orphanSessions = useMemo( () => sessions.filter((s) => !s.workspaceId), [sessions] ); const trimmedQuery = query.trim(); const builtInTabs = useMemo(() => { if (!trimmedQuery) return showSftpTab ? ["vault", "sftp"] : ["vault"]; const matched: string[] = []; if (matchesSearchQuery(trimmedQuery, "Vaults", "vault", "hosts", "connections")) { matched.push("vault"); } if (showSftpTab && matchesSearchQuery(trimmedQuery, "SFTP", "files", "transfer", "sftp")) { matched.push("sftp"); } return matched; }, [showSftpTab, trimmedQuery]); const filteredOrphanSessions = useMemo(() => { if (!trimmedQuery) return orphanSessions; return orphanSessions.filter((session) => matchesSearchQuery( trimmedQuery, session.hostLabel || "", session.hostname || "", session.id, ), ); }, [orphanSessions, trimmedQuery]); const filteredWorkspaces = useMemo(() => { if (!trimmedQuery) return workspaces; return workspaces.filter((workspace) => matchesSearchQuery(trimmedQuery, workspace.title, workspace.id), ); }, [trimmedQuery, workspaces]); const shouldShowLocalTerminalFallback = filteredShells.length === 0 && !!onCreateLocalTerminal && !trimmedQuery; // Always show categorized view (Hosts/Tabs/Quick connect) const showCategorized = true; // Memoize flat items list and index map const { flatItems, itemIndexMap } = useMemo(() => { const items: QuickSwitcherItem[] = []; if (showCategorized) { // Hosts results.forEach((host) => items.push({ type: "host", id: host.id, data: host }), ); // Tabs (built-in + sessions + workspaces) builtInTabs.forEach((tabId) => { items.push({ type: "tab", id: tabId }); }); filteredOrphanSessions.forEach((s) => items.push({ type: "tab", id: s.id, data: s }), ); filteredWorkspaces.forEach((w) => items.push({ type: "workspace", id: w.id, data: w }), ); // Local shells (or fallback action if discovery not ready) if (filteredShells.length > 0) { filteredShells.forEach((shell) => items.push({ type: "shell", id: shell.id }), ); } else if (shouldShowLocalTerminalFallback) { items.push({ type: "action", id: "local-terminal" }); } } else { // Recent connections only results.forEach((host) => items.push({ type: "host", id: host.id, data: host }), ); // Also include matching shells in search results filteredShells.forEach((shell) => items.push({ type: "shell", id: shell.id }), ); } // Build index map for O(1) lookup const indexMap = new Map(); items.forEach((item, idx) => { indexMap.set(`${item.type}:${item.id}`, idx); }); return { flatItems: items, itemIndexMap: indexMap }; }, [showCategorized, results, builtInTabs, filteredOrphanSessions, filteredWorkspaces, filteredShells, shouldShowLocalTerminalFallback]); // O(1) index lookup const getItemIndex = useCallback((type: string, id: string) => { return itemIndexMap.get(`${type}:${id}`) ?? -1; }, [itemIndexMap]); if (!isOpen) return null; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, flatItems.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); } else if (e.key === "Enter" && flatItems.length > 0) { e.preventDefault(); const item = flatItems[selectedIndex]; handleItemSelect(item); } }; const handleItemSelect = (item: QuickSwitcherItem) => { switch (item.type) { case "host": onSelect(item.data as Host); break; case "tab": case "workspace": onSelectTab(item.id); onClose(); break; case "action": if (item.id === "local-terminal" && onCreateLocalTerminal) { onCreateLocalTerminal(); onClose(); } break; case "shell": { const shell = discoveredShells.find(s => s.id === item.id); if (shell && onCreateLocalTerminal) { onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon }); onClose(); } break; } } }; return (
{/* Search Header */}
{ onQueryChange(e.target.value); setSelectedIndex(0); }} onKeyDown={handleKeyDown} placeholder={t("qs.search.placeholder")} className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm" /> {quickSwitchKey && ( {quickSwitchKey.replace(/ \+ /g, '+')} )}
{/* Categorized view: Hosts/Tabs/Quick connect */}
{/* Jump To hint + New Workspace action */}
{t("qs.jumpTo")} {quickSwitchKey && ( {quickSwitchKey.replace(/ \+ /g, '+')} )} {onCreateWorkspace && ( )}
{/* Hosts section */} {results.length > 0 && (
Hosts
{results.map((host) => ( setSelectedIndex(getItemIndex("host", host.id))} /> ))}
)} {/* Tabs section */}
Tabs
{/* Built-in tabs */} {builtInTabs.map((tabId) => { const idx = getItemIndex("tab", tabId); const isSelected = idx === selectedIndex; const icon = tabId === "vault" ? ( ) : ( ); const label = tabId === "vault" ? "Vaults" : "SFTP"; return (
{ onSelectTab(tabId); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} >
{icon}
{label}
); })} {/* Workspaces */} {filteredWorkspaces.map((workspace) => { const idx = getItemIndex("workspace", workspace.id); const isSelected = idx === selectedIndex; return (
{ onSelectTab(workspace.id); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} >
{workspace.title}
); })} {/* Orphan sessions */} {filteredOrphanSessions.map((session) => { const idx = getItemIndex("tab", session.id); const isSelected = idx === selectedIndex; return (
{ onSelectTab(session.id); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} >
{session.hostLabel}
); })}
{/* Local Shells section */} {/* Local Shells or fallback Local Terminal */} {filteredShells.length > 0 ? (
{t("qs.localShells")}
{filteredShells.map((shell) => { const idx = getItemIndex("shell", shell.id); const isSelected = idx === selectedIndex; return (
{ if (onCreateLocalTerminal) { onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon }); onClose(); } }} onMouseEnter={() => setSelectedIndex(idx)} > {shell.name} {shell.name} {shell.isDefault && ( {t("qs.default")} )}
); })}
) : shouldShowLocalTerminalFallback && (
{t("qs.localShells")}
{ onCreateLocalTerminal(); onClose(); }} onMouseEnter={() => setSelectedIndex(getItemIndex("action", "local-terminal")) } >
{t("qs.localTerminal")}
)}
); }; export const QuickSwitcher = memo(QuickSwitcherInner); QuickSwitcher.displayName = "QuickSwitcher";