mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
b8c221d112
Reorder and normalize import statements, group icon imports, and add missing utility imports (cn). Fix export/newline formatting and minor type import tweaks. Add ESLint scripts to package.json and update the lockfile.
338 lines
12 KiB
TypeScript
338 lines
12 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 { 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;
|
|
}
|
|
|
|
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
|
isOpen,
|
|
query,
|
|
results,
|
|
sessions,
|
|
workspaces,
|
|
onQueryChange,
|
|
onSelect,
|
|
onSelectTab,
|
|
onClose,
|
|
onCreateLocalTerminal,
|
|
onCreateWorkspace,
|
|
}) => {
|
|
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: Array<{ type: 'host' | 'tab' | 'workspace' | 'action'; id: string; data?: any }> = [];
|
|
|
|
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: { type: string; id: string; data?: any }) => {
|
|
switch (item.type) {
|
|
case 'host':
|
|
onSelect(item.data);
|
|
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"
|
|
/>
|
|
<kbd className="text-[11px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">Ctrl+K</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>
|
|
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">Ctrl+Shift+J</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';
|