Files
Netcatty/components/vault/VaultViewLayout.tsx
T
2026-06-29 23:26:53 +08:00

1060 lines
47 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
import { VaultHostListSection } from "./VaultHostListSection";
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./VaultPageHeader";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
type VaultViewLayoutContext = Record<string, any>;
const VaultSectionLoading = () => (
<div className="netcatty-lazy-fade-in min-h-[320px] flex-1" aria-hidden="true" />
);
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const { Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, handleSidebarWidthCommit, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, reorderGroup, reorderHost, RippleButton, rootRef, sanitizeHost, search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap } = ctx;
const { knownHosts, noteGroups, NotebookText, notes, NotesManager, onOpenHostFromNote, onOpenNoteIdHandled, onOpenSnippetIdHandled, onUpdateNoteGroups, onUpdateNotes, openNoteId, openSnippetId } = ctx;
const vaultHostPanelResizeProps = {
resizable: true as const,
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const newHostActionsRef = React.useRef<HTMLDivElement>(null);
const sessionActionsRef = React.useRef<HTMLDivElement>(null);
const sidebarMinWidth = 56;
const sidebarMaxWidth = 320;
const effectiveSidebarWidth = Math.max(
sidebarMinWidth,
Math.min(sidebarMaxWidth, Number(sidebarWidth) || 208),
);
const handleSidebarResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = effectiveSidebarWidth;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
setIsSidebarResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const clampWidth = (value: number) =>
Math.max(sidebarMinWidth, Math.min(sidebarMaxWidth, value));
const handlePointerMove = (moveEvent: PointerEvent) => {
setSidebarWidth(clampWidth(startWidth + moveEvent.clientX - startX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
setSidebarWidth(nextWidth);
handleSidebarWidthCommit(nextWidth);
setIsSidebarResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, [effectiveSidebarWidth, handleSidebarWidthCommit, setSidebarWidth]);
React.useEffect(() => {
if (!isHostPanelOpen) return;
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (
newHostActionsRef.current?.contains(activeElement)
|| sessionActionsRef.current?.contains(activeElement)
) {
activeElement.blur();
}
}, [isHostPanelOpen]);
return (
<div ref={rootRef} className="absolute inset-0 min-h-0 flex bg-secondary" data-section="vault-view">
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div
className={cn(
"relative shrink-0 bg-secondary flex flex-col",
isSidebarResizing ? "transition-none" : "transition-[width] duration-200",
)}
style={{ width: effectiveSidebarWidth }}
data-section="vault-sidebar"
>
<div className={cn(
"pt-5 pb-6 flex items-center",
sidebarCollapsed ? "px-2 justify-center" : "px-4"
)}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
>
<AppLogo className="h-8 w-8 flex-shrink-0" />
{!sidebarCollapsed && (
<p className="text-xl font-black italic tracking-tight text-foreground leading-none">
Netcatty
</p>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
</TooltipContent>
</Tooltip>
</div>
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "hosts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "hosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
setSelectedGroupPath(null);
}}
>
<LayoutGrid size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.hosts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "keys" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "keys" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
}}
>
<Key size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.keychain")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "proxies" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "proxies" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("proxies");
}}
>
<Globe size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.proxies")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.proxies")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "port" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "port" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
<Plug size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.portForwarding")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "snippets" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "snippets" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
}}
>
<FileCode size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.scripts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.scripts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "notes" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "notes" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("notes");
}}
>
<NotebookText size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.notes")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.notes")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "knownhosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
<BookMarked size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.knownHosts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "logs" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "logs" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
<Activity size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.logs")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
</Tooltip>
</div>
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-2.5")}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3"
)}
onClick={onOpenSettings}
>
<Settings size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("common.settings")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("common.settings")}</TooltipContent>}
</Tooltip>
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("vault.sidebar.resize")}
className={cn(
"app-no-drag absolute right-0 top-0 z-20 h-full w-2 translate-x-1/2 cursor-col-resize",
"after:absolute after:right-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:translate-x-1/2 after:bg-border/0 after:transition-colors",
"hover:after:bg-border/70",
isSidebarResizing && "after:bg-primary/70",
)}
onPointerDown={handleSidebarResizeStart}
/>
</div>
</TooltipProvider>
<div className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0" data-section="vault-stage">
<div
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
data-section="vault-surface"
>
{/* Main Area */}
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<VaultPageHeader
className={cn(!isHostsSectionActive && "hidden")}
dataSection="vault-hosts-header"
>
<VaultHeaderSearch
placeholder={t("vault.hosts.search.placeholder")}
className="flex-1"
inputClassName={cn(
isSearchQuickConnect &&
"border-primary/50 ring-1 ring-primary/20",
)}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
rightAdornment={
isSearchQuickConnect ? (
<Zap size={14} className="text-primary" />
) : null
}
/>
<Button
variant={isSearchQuickConnect ? "default" : "secondary"}
className={cn(
"h-10 px-4",
!isSearchQuickConnect &&
vaultHeaderSecondaryButtonClass,
)}
onClick={handleConnectClick}
>
{t("vault.hosts.connect")}
</Button>
{/* View mode, tag filter, and sort controls */}
<div className="flex items-center gap-1 app-no-drag">
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : viewMode === "list" ? (
<List size={16} />
) : (
<Network size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</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} /> {t("vault.view.grid")}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<List size={14} /> {t("vault.view.list")}
</Button>
<Button
variant={viewMode === "tree" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("tree")}
>
<Network size={14} /> {t("vault.view.tree")}
</Button>
</DropdownContent>
</Dropdown>
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
className={vaultHeaderIconButtonClass}
/>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className={vaultHeaderIconButtonClass}
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
</Tooltip>
</div>
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
would be a no-op in that state. */}
<div
ref={newHostActionsRef}
className={cn(
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
: "max-w-[260px] opacity-100",
)}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<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}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
</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"
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setTargetParentPath(selectedGroupPath);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setIsImportOpen(true);
}}
>
<Upload size={14} /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
</div>
{/* Terminal + Serial — collapse together with an animation when
the host details / new-host aside panel is open, freeing
horizontal space for the panel. */}
<div
ref={sessionActionsRef}
className={cn(
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
: "max-w-[320px] opacity-100",
)}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<Button
size="sm"
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={onCreateLocalTerminal}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</VaultPageHeader>
{isMultiSelectMode && isHostsSectionActive && (
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
<span className="flex items-center h-7 text-xs text-muted-foreground leading-none">
{t("vault.hosts.selected", { count: selectedHostIds.size })}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
const allIds = new Set(displayedHosts.map(h => h.id));
setSelectedHostIds(allIds);
}}
>
{t("vault.hosts.selectAll")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearHostSelection}
>
{t("vault.hosts.deselectAll")}
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={connectSelectedHosts}
>
<Plug size={12} className="mr-1" />
{t("vault.hosts.connectSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={deleteSelectedHosts}
>
<Trash2 size={12} className="mr-1" />
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearHostSelection}
>
<X size={12} />
</Button>
</div>
)}
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
<VaultHostListSection ctx={{ Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, pinnedRecentIds, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, selectedGroupPath, selectedHostIds, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts }} />
{currentSection === "snippets" && (
<LazyLoadBoundary name="Snippets" resetKey="snippets">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SnippetsManager
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
customGroups={customGroups}
shellHistory={shellHistory}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onPackagesChange={onUpdateSnippetPackages}
onSave={(s) =>
onUpdateSnippets(
snippets.find((ex) => ex.id === s.id)
? snippets.map((ex) => (ex.id === s.id ? s : ex))
: [...snippets, s],
)
}
onBulkSave={onUpdateSnippets}
onDelete={(id) =>
onUpdateSnippets(snippets.filter((s) => s.id !== id))
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onUpdateHosts={onUpdateHosts}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
openSnippetId={openSnippetId ?? null}
onOpenSnippetIdHandled={onOpenSnippetIdHandled}
/>
</div>
</Suspense>
</LazyLoadBoundary>
)}
<div
className={cn("min-h-0 flex-1", currentSection !== "notes" && "hidden")}
data-section="vault-notes-retained"
>
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={onUpdateNotes}
onUpdateNoteGroups={onUpdateNoteGroups}
openNoteId={openNoteId ?? null}
onOpenNoteIdHandled={onOpenNoteIdHandled}
onOpenHost={(host: any, source: any) => {
if (source?.noteId && onOpenHostFromNote) {
onOpenHostFromNote(host, source);
return;
}
handleHostConnect(host);
}}
/>
</div>
{currentSection === "keys" && (
<LazyLoadBoundary name="Keychain" resetKey="keys">
<Suspense fallback={<VaultSectionLoading />}>
<KeychainManager
keys={keys}
identities={identities}
hosts={hosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
groupConfigs={groupConfigs}
managedSources={managedSources}
onSave={(k) => onUpdateKeys([...keys, k])}
onUpdate={(k) =>
onUpdateKeys(
keys.map((existing) => (existing.id === k.id ? k : existing)),
)
}
onReorderKeys={onUpdateKeys}
onDelete={(id) => onUpdateKeys(keys.filter((k) => k.id !== id))}
onSaveIdentity={(identity) =>
onUpdateIdentities(
identities.find((ex) => ex.id === identity.id)
? identities.map((ex) =>
ex.id === identity.id ? identity : ex,
)
: [...identities, identity],
)
}
onDeleteIdentity={(id) =>
onUpdateIdentities(identities.filter((i) => i.id !== id))
}
onReorderIdentities={onUpdateIdentities}
onSaveHost={(host) => {
// Update existing host or add new one
const existingIndex = hosts.findIndex((h) => h.id === host.id);
if (existingIndex >= 0) {
onUpdateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
} else {
onUpdateHosts([...hosts, host]);
}
}}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
/>
</Suspense>
</LazyLoadBoundary>
)}
{currentSection === "proxies" && (
<LazyLoadBoundary name="Proxy profiles" resetKey="proxies">
<Suspense fallback={<VaultSectionLoading />}>
<ProxyProfilesManager
proxyProfiles={proxyProfiles}
hosts={hosts}
groupConfigs={groupConfigs}
identities={identities}
onUpdateProxyProfiles={onUpdateProxyProfiles}
onUpdateHosts={onUpdateHosts}
onUpdateGroupConfigs={onUpdateGroupConfigs}
/>
</Suspense>
</LazyLoadBoundary>
)}
{currentSection === "port" && (
<LazyLoadBoundary name="Port forwarding" resetKey="port-forwarding">
<Suspense fallback={<VaultSectionLoading />}>
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
knownHosts={knownHosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
groupConfigs={groupConfigs}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
<div
style={{
display: currentSection === "knownhosts" ? "contents" : "none",
}}
>
{knownHostsManagerElement}
</div>
{/* Connection Logs */}
{currentSection === "logs" && (
<LazyLoadBoundary name="Connection logs" resetKey="connection-logs">
<Suspense fallback={<VaultSectionLoading />}>
<LazyConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
</Suspense>
</LazyLoadBoundary>
)}
</div>
{/* Group Details Panel */}
{currentSection === "hosts" && isGroupPanelOpen && editingGroupPath && (
<GroupDetailsPanel
key={editingGroupPath}
groupPath={editingGroupPath}
config={groupConfigs.find(c => c.path === editingGroupPath)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
groupConfigs={groupConfigs}
terminalFontSize={terminalFontSize}
onSave={handleSaveGroupConfig}
onCancel={() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
{/* Host Details Panel */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
const latestHost = hosts.find((entry: { id: string }) => entry.id === host.id);
const nextHost = preserveConcurrentHostLineTimestampUpdate({
draft: host,
openedHost: editingHost,
latestHost,
});
onUpdateHosts(upsertHostById(hosts, nextHost));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCreateGroup={(groupPath) => {
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
{/* Serial Host Details Panel - for editing serial port hosts */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
</div>
</div>
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
setIsNewFolderOpen(open);
if (!open) {
setNewFolderName("");
setTargetParentPath(null);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{targetParentPath
? t("vault.groups.createSubfolder")
: t("vault.groups.createRoot")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("vault.groups.createDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label>{t("vault.groups.field.name")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("vault.groups.placeholder.example")}
autoFocus
onKeyDown={(e) => e.key === "Enter" && submitNewFolder()}
/>
{targetParentPath && (
<p className="text-xs text-muted-foreground mt-2">
{t("vault.groups.parentLabel")}:{" "}
<span className="font-mono">{targetParentPath}</span>
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsNewFolderOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={submitNewFolder}>{t("common.create")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isRenameGroupOpen}
onOpenChange={(open) => {
setIsRenameGroupOpen(open);
if (!open) {
setRenameTargetPath(null);
setRenameGroupName("");
setRenameGroupError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.groups.renameDialogTitle")}</DialogTitle>
<DialogDescription className="sr-only">
{t("vault.groups.renameDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
<Label>{t("vault.groups.field.name")}</Label>
<Input
value={renameGroupName}
onChange={(e) => {
setRenameGroupName(e.target.value);
setRenameGroupError(null);
}}
placeholder={t("vault.groups.placeholder.example")}
autoFocus
onKeyDown={(e) => e.key === "Enter" && submitRenameGroup()}
/>
{renameTargetPath && (
<p className="text-xs text-muted-foreground">
{t("vault.groups.pathLabel")}:{" "}
<span className="font-mono">{renameTargetPath}</span>
</p>
)}
{renameGroupError && (
<p className="text-xs text-destructive">{renameGroupError}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsRenameGroupOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={submitRenameGroup}>{t("common.rename")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteGroupOpen}
onOpenChange={(open) => {
setIsDeleteGroupOpen(open);
if (!open) {
setDeleteTargetPath(null);
setDeleteGroupWithHosts(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.groups.deleteDialogTitle")}</DialogTitle>
<DialogDescription>
{deleteTargetPath && managedGroupPaths.has(deleteTargetPath)
? t("vault.groups.deleteDialog.managedDesc")
: t("vault.groups.deleteDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{deleteTargetPath && (
<>
<p className="text-sm text-muted-foreground">
{t("vault.groups.pathLabel")}:{" "}
<span className="font-mono">{deleteTargetPath}</span>
</p>
{!managedGroupPaths.has(deleteTargetPath) && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={deleteGroupWithHosts}
onChange={(e) => setDeleteGroupWithHosts(e.target.checked)}
className="rounded border-border"
/>
<span>{t("vault.groups.deleteDialog.deleteHosts")}</span>
</label>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteTargetPath) {
const isManaged = managedGroupPaths.has(deleteTargetPath);
deleteGroupPath(deleteTargetPath, isManaged || deleteGroupWithHosts);
}
setIsDeleteGroupOpen(false);
setDeleteGroupWithHosts(false);
}}
>
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ImportVaultDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
onFileSelected={handleImportFileSelected}
/>
{/* Quick Connect Wizard */}
{isQuickConnectOpen && quickConnectTarget && (
<QuickConnectWizard
open={isQuickConnectOpen}
target={quickConnectTarget}
keys={keys}
onConnect={handleQuickConnect}
onSaveHost={handleQuickConnectSaveHost}
onClose={() => {
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
}}
warnings={quickConnectWarnings}
/>
)}
{/* Protocol Select Dialog */}
{protocolSelectHost && (
<LazyLoadBoundary name="Protocol selector" resetKey={protocolSelectHost.id}>
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Serial Connect Modal */}
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config, options) => {
if (onConnectSerial) {
onConnectSerial(config, options);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
</div>
);
}