mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
Replaces popover menus with dropdowns and improves UX
Unifies UI controls by switching popover-based menus to dropdown components for view mode, sort, and tag filtering across multiple editors. Refactors dropdown rendering to use portals for more reliable positioning, and updates menu trigger icons for clarity. Handles EPIPE and stream errors gracefully in Electron to prevent crashes when writing to closed streams, improving terminal stability. Enhances code maintainability and ensures consistent user experience.
This commit is contained in:
@@ -11,22 +11,16 @@ import {
|
||||
Shield,
|
||||
FolderOpen,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ArrowDownAZ,
|
||||
ArrowUpAZ,
|
||||
Calendar,
|
||||
Check,
|
||||
List as ListIcon,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { KnownHost, Host } from '../types';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from './ui/popover';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
@@ -40,7 +34,6 @@ interface KnownHostsManagerProps {
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortBy = 'name-asc' | 'name-desc' | 'newest' | 'oldest';
|
||||
|
||||
// Helper functions outside component for stable references
|
||||
const formatDateFn = (timestamp: number) => {
|
||||
@@ -250,9 +243,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('newest');
|
||||
const [viewPopoverOpen, setViewPopoverOpen] = useState(false);
|
||||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
@@ -285,10 +276,10 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name-asc':
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.hostname.localeCompare(b.hostname);
|
||||
case 'name-desc':
|
||||
case 'za':
|
||||
return b.hostname.localeCompare(a.hostname);
|
||||
case 'newest':
|
||||
return b.discoveredAt - a.discoveredAt;
|
||||
@@ -300,7 +291,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [knownHosts, deferredSearch, sortBy]);
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
// Limit rendered items for performance
|
||||
const displayedHosts = useMemo(() => {
|
||||
@@ -383,13 +374,6 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
onConvertToHost(knownHost);
|
||||
}, [onConvertToHost]);
|
||||
|
||||
// View/Sort handlers
|
||||
const setGridView = useCallback(() => { setViewMode('grid'); setViewPopoverOpen(false); }, []);
|
||||
const setListView = useCallback(() => { setViewMode('list'); setViewPopoverOpen(false); }, []);
|
||||
const setSortNameAsc = useCallback(() => { setSortBy('name-asc'); setSortPopoverOpen(false); }, []);
|
||||
const setSortNameDesc = useCallback(() => { setSortBy('name-desc'); setSortPopoverOpen(false); }, []);
|
||||
const setSortNewest = useCallback(() => { setSortBy('newest'); setSortPopoverOpen(false); }, []);
|
||||
const setSortOldest = useCallback(() => { setSortBy('oldest'); setSortPopoverOpen(false); }, []);
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
@@ -426,93 +410,37 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* View Mode Toggle */}
|
||||
<Popover open={viewPopoverOpen} onOpenChange={setViewPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <List size={16} />}
|
||||
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <ListIcon size={16} />}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="end">
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors",
|
||||
viewMode === 'grid' && "bg-secondary"
|
||||
)}
|
||||
onClick={setGridView}
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Grid
|
||||
{viewMode === 'grid' && <Check size={14} className="ml-auto" />}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors",
|
||||
viewMode === 'list' && "bg-secondary"
|
||||
)}
|
||||
onClick={setListView}
|
||||
<LayoutGrid size={14} /> Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List size={14} />
|
||||
List
|
||||
{viewMode === 'list' && <Check size={14} className="ml-auto" />}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ListIcon size={14} /> List
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Sort Toggle */}
|
||||
<Popover open={sortPopoverOpen} onOpenChange={setSortPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Calendar size={16} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="end">
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors",
|
||||
sortBy === 'name-asc' && "bg-secondary"
|
||||
)}
|
||||
onClick={setSortNameAsc}
|
||||
>
|
||||
<ArrowDownAZ size={14} />
|
||||
A-z
|
||||
{sortBy === 'name-asc' && <Check size={14} className="ml-auto" />}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors",
|
||||
sortBy === 'name-desc' && "bg-secondary"
|
||||
)}
|
||||
onClick={setSortNameDesc}
|
||||
>
|
||||
<ArrowUpAZ size={14} />
|
||||
Z-a
|
||||
{sortBy === 'name-desc' && <Check size={14} className="ml-auto" />}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors whitespace-nowrap",
|
||||
sortBy === 'newest' && "bg-secondary"
|
||||
)}
|
||||
onClick={setSortNewest}
|
||||
>
|
||||
<Calendar size={14} className="flex-shrink-0" />
|
||||
Newest to oldest
|
||||
{sortBy === 'newest' && <Check size={14} className="ml-auto flex-shrink-0" />}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-secondary transition-colors whitespace-nowrap",
|
||||
sortBy === 'oldest' && "bg-secondary"
|
||||
)}
|
||||
onClick={setSortOldest}
|
||||
>
|
||||
<Calendar size={14} className="flex-shrink-0" />
|
||||
Oldest to newest
|
||||
{sortBy === 'oldest' && <Check size={14} className="ml-auto flex-shrink-0" />}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -31,7 +31,8 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter, AsideActionMenu, Aside
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Dropdown, DropdownTrigger, DropdownContent } from './ui/dropdown';
|
||||
import { SortDropdown } from './ui/sort-dropdown';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from './ui/dialog';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator } from './ui/context-menu';
|
||||
@@ -80,13 +81,6 @@ const TYPE_ICONS: Record<PortForwardingType, React.ReactNode> = {
|
||||
dynamic: <Shuffle size={16} />,
|
||||
};
|
||||
|
||||
const SORT_LABELS: Record<SortMode, { label: string; icon: React.ReactNode }> = {
|
||||
az: { label: 'A-z', icon: <SortAsc size={14} /> },
|
||||
za: { label: 'Z-a', icon: <SortDesc size={14} /> },
|
||||
newest: { label: 'Newest to oldest', icon: <Calendar size={14} /> },
|
||||
oldest: { label: 'Oldest to newest', icon: <CalendarClock size={14} /> },
|
||||
};
|
||||
|
||||
const PortForwarding: React.FC<PortForwardingProps> = ({ hosts, keys, customGroups, onNewHost, onSaveHost, onCreateGroup }) => {
|
||||
const {
|
||||
rules,
|
||||
@@ -876,15 +870,15 @@ const PortForwarding: React.FC<PortForwardingProps> = ({ hosts, keys, customGrou
|
||||
<div className={cn("flex-1 flex flex-col min-h-0", (showWizard || showEditPanel || showNewForm) ? "mr-[360px]" : "")}>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 flex items-center gap-3 bg-secondary/60 border-b border-border/60 relative z-20">
|
||||
<Popover open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="secondary" className="h-9 px-3 gap-2">
|
||||
<Zap size={14} />
|
||||
NEW FORWARDING
|
||||
<ChevronDown size={14} className={cn("transition-transform", showNewMenu ? "rotate-180" : "")} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1 z-[9999]" align="start" sideOffset={8}>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-52" align="start" sideOffset={8}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 h-10"
|
||||
@@ -909,8 +903,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({ hosts, keys, customGrou
|
||||
<Shuffle size={16} className="text-purple-500" />
|
||||
Dynamic Forwarding
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
@@ -924,14 +918,14 @@ const PortForwarding: React.FC<PortForwardingProps> = ({ hosts, keys, customGrou
|
||||
</div>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <ListIcon size={16} />}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-32 p-1 z-50" align="end">
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
@@ -948,31 +942,15 @@ const PortForwarding: React.FC<PortForwardingProps> = ({ hosts, keys, customGrou
|
||||
<ListIcon size={14} /> List
|
||||
{viewMode === 'list' && <Check size={12} className="ml-auto" />}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Sort mode toggle */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
{SORT_LABELS[sortMode].icon}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1 z-50" align="end">
|
||||
{(Object.keys(SORT_LABELS) as SortMode[]).map(mode => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={sortMode === mode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setSortMode(mode)}
|
||||
>
|
||||
{SORT_LABELS[mode].icon} {SORT_LABELS[mode].label}
|
||||
{sortMode === mode && <Check size={12} className="ml-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
import { Combobox, ComboboxOption } from './ui/combobox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Dropdown, DropdownTrigger, DropdownContent } from './ui/dropdown';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
@@ -479,7 +479,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex gap-3 relative">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
||||
{/* Search box */}
|
||||
<div className="relative w-64">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -514,14 +514,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</Button>
|
||||
{/* View mode and sort controls */}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <ListIcon size={16} />}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-32 p-1 z-50" align="end">
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
@@ -536,8 +536,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
>
|
||||
<ListIcon size={14} /> List
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
@@ -545,10 +545,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground px-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold px-4">
|
||||
<button className="text-primary hover:underline" onClick={() => setSelectedPackage(null)}>All packages</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<span key={b.path} className="flex items-center gap-1">
|
||||
<span key={b.path} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button className="text-primary hover:underline" onClick={() => setSelectedPackage(b.path)}>{b.name}</button>
|
||||
</span>
|
||||
@@ -556,7 +556,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
|
||||
{!snippets.length && displayedPackages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center px-3">
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
|
||||
<FileCode size={22} />
|
||||
@@ -567,7 +567,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-3 pb-3">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
|
||||
{displayedPackages.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+38
-38
@@ -31,7 +31,7 @@ import QuickConnectWizard, { parseQuickConnectInput, isQuickConnectInput } from
|
||||
import ProtocolSelectDialog from './ProtocolSelectDialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Dropdown, DropdownTrigger, DropdownContent } from './ui/dropdown';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -476,14 +476,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</Button>
|
||||
{/* View mode, tag filter, and sort controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <List size={16} />}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-32 p-1 z-50" align="end">
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
@@ -498,8 +498,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<List size={14} /> List
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
@@ -516,43 +516,43 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> New Host
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> New Host
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => { setTargetParentPath(selectedGroupPath); setIsNewFolderOpen(true); }}
|
||||
>
|
||||
<FolderTree size={14} /> New Group
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
// TODO: Import hosts
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> Import
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => { setTargetParentPath(selectedGroupPath); setIsNewFolderOpen(true); }}
|
||||
>
|
||||
<FolderTree size={14} /> New Group
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
// TODO: Import hosts
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> Import
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" className="h-10 px-3" onClick={onCreateLocalTerminal}>
|
||||
<TerminalSquare size={14} className="mr-2" /> Terminal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
interface DropdownContextValue {
|
||||
@@ -154,14 +155,17 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
// Calculate position synchronously after DOM updates
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
// Initial calculation
|
||||
const pos = calculatePosition()
|
||||
if (pos) setPosition(pos)
|
||||
// Reset position first to hide content while calculating
|
||||
setPosition(null)
|
||||
|
||||
// Recalculate after a frame to ensure content is fully rendered
|
||||
// Use double requestAnimationFrame to ensure content is fully rendered
|
||||
// First frame: content is added to DOM
|
||||
// Second frame: layout is calculated, offsetWidth is available
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calculatePosition()
|
||||
if (pos) setPosition(pos)
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calculatePosition()
|
||||
if (pos) setPosition(pos)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
setPosition(null)
|
||||
@@ -204,7 +208,7 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
@@ -218,7 +222,8 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
-13
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { useLayoutEffect, useState, useRef } from "react"
|
||||
import { useLayoutEffect, useState, useRef, useCallback } from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
@@ -16,31 +16,37 @@ const PopoverContent = React.forwardRef<
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
// Fix for Electron: ensure position is calculated after content is fully rendered
|
||||
const [isPositioned, setIsPositioned] = useState(false)
|
||||
const innerRef = useRef<HTMLDivElement>(null)
|
||||
const [node, setNode] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
// Combine refs
|
||||
const combinedRef = (node: HTMLDivElement) => {
|
||||
innerRef.current = node
|
||||
// Use callback ref to detect when element is mounted
|
||||
const callbackRef = useCallback((element: HTMLDivElement | null) => {
|
||||
setNode(element)
|
||||
// Forward ref
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
ref(element)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
ref.current = element
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Reset on each open
|
||||
if (!node) {
|
||||
setIsPositioned(false)
|
||||
return
|
||||
}
|
||||
// Element just mounted, wait for next frame to ensure position is calculated
|
||||
setIsPositioned(false)
|
||||
// Wait for next frame to ensure content is rendered
|
||||
requestAnimationFrame(() => {
|
||||
setIsPositioned(true)
|
||||
requestAnimationFrame(() => {
|
||||
setIsPositioned(true)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
}, [node])
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={combinedRef}
|
||||
ref={callbackRef}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
// Force position recalculation on every animation frame
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Check, ChevronDown, ChevronUp, SortAsc, SortDesc, Calendar, CalendarClock } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from './dropdown';
|
||||
|
||||
export type SortMode = 'az' | 'za' | 'newest' | 'oldest';
|
||||
|
||||
export const SORT_OPTIONS: Record<SortMode, { label: string; icon: React.ReactNode }> = {
|
||||
az: { label: 'A-z', icon: <SortAsc size={14} /> },
|
||||
za: { label: 'Z-a', icon: <SortDesc size={14} /> },
|
||||
newest: { label: 'Newest to oldest', icon: <Calendar size={14} /> },
|
||||
oldest: { label: 'Oldest to newest', icon: <CalendarClock size={14} /> },
|
||||
export const SORT_OPTIONS: Record<SortMode, { label: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
|
||||
az: { label: 'A-z', icon: <SortAsc className="w-4 h-4 shrink-0" />, triggerIcon: <SortAsc className="w-4 h-4" /> },
|
||||
za: { label: 'Z-a', icon: <SortDesc className="w-4 h-4 shrink-0" />, triggerIcon: <SortDesc className="w-4 h-4" /> },
|
||||
newest: { label: 'Newest to oldest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
|
||||
oldest: { label: 'Oldest to newest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
|
||||
};
|
||||
|
||||
interface SortDropdownProps {
|
||||
@@ -22,14 +22,14 @@ export const SortDropdown: React.FC<SortDropdownProps> = ({ value, onChange, cla
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={className || "h-8 w-8"}>
|
||||
{SORT_OPTIONS[value].icon}
|
||||
{SORT_OPTIONS[value].triggerIcon}
|
||||
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1 z-[60]" align="end">
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-44" align="end">
|
||||
{(Object.keys(SORT_OPTIONS) as SortMode[]).map(mode => (
|
||||
<Button
|
||||
key={mode}
|
||||
@@ -44,8 +44,8 @@ export const SortDropdown: React.FC<SortDropdownProps> = ({ value, onChange, cla
|
||||
{value === mode && <Check size={12} className="ml-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Check, ChevronDown, ChevronUp, Tag, Search, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from './dropdown';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
@@ -113,8 +113,8 @@ export const TagFilterDropdown: React.FC<TagFilterDropdownProps> = ({
|
||||
const canDelete = !!onDeleteTag;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -126,8 +126,8 @@ export const TagFilterDropdown: React.FC<TagFilterDropdownProps> = ({
|
||||
<Tag size={14} />
|
||||
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1 z-[60]" align="end">
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-64" align="end">
|
||||
{allTags.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No tags available
|
||||
@@ -245,8 +245,8 @@ export const TagFilterDropdown: React.FC<TagFilterDropdownProps> = ({
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+30
-8
@@ -5,6 +5,18 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions for EPIPE errors (when writing to closed streams)
|
||||
process.on('uncaughtException', (err) => {
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
// Ignore EPIPE errors - these happen when terminal is closed while data is being written
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Re-throw other errors
|
||||
console.error('Uncaught exception:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
let electronModule;
|
||||
try {
|
||||
electronModule = require("node:electron");
|
||||
@@ -661,13 +673,20 @@ const registerSSHBridge = (win) => {
|
||||
const write = (_event, payload) => {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
// SSH sessions use stream, local terminal uses proc, telnet-native uses socket
|
||||
if (session.stream) {
|
||||
session.stream.write(payload.data);
|
||||
} else if (session.proc) {
|
||||
session.proc.write(payload.data);
|
||||
} else if (session.socket) {
|
||||
session.socket.write(payload.data);
|
||||
try {
|
||||
// SSH sessions use stream, local terminal uses proc, telnet-native uses socket
|
||||
if (session.stream) {
|
||||
session.stream.write(payload.data);
|
||||
} else if (session.proc) {
|
||||
session.proc.write(payload.data);
|
||||
} else if (session.socket) {
|
||||
session.socket.write(payload.data);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore EPIPE errors when process is already closed
|
||||
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
||||
console.warn("Write failed", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -696,7 +715,10 @@ const registerSSHBridge = (win) => {
|
||||
session.socket.write(buf);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Resize failed", err);
|
||||
// Ignore errors when process is already closed
|
||||
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
||||
console.warn("Resize failed", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user