From 48dc5aa6a873ca3b9429bf5cf9e9debbcd100acd Mon Sep 17 00:00:00 2001 From: "LAPTOP-O016UC3M\\Qi Chen" <16399091+binaricat@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:05:06 +0800 Subject: [PATCH] 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. --- components/KnownHostsManager.tsx | 140 +++++++------------------- components/PortForwardingNew.tsx | 60 ++++------- components/SnippetsManager.tsx | 24 ++--- components/VaultView.tsx | 76 +++++++------- components/ui/dropdown.tsx | 21 ++-- components/ui/popover.tsx | 32 +++--- components/ui/sort-dropdown.tsx | 26 ++--- components/ui/tag-filter-dropdown.tsx | 14 +-- electron/main.cjs | 38 +++++-- 9 files changed, 185 insertions(+), 246 deletions(-) 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); + } } };