import { Folder, LayoutGrid, Search, Shield, 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"; type QuickSwitcherItem = { type: "host" | "tab" | "workspace" | "action"; id: string; data?: Host | TerminalSession | Workspace; }; import { DistroAvatar } from "./DistroAvatar"; import { Button } from "./ui/button"; 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?: () => void; onCreateWorkspace?: () => void; keyBindings?: KeyBinding[]; } const QuickSwitcherInner: React.FC = ({ isOpen, query, results, sessions, workspaces, onQueryChange, onSelect, onSelectTab, onClose, onCreateLocalTerminal, onCreateWorkspace, keyBindings, }) => { const { t } = useI18n(); // 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 [isFocused, setIsFocused] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const containerRef = useRef(null); // Reset state when opening useEffect(() => { if (isOpen) { setIsFocused(false); setSelectedIndex(0); // Auto focus the input after a short delay setTimeout(() => { inputRef.current?.focus(); }, 50); } }, [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 showCategorized = isFocused || query.trim().length > 0; // 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) items.push({ type: "tab", id: "vault" }); items.push({ type: "tab", id: "sftp" }); orphanSessions.forEach((s) => items.push({ type: "tab", id: s.id, data: s }), ); workspaces.forEach((w) => items.push({ type: "workspace", id: w.id, data: w }), ); // Quick connect actions items.push({ type: "action", id: "local-terminal" }); } else { // Recent connections only results.forEach((host) => items.push({ type: "host", id: host.id, data: host }), ); } // 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, orphanSessions, workspaces]); // 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; } }; return (
{/* Search Header */}
{ onQueryChange(e.target.value); setSelectedIndex(0); }} onFocus={() => setIsFocused(true)} 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, '+')} )}
{!showCategorized ? ( /* Default view: Recent connections with header */
{t("qs.recentConnections")}
{results.length > 0 ? ( results.map((host) => ( setSelectedIndex(getItemIndex("host", host.id))} /> )) ) : (
No recent connections
)}
) : ( /* Focused/searching view: Categorized items */
{/* Jump To hint */}
{t("qs.jumpTo")} {quickSwitchKey && ( {quickSwitchKey.replace(/ \+ /g, '+')} )}
{/* Hosts section */} {results.length > 0 && (
Hosts
{results.map((host) => ( setSelectedIndex(getItemIndex("host", host.id))} /> ))}
)} {/* Tabs section */}
Tabs
{/* Built-in tabs */} {["vault", "sftp"].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 */} {workspaces.map((workspace) => { const idx = getItemIndex("workspace", workspace.id); const isSelected = idx === selectedIndex; return (
{ onSelectTab(workspace.id); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} >
{workspace.title}
); })} {/* Orphan sessions */} {orphanSessions.map((session) => { const idx = getItemIndex("tab", session.id); const isSelected = idx === selectedIndex; return (
{ onSelectTab(session.id); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} >
{session.hostLabel}
); })}
{/* Quick connect section */}
Quick connect
{/* Local Terminal */} {onCreateLocalTerminal && (
{ onCreateLocalTerminal(); onClose(); }} onMouseEnter={() => setSelectedIndex(getItemIndex("action", "local-terminal")) } >
{t("qs.localTerminal")}
)} {/* Serial removed (not supported) */}
)}
); }; export const QuickSwitcher = memo(QuickSwitcherInner); QuickSwitcher.displayName = "QuickSwitcher";