import { ArrowRight, ChevronDown, FolderOpen, Import, LayoutGrid, List as ListIcon, RefreshCw, Server, Shield, Trash2, } from "lucide-react"; import React, { memo, useCallback, useDeferredValue, useEffect, useMemo, useState, } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend"; import { useStoredBoolean } from "../application/state/useStoredBoolean"; import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode"; import { fingerprintFromPublicKey } from "../domain/knownHosts"; import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, shouldAutoScanSystemKnownHosts, } from "../domain/systemKnownHostsAutoImport"; import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder"; import { STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE, } from "../infrastructure/config/storageKeys"; import { logger } from "../lib/logger"; import { cn } from "../lib/utils"; import { Host, KnownHost } from "../types"; import { Button } from "./ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "./ui/context-menu"; import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { ScrollArea } from "./ui/scroll-area"; import { SortDropdown, SortMode } from "./ui/sort-dropdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { toast } from "./ui/toast"; import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass, } from "./vault/VaultPageHeader"; import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog"; import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon"; import { useVaultItemReorder } from "./vault/vaultReorderDrag"; interface KnownHostsManagerProps { knownHosts: KnownHost[]; hosts: Host[]; onSave: (knownHost: KnownHost) => void; onUpdate: (knownHost: KnownHost) => void; onReorder: (knownHosts: KnownHost[]) => void; onDelete: (id: string) => void; onConvertToHost: (knownHost: KnownHost) => void; onImportFromFile: (hosts: KnownHost[]) => void; onRefresh: () => void; } // Parse known_hosts file content - pure function, moved outside component const parseKnownHostsFile = (content: string): KnownHost[] => { const lines = content .split("\n") .filter((line) => line.trim() && !line.startsWith("#")); const parsed: KnownHost[] = []; for (const line of lines) { try { const parts = line.trim().split(/\s+/); if (parts.length < 3) continue; const [hostPattern, keyType, publicKey] = parts; let hostname = hostPattern; let port = 22; const bracketMatch = hostPattern.match(/^\[([^\]]+)\]:(\d+)$/); if (bracketMatch) { hostname = bracketMatch[1]; port = parseInt(bracketMatch[2], 10); } else if (hostPattern.includes(",")) { hostname = hostPattern.split(",")[0]; } if (hostname.startsWith("|1|")) { hostname = "(hashed)"; } const fullPublicKey = `${keyType} ${publicKey}`; // Compute the fingerprint up front so the SSH host verifier can match // against this record directly instead of re-deriving on every connect — // the re-derivation path is where the false "fingerprint changed" // warnings in #972 originated. const fingerprint = fingerprintFromPublicKey(fullPublicKey); parsed.push({ id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, hostname, port, keyType, publicKey: fullPublicKey, fingerprint: fingerprint || undefined, discoveredAt: Date.now(), }); } catch { logger.warn("Failed to parse known_hosts line:", line); } } return parsed; }; // Well-known public service hostnames that should not be imported as managed hosts. const PUBLIC_SERVICE_HOSTNAMES = new Set([ 'github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com', 'vs-ssh.visualstudio.com', ]); const isPublicServiceHost = (hostname: string): boolean => PUBLIC_SERVICE_HOSTNAMES.has(hostname); // Memoized Grid Item Component interface HostItemProps { knownHost: KnownHost; converted: boolean; viewMode: ViewMode; reorderProps?: React.HTMLAttributes; onDelete: (id: string) => void; onConvertToHost: (knownHost: KnownHost) => void; } const knownHostActionButtonClass = "h-8 w-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"; const KnownHostItemActions: React.FC<{ knownHost: KnownHost; converted: boolean; onDelete: (id: string) => void; onConvertToHost: (knownHost: KnownHost) => void; }> = ({ knownHost, converted, onDelete, onConvertToHost }) => { const { t } = useI18n(); return (
{!converted && ( {t("action.convertToHost")} )} {t("action.remove")}
); }; const HostItem = React.memo( ({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => { const { t } = useI18n(); // Disabled to reduce log noise - uncomment for debugging // console.log('[HostItem] render:', knownHost.hostname); if (viewMode === "grid") { return (
} />
{knownHost.hostname}
{!converted && ( onConvertToHost(knownHost)}> {t("action.convertToHost")} )} onDelete(knownHost.id)} > {t("action.remove")}
); } // List view return (
} />
{knownHost.hostname}
{!converted && ( onConvertToHost(knownHost)}> {t("action.convertToHost")} )} onDelete(knownHost.id)} > {t("action.remove")}
); }, ); HostItem.displayName = "HostItem"; const KnownHostsManager: React.FC = ({ knownHosts, hosts, onSave: _onSave, onUpdate: _onUpdate, onReorder, onDelete, onConvertToHost, onImportFromFile, onRefresh, }) => { const { t } = useI18n(); const { readKnownHosts } = useKnownHostsBackend(); const [search, setSearch] = useState(""); const deferredSearch = useDeferredValue(search); const [isScanning, setIsScanning] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); const [viewMode, setViewMode] = useStoredViewMode( STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE, "grid", ); const [autoImportSystemKnownHosts] = useStoredBoolean( STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, ); const [sortMode, setSortMode] = useState("manual"); const fileInputRef = React.useRef(null); const hasScannedRef = React.useRef(false); const listRef = React.useRef(null); const RENDER_LIMIT = 100; // Limit rendered items for performance // Define handleScanSystem before useEffect that depends on it const handleScanSystem = useCallback(async (silent = false) => { setIsScanning(true); try { const content = await readKnownHosts(); if (content === undefined) { if (!silent) toast.error( t("knownHosts.toast.scanUnavailable"), t("vault.nav.knownHosts"), ); return; } if (!content) { if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts")); return; } const parsed = parseKnownHostsFile(content); if (parsed.length === 0) { if (!silent) toast.info( t("knownHosts.toast.scanNoEntries"), t("vault.nav.knownHosts"), ); return; } const existingHostnames = new Set( knownHosts.map((h) => `${h.hostname}:${h.port}`), ); const newHosts = parsed.filter( (h) => !existingHostnames.has(`${h.hostname}:${h.port}`) && !isPublicServiceHost(h.hostname), ); const publicFiltered = parsed.filter((h) => isPublicServiceHost(h.hostname)).length; if (newHosts.length > 0) { onImportFromFile(newHosts); if (!silent) toast.success( t("knownHosts.toast.scanImported", { count: newHosts.length }), t("vault.nav.knownHosts"), ); } else { if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts")); } if (!silent && publicFiltered > 0) { toast.info( t("knownHosts.toast.scanFiltered", { count: publicFiltered }), t("vault.nav.knownHosts"), ); } } catch (err) { logger.error("Failed to scan system known_hosts:", err); if (!silent) toast.error( err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"), t("vault.nav.knownHosts"), ); } finally { onRefresh(); setIsScanning(false); } }, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]); // Auto-scan on first mount when enabled (silent — no toasts for missing known_hosts) useEffect(() => { if ( !shouldAutoScanSystemKnownHosts({ autoImportEnabled: autoImportSystemKnownHosts, alreadyScanned: hasScannedRef.current, }) ) { return; } hasScannedRef.current = true; const timer = setTimeout(() => { handleScanSystem(true); }, 100); return () => clearTimeout(timer); }, [autoImportSystemKnownHosts, handleScanSystem]); // Sort and filter hosts with deduplication by hostname const filteredHosts = useMemo(() => { // First, deduplicate by hostname (keep the most recent one) const hostnameMap = new Map(); for (const h of knownHosts) { const key = h.hostname; const existing = hostnameMap.get(key); if (!existing || h.discoveredAt > existing.discoveredAt) { hostnameMap.set(key, h); } } let result = Array.from(hostnameMap.values()); // Filter by search if (deferredSearch.trim()) { const term = deferredSearch.toLowerCase(); result = result.filter( (h) => h.hostname.toLowerCase().includes(term) || h.keyType.toLowerCase().includes(term), ); } // Sort result = [...result].sort((a, b) => { switch (sortMode) { case "az": return a.hostname.localeCompare(b.hostname); case "za": return b.hostname.localeCompare(a.hostname); case "newest": return b.discoveredAt - a.discoveredAt; case "oldest": return a.discoveredAt - b.discoveredAt; case "manual": return 0; default: return 0; } }); return sortMode === "manual" ? sortByVaultOrder(result) : result; }, [knownHosts, deferredSearch, sortMode]); // Limit rendered items for performance const displayedHosts = useMemo(() => { return filteredHosts.slice(0, RENDER_LIMIT); }, [filteredHosts]); const hasMore = filteredHosts.length > RENDER_LIMIT; const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const content = event.target?.result as string; const parsed = parseKnownHostsFile(content); // Filter out already existing hosts and directly import const existingHostnames = new Set( knownHosts.map((h) => `${h.hostname}:${h.port}`), ); const newHosts = parsed.filter( (h) => !existingHostnames.has(`${h.hostname}:${h.port}`) && !isPublicServiceHost(h.hostname), ); if (newHosts.length > 0) { onImportFromFile(newHosts); } }; reader.readAsText(file); // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ""; } }, [knownHosts, onImportFromFile], ); // Memoize host lookup for performance const hostIdSet = useMemo(() => new Set(hosts.map((h) => h.id)), [hosts]); // Pre-compute converted status for all known hosts const convertedMap = useMemo(() => { const map = new Map(); for (const kh of knownHosts) { if (kh.convertedToHostId) { map.set(kh.id, hostIdSet.has(kh.convertedToHostId)); } else { map.set(kh.id, false); } } return map; }, [knownHosts, hostIdSet]); // Memoized handlers to prevent re-renders const handleDelete = useCallback( (id: string) => { setDeleteTargetId(id); }, [], ); const deleteTarget = useMemo( () => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null, [deleteTargetId, knownHosts], ); const confirmDelete = useCallback( () => { if (!deleteTargetId) return; onDelete(deleteTargetId); setDeleteTargetId(null); }, [deleteTargetId, onDelete], ); const handleConvertToHost = useCallback( (knownHost: KnownHost) => { onConvertToHost(knownHost); }, [onConvertToHost], ); const openFilePicker = useCallback(() => fileInputRef.current?.click(), []); const knownHostReorder = useVaultItemReorder({ containerRef: listRef, viewMode, dragType: "known-host-id", targetAttribute: "data-known-host-id", disabled: deferredSearch.trim().length > 0, onReorder: (sourceId, targetId, position) => { onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position)); setSortMode("manual"); }, }); // Memoize the rendered list to prevent re-renders const renderedItems = useMemo(() => { return displayedHosts.map((knownHost) => ( )); }, [ displayedHosts, convertedMap, viewMode, handleDelete, handleConvertToHost, knownHostReorder, ]); return (
setSearch(e.target.value)} />
{/* View Mode Toggle */} {/* Sort Toggle */}
{/* Content */}
{displayedHosts.length === 0 ? (

{t("knownHosts.empty.title")}

{t("knownHosts.empty.desc")}

) : ( <> {renderedItems} {hasMore && (
{t("knownHosts.results.showingLimited", { shown: displayedHosts.length, total: filteredHosts.length, })}
)} )}
{ if (!open) setDeleteTargetId(null); }} onConfirm={confirmDelete} />
); }; // Custom comparison - only compare data props, not callbacks const knownHostsManagerAreEqual = ( prev: KnownHostsManagerProps, next: KnownHostsManagerProps, ): boolean => { return prev.knownHosts === next.knownHosts && prev.hosts === next.hosts; }; export default memo(KnownHostsManager, knownHostsManagerAreEqual);