mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
aab977f194
Upgrades multiple dependencies, including node-pty and Tailwind CSS, to improve compatibility and receive upstream fixes. Removes an unused theme card component to reduce code clutter. Also makes minor code cleanups for consistency.
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import {
|
|
Folder,
|
|
LayoutGrid,
|
|
Search,
|
|
Shield,
|
|
Terminal,
|
|
TerminalSquare,
|
|
} from "lucide-react";
|
|
import React, { memo, useEffect, useRef, useState } from "react";
|
|
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";
|
|
|
|
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<QuickSwitcherProps> = ({
|
|
isOpen,
|
|
query,
|
|
results,
|
|
sessions,
|
|
workspaces,
|
|
onQueryChange,
|
|
onSelect,
|
|
onSelectTab,
|
|
onClose,
|
|
onCreateLocalTerminal,
|
|
onCreateWorkspace,
|
|
keyBindings,
|
|
}) => {
|
|
// Get hotkey display strings
|
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
const getHotkeyLabel = (actionId: string) => {
|
|
const binding = keyBindings?.find(k => k.id === actionId);
|
|
if (!binding) return '';
|
|
return isMac ? binding.mac : binding.pc;
|
|
};
|
|
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(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]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const showCategorized = isFocused || query.trim().length > 0;
|
|
|
|
// Get orphan sessions (sessions without workspace)
|
|
const orphanSessions = sessions.filter((s) => !s.workspaceId);
|
|
|
|
// Build categorized items for navigation
|
|
const buildFlatItems = () => {
|
|
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 }),
|
|
);
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const flatItems = buildFlatItems();
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
// Helper to get item index in flat list
|
|
const getItemIndex = (type: string, id: string) => {
|
|
return flatItems.findIndex((item) => item.type === type && item.id === id);
|
|
};
|
|
|
|
const renderHostItem = (host: Host) => {
|
|
const idx = getItemIndex("host", host.id);
|
|
const isSelected = idx === selectedIndex;
|
|
|
|
return (
|
|
<div
|
|
key={host.id}
|
|
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={() => setSelectedIndex(idx)}
|
|
>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
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);
|
|
}}
|
|
onFocus={() => setIsFocused(true)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Search hosts or tabs"
|
|
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">
|
|
{!showCategorized ? (
|
|
/* Default view: Recent connections with header */
|
|
<div>
|
|
<div className="px-4 py-2 flex items-center justify-between">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Recent connections
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 px-2 text-[11px]"
|
|
onClick={() => onCreateWorkspace?.()}
|
|
>
|
|
Create a workspace
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 px-2 text-[11px]"
|
|
disabled
|
|
>
|
|
Restore
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{results.length > 0 ? (
|
|
results.map(renderHostItem)
|
|
) : (
|
|
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
|
No recent connections
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Focused/searching view: Categorized items */
|
|
<div>
|
|
{/* Jump To hint */}
|
|
<div className="px-4 py-2 flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">Jump To</span>
|
|
{quickSwitchKey && (
|
|
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
|
{quickSwitchKey.replace(/ \+ /g, '+')}
|
|
</kbd>
|
|
)}
|
|
</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(renderHostItem)}
|
|
</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 */}
|
|
{["vault", "sftp"].map((tabId) => {
|
|
const idx = getItemIndex("tab", tabId);
|
|
const isSelected = idx === selectedIndex;
|
|
const icon =
|
|
tabId === "vault" ? (
|
|
<Shield 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 */}
|
|
{workspaces.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 */}
|
|
{orphanSessions.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>
|
|
|
|
{/* Quick connect section */}
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Quick connect
|
|
</span>
|
|
</div>
|
|
|
|
{/* Local Terminal */}
|
|
{onCreateLocalTerminal && (
|
|
<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">Local Terminal</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Serial removed (not supported) */}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const QuickSwitcher = memo(QuickSwitcherInner);
|
|
QuickSwitcher.displayName = "QuickSwitcher";
|