mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
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;
|
|
}) => (
|
|
<div
|
|
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => onSelect(host)}
|
|
onMouseEnter={onMouseEnter}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<DistroAvatar
|
|
host={host}
|
|
fallback={host.label.slice(0, 2).toUpperCase()}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm font-medium truncate">{host.label}</span>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{host.group ? `Personal / ${host.group}` : "Personal"}
|
|
</div>
|
|
</div>
|
|
));
|
|
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<QuickSwitcherProps> = ({
|
|
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<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(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<string, number>();
|
|
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 (
|
|
<div
|
|
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full max-w-2xl mx-4 bg-background border border-border rounded-xl shadow-2xl overflow-hidden max-h-[520px] flex flex-col"
|
|
style={{ pointerEvents: "auto" }}
|
|
>
|
|
{/* Search Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
<Search size={16} className="text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<kbd className="text-[11px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{quickSwitchKey.replace(/ \+ /g, '+')}
|
|
</kbd>
|
|
)}
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 h-full">
|
|
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
|
<div>
|
|
{/* Jump To hint + New Workspace action */}
|
|
<div className="px-4 py-2 flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
|
{quickSwitchKey && (
|
|
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
|
{quickSwitchKey.replace(/ \+ /g, '+')}
|
|
</kbd>
|
|
)}
|
|
{onCreateWorkspace && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onCreateWorkspace();
|
|
onClose();
|
|
}}
|
|
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
|
>
|
|
<Plus size={11} />
|
|
<span>New Workspace</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hosts section */}
|
|
{results.length > 0 && (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Hosts
|
|
</span>
|
|
</div>
|
|
{results.map((host) => (
|
|
<HostItem
|
|
key={host.id}
|
|
host={host}
|
|
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
|
onSelect={onSelect}
|
|
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs section */}
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Tabs
|
|
</span>
|
|
</div>
|
|
|
|
{/* Built-in tabs */}
|
|
{builtInTabs.map((tabId) => {
|
|
const idx = getItemIndex("tab", tabId);
|
|
const isSelected = idx === selectedIndex;
|
|
const icon =
|
|
tabId === "vault" ? (
|
|
<FolderLock size={16} />
|
|
) : (
|
|
<Folder size={16} />
|
|
);
|
|
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
|
|
|
return (
|
|
<div
|
|
key={tabId}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(tabId);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
{icon}
|
|
</div>
|
|
<span className="text-sm font-medium">{label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Workspaces */}
|
|
{filteredWorkspaces.map((workspace) => {
|
|
const idx = getItemIndex("workspace", workspace.id);
|
|
const isSelected = idx === selectedIndex;
|
|
|
|
return (
|
|
<div
|
|
key={workspace.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(workspace.id);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<LayoutGrid size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{workspace.title}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Orphan sessions */}
|
|
{filteredOrphanSessions.map((session) => {
|
|
const idx = getItemIndex("tab", session.id);
|
|
const isSelected = idx === selectedIndex;
|
|
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(session.id);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<TerminalSquare size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{session.hostLabel}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Local Shells section */}
|
|
{/* Local Shells or fallback Local Terminal */}
|
|
{filteredShells.length > 0 ? (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t("qs.localShells")}
|
|
</span>
|
|
</div>
|
|
{filteredShells.map((shell) => {
|
|
const idx = getItemIndex("shell", shell.id);
|
|
const isSelected = idx === selectedIndex;
|
|
return (
|
|
<div
|
|
key={shell.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
|
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
if (onCreateLocalTerminal) {
|
|
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
|
onClose();
|
|
}
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<img
|
|
src={getShellIconPath(shell.icon)}
|
|
alt={shell.name}
|
|
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
|
/>
|
|
<span className="text-sm font-medium">{shell.name}</span>
|
|
{shell.isDefault && (
|
|
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{t("qs.default")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : shouldShowLocalTerminalFallback && (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t("qs.localShells")}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
|
getItemIndex("action", "local-terminal") === selectedIndex
|
|
? "bg-primary/15"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onCreateLocalTerminal();
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() =>
|
|
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
|
}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<Terminal size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const QuickSwitcher = memo(QuickSwitcherInner);
|
|
QuickSwitcher.displayName = "QuickSwitcher";
|