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.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";