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:
LAPTOP-O016UC3M\Qi Chen
2025-12-11 12:05:06 +08:00
parent 7a710caa60
commit 48dc5aa6a8
9 changed files with 185 additions and 246 deletions
+34 -106
View File
@@ -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">
+19 -41
View File
@@ -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>
+12 -12
View File
@@ -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
View File
@@ -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
+13 -8
View File
@@ -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
View File
@@ -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
+13 -13
View File
@@ -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>
);
};
+7 -7
View File
@@ -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
View File
@@ -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);
}
}
};