diff --git a/components/KnownHostsManager.tsx b/components/KnownHostsManager.tsx index 98004463b..ba1f7eaa3 100644 --- a/components/KnownHostsManager.tsx +++ b/components/KnownHostsManager.tsx @@ -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 = ({ const deferredSearch = useDeferredValue(search); const [isScanning, setIsScanning] = useState(false); const [viewMode, setViewMode] = useState('grid'); - const [sortBy, setSortBy] = useState('newest'); - const [viewPopoverOpen, setViewPopoverOpen] = useState(false); - const [sortPopoverOpen, setSortPopoverOpen] = useState(false); + const [sortMode, setSortMode] = useState('newest'); const fileInputRef = React.useRef(null); const hasScannedRef = React.useRef(false); const RENDER_LIMIT = 100; // Limit rendered items for performance @@ -285,10 +276,10 @@ const KnownHostsManager: React.FC = ({ // 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 = ({ }); return result; - }, [knownHosts, deferredSearch, sortBy]); + }, [knownHosts, deferredSearch, sortMode]); // Limit rendered items for performance const displayedHosts = useMemo(() => { @@ -383,13 +374,6 @@ const KnownHostsManager: React.FC = ({ 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 = ({
{/* View Mode Toggle */} - - + + - - - - + - - + List + + + {/* Sort Toggle */} - - - - - - - -
- - - - +
diff --git a/components/PortForwardingNew.tsx b/components/PortForwardingNew.tsx index f7076e0f6..1602451e1 100644 --- a/components/PortForwardingNew.tsx +++ b/components/PortForwardingNew.tsx @@ -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 = { dynamic: , }; -const SORT_LABELS: Record = { - az: { label: 'A-z', icon: }, - za: { label: 'Z-a', icon: }, - newest: { label: 'Newest to oldest', icon: }, - oldest: { label: 'Oldest to newest', icon: }, -}; - const PortForwarding: React.FC = ({ hosts, keys, customGroups, onNewHost, onSaveHost, onCreateGroup }) => { const { rules, @@ -876,15 +870,15 @@ const PortForwarding: React.FC = ({ hosts, keys, customGrou
{/* Toolbar */}
- - + + - - + + - - + +
@@ -924,14 +918,14 @@ const PortForwarding: React.FC = ({ hosts, keys, customGrou
{/* View mode toggle */} - - + + - - + + - - + + {/* Sort mode toggle */} - - - - - - {(Object.keys(SORT_LABELS) as SortMode[]).map(mode => ( - - ))} - - +
diff --git a/components/SnippetsManager.tsx b/components/SnippetsManager.tsx index 2ad4252a0..6f88d3122 100644 --- a/components/SnippetsManager.tsx +++ b/components/SnippetsManager.tsx @@ -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 = ({ return (
-
+
{/* Search box */}
@@ -514,14 +514,14 @@ const SnippetsManager: React.FC = ({ {/* View mode and sort controls */}
- - + + - - + + - - + + = ({ />
-
+
{breadcrumb.map((b) => ( - + › @@ -556,7 +556,7 @@ const SnippetsManager: React.FC = ({
{!snippets.length && displayedPackages.length === 0 && ( -
+
@@ -567,7 +567,7 @@ const SnippetsManager: React.FC = ({
)} -
+
{displayedPackages.length > 0 && ( <>
diff --git a/components/VaultView.tsx b/components/VaultView.tsx index a7ecec0e4..6d376dd17 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -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 = ({ {/* View mode, tag filter, and sort controls */}
- - + + - - + + - - + + = ({
{/* New Host split button */}
-
- - - + +
+ + - - - - - - -
+ +
+ + + + +
, + document.body ) } diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx index d9195efd0..39a4e26a2 100755 --- a/components/ui/popover.tsx +++ b/components/ui/popover.tsx @@ -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(null) + const [node, setNode] = useState(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 ( = { - az: { label: 'A-z', icon: }, - za: { label: 'Z-a', icon: }, - newest: { label: 'Newest to oldest', icon: }, - oldest: { label: 'Oldest to newest', icon: }, +export const SORT_OPTIONS: Record = { + az: { label: 'A-z', icon: , triggerIcon: }, + za: { label: 'Z-a', icon: , triggerIcon: }, + newest: { label: 'Newest to oldest', icon: , triggerIcon: }, + oldest: { label: 'Oldest to newest', icon: , triggerIcon: }, }; interface SortDropdownProps { @@ -22,14 +22,14 @@ export const SortDropdown: React.FC = ({ value, onChange, cla const [open, setOpen] = React.useState(false); return ( - - + + - - + + {(Object.keys(SORT_OPTIONS) as SortMode[]).map(mode => ( ))} - - + + ); }; diff --git a/components/ui/tag-filter-dropdown.tsx b/components/ui/tag-filter-dropdown.tsx index 49416d509..60ec61255 100644 --- a/components/ui/tag-filter-dropdown.tsx +++ b/components/ui/tag-filter-dropdown.tsx @@ -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 = ({ const canDelete = !!onDeleteTag; return ( - - + + - - + + {allTags.length === 0 ? (
No tags available @@ -245,8 +245,8 @@ export const TagFilterDropdown: React.FC = ({ )} - - + + ); }; diff --git a/electron/main.cjs b/electron/main.cjs index 22a9827d8..5cd796a11 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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); + } } };