import React, { useCallback, useMemo } from "react"; import { upsertKnownHost } from "../../domain/knownHosts"; import { sortByVaultOrder, sortVaultStringsByOrder } from "../../domain/vaultOrder"; import { matchesHostSearchQuery, matchesSearchQuery } from "../../lib/searchMatcher"; import type { GroupConfig, GroupNode, Host, KnownHost } from "../../types"; import KnownHostsManager from "../KnownHostsManager"; import type { SortMode } from "../ui/sort-dropdown"; interface UseVaultHostCollectionsOptions { customGroups: string[]; groupConfigs: GroupConfig[]; hosts: Host[]; knownHosts: KnownHost[]; onConvertKnownHost: (knownHost: KnownHost) => void; onUpdateHosts: (hosts: Host[]) => void; onUpdateKnownHosts: (knownHosts: KnownHost[]) => void; search: string; selectedGroupPath: string | null; selectedTags: string[]; showOnlyUngroupedHostsInRoot: boolean; showRecentHosts: boolean; sortMode: SortMode; viewMode: "grid" | "list" | "tree"; } export function hostBelongsToSelectedVaultGroup(host: Host, selectedGroupPath: string): boolean { const hostGroup = host.group || ""; if (selectedGroupPath === "General") { return hostGroup === "" || hostGroup === "General"; } return hostGroup === selectedGroupPath; } export function filterVaultHostsForDisplay({ filteredHosts, searchTerm, selectedGroupPath, showOnlyUngroupedHostsInRoot, }: { filteredHosts: Host[]; searchTerm: string; selectedGroupPath: string | null; showOnlyUngroupedHostsInRoot: boolean; }): Host[] { if (selectedGroupPath) { return filteredHosts.filter((host) => hostBelongsToSelectedVaultGroup(host, selectedGroupPath), ); } if (!searchTerm && showOnlyUngroupedHostsInRoot) { return filteredHosts.filter((host) => { const hostGroup = (host.group || "").trim(); return hostGroup === ""; }); } return filteredHosts; } export function useVaultHostCollections({ customGroups, groupConfigs, hosts, knownHosts, onConvertKnownHost, onUpdateHosts, onUpdateKnownHosts, search, selectedGroupPath, selectedTags, showOnlyUngroupedHostsInRoot, showRecentHosts, sortMode, viewMode, }: UseVaultHostCollectionsOptions) { const groupOrderByPath = useMemo(() => { return new Map( groupConfigs .filter((config) => typeof config.order === "number" && Number.isFinite(config.order)) .map((config) => [config.path, config.order as number]), ); }, [groupConfigs]); const searchTerm = useMemo(() => search.trim(), [search]); const selectedTagSet = useMemo(() => new Set(selectedTags), [selectedTags]); const hasSelectedTags = selectedTags.length > 0; const hostMatchesSearchAndTags = useCallback((host: Host): boolean => { if (searchTerm) { const matchesSearch = matchesHostSearchQuery(searchTerm, host) || matchesSearchQuery(searchTerm, host.username, host.notes); if (!matchesSearch) return false; } if (hasSelectedTags && !host.tags?.some((tag) => selectedTagSet.has(tag))) { return false; } return true; }, [hasSelectedTags, searchTerm, selectedTagSet]); const filteredHosts = useMemo( () => hosts.filter(hostMatchesSearchAndTags), [hostMatchesSearchAndTags, hosts], ); const sortHosts = useCallback((input: readonly Host[]): Host[] => { if (sortMode === "manual") return sortByVaultOrder(input); return [...input].sort((a, b) => { switch (sortMode) { case "az": return a.label.localeCompare(b.label); case "za": return b.label.localeCompare(a.label); case "newest": return (b.createdAt || 0) - (a.createdAt || 0); case "oldest": return (a.createdAt || 0) - (b.createdAt || 0); case "group": { const groupA = a.group || ""; const groupB = b.group || ""; const groupCmp = groupA.localeCompare(groupB); return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label); } default: return 0; } }); }, [sortMode]); const orderedCustomGroups = useMemo(() => { return sortVaultStringsByOrder(customGroups, groupOrderByPath); }, [customGroups, groupOrderByPath]); const sortGroupNodes = useCallback((nodes: GroupNode[]) => { const originalIndex = new Map(nodes.map((node, index) => [node.path, index])); return [...nodes].sort((a, b) => { const orderA = groupOrderByPath.get(a.path); const orderB = groupOrderByPath.get(b.path); const hasOrderA = typeof orderA === "number" && Number.isFinite(orderA); const hasOrderB = typeof orderB === "number" && Number.isFinite(orderB); if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB; if (hasOrderA) return -1; if (hasOrderB) return 1; return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0); }); }, [groupOrderByPath]); const countAllHostsInNode = useCallback((node: GroupNode): number => { let count = node.hosts.length; Object.values(node.children).forEach((child) => { count += countAllHostsInNode(child); }); node.totalHostCount = count; return count; }, []); const buildGroupTree = useMemo>(() => { const root: Record = {}; const insertPath = (path: string, host?: Host) => { const parts = path.split("/").filter(Boolean); let currentLevel = root; let currentPath = ""; parts.forEach((part, index) => { currentPath = currentPath ? `${currentPath}/${part}` : part; if (!currentLevel[part]) { currentLevel[part] = { name: part, path: currentPath, children: {}, hosts: [], }; } if (host && index === parts.length - 1) currentLevel[part].hosts.push(host); currentLevel = currentLevel[part].children; }); }; orderedCustomGroups.forEach((path) => insertPath(path)); hosts.forEach((host) => insertPath(host.group || "General", host)); Object.values(root).forEach(countAllHostsInNode); return root; }, [hosts, orderedCustomGroups, countAllHostsInNode]); // Generate all possible group paths from the tree (including all intermediate nodes) const allGroupPaths = useMemo(() => { const paths = new Set(); const traverse = (nodes: Record) => { Object.values(nodes).forEach((node) => { if (node.path) { paths.add(node.path); } if (node.children) { traverse(node.children); } }); }; // Traverse the tree traverse(buildGroupTree); return Array.from(paths).sort(); }, [buildGroupTree]); const findGroupNode = (path: string | null): GroupNode | null => { if (!path) return { name: "root", path: "", children: buildGroupTree, hosts: [], } as GroupNode; const parts = path.split("/").filter(Boolean); let current: { children?: Record; hosts?: Host[] } = { children: buildGroupTree, }; for (const p of parts) { const next = current.children?.[p]; if (!next) return null; current = next; } return current as GroupNode; }; const displayedHosts = useMemo(() => { const filtered = filterVaultHostsForDisplay({ filteredHosts, searchTerm, selectedGroupPath, showOnlyUngroupedHostsInRoot, }); return sortHosts(filtered); }, [filteredHosts, searchTerm, selectedGroupPath, showOnlyUngroupedHostsInRoot, sortHosts]); // Pinned hosts for root-level display (not inside a subgroup) // Respects active search and tag filters const pinnedHosts = useMemo(() => { if (selectedGroupPath) return []; const filtered = filteredHosts.filter((h) => h.pinned); return filtered.sort((a, b) => a.label.localeCompare(b.label)); }, [filteredHosts, selectedGroupPath]); // Recently connected hosts for root-level display // Respects active search and tag filters const recentHosts = useMemo(() => { if (selectedGroupPath) return []; const filtered = filteredHosts.filter((h) => h.lastConnectedAt && !h.pinned); return filtered .sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0)) .slice(0, 6); }, [filteredHosts, selectedGroupPath]); const pinnedRecentIds = useMemo(() => new Set([ ...pinnedHosts.map((host) => host.id), ...(showRecentHosts ? recentHosts.map((host) => host.id) : []), ]), [pinnedHosts, recentHosts, showRecentHosts]); const visibleDisplayedHosts = useMemo( () => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)), [displayedHosts, selectedGroupPath, pinnedRecentIds], ); // For tree view: apply search, tag filter, and sorting, but not group filtering const treeViewHosts = useMemo(() => { return sortHosts(filteredHosts); }, [filteredHosts, sortHosts]); const groupedDisplayHosts = useMemo(() => { if (sortMode !== "group") return null; const groups: { name: string; hosts: Host[] }[] = []; const groupMap = new Map(); for (const host of visibleDisplayedHosts) { const groupName = host.group || ""; if (!groupMap.has(groupName)) { groupMap.set(groupName, []); } groupMap.get(groupName)!.push(host); } const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b)); for (const key of sortedKeys) { groups.push({ name: key, hosts: groupMap.get(key)! }); } return groups; }, [sortMode, visibleDisplayedHosts]); const buildTreeViewGroupTree = useMemo>(() => { const root: Record = {}; const insertPath = (path: string, host?: Host) => { const parts = path.split("/").filter(Boolean); let currentLevel = root; let currentPath = ""; parts.forEach((part, index) => { currentPath = currentPath ? `${currentPath}/${part}` : part; if (!currentLevel[part]) { currentLevel[part] = { name: part, path: currentPath, children: {}, hosts: [], }; } if (host && index === parts.length - 1) currentLevel[part].hosts.push(host); currentLevel = currentLevel[part].children; }); }; if (!searchTerm && selectedTags.length === 0) { orderedCustomGroups.forEach((path) => insertPath(path)); } // Use filtered hosts (treeViewHosts) instead of all hosts to respect search/tag filters treeViewHosts.forEach((host) => { if (host.group && host.group.trim() !== "") { insertPath(host.group, host); } }); Object.values(root).forEach(countAllHostsInNode); return root; }, [treeViewHosts, orderedCustomGroups, countAllHostsInNode, searchTerm, selectedTags.length]); // Create tree view specific group tree that excludes ungrouped hosts const treeViewGroupTree = useMemo(() => { const nodes = Object.values(buildTreeViewGroupTree) as GroupNode[]; if (sortMode === "manual") return sortGroupNodes(nodes); return nodes.sort((a, b) => a.name.localeCompare(b.name)); }, [buildTreeViewGroupTree, sortGroupNodes, sortMode]); // Compute all unique tags across all hosts const allTags = useMemo(() => { const tagSet = new Set(); hosts.forEach((h) => h.tags?.forEach((t) => tagSet.add(t))); return Array.from(tagSet).sort(); }, [hosts]); // Handle tag edit - rename tag across all hosts const handleEditTag = useCallback( (oldTag: string, newTag: string) => { if (oldTag === newTag) return; const updatedHosts = hosts.map((host) => { if (host.tags?.includes(oldTag)) { const newTags = host.tags.map((t) => (t === oldTag ? newTag : t)); // Remove duplicates in case newTag already exists return { ...host, tags: Array.from(new Set(newTags)) }; } return host; }); onUpdateHosts(updatedHosts); }, [hosts, onUpdateHosts], ); // Handle tag delete - remove tag from all hosts const handleDeleteTag = useCallback( (tag: string) => { const updatedHosts = hosts.map((host) => { if (host.tags?.includes(tag)) { return { ...host, tags: host.tags.filter((t) => t !== tag) }; } return host; }); onUpdateHosts(updatedHosts); }, [hosts, onUpdateHosts], ); const displayedGroups = useMemo(() => { const hasActiveFilters = Boolean(searchTerm) || selectedTags.length > 0; const sourceTree = hasActiveFilters ? buildTreeViewGroupTree : buildGroupTree; const findSourceNode = (path: string): GroupNode | null => { const parts = path.split("/").filter(Boolean); let currentLevel = sourceTree; let currentNode: GroupNode | null = null; for (const part of parts) { currentNode = currentLevel[part] ?? null; if (!currentNode) return null; currentLevel = currentNode.children; } return currentNode; }; if (!selectedGroupPath) { // Hide "General" group at root level only if it's auto-generated // (not user-created and has no subgroups) const isGeneralUserCreated = customGroups.some( (g) => g === "General" || g.startsWith("General/") ); const nodes = (Object.values(sourceTree) as GroupNode[]) .filter((node) => { if (node.name !== "General") return true; // Keep General if user explicitly created it or it has subgroups if (isGeneralUserCreated) return true; if (Object.keys(node.children).length > 0) return true; return false; }); if (sortMode === "manual") return sortGroupNodes(nodes); return nodes.sort((a, b) => a.name.localeCompare(b.name)); } const node = findSourceNode(selectedGroupPath); if (!node || !node.children) return []; const children = Object.values(node.children) as GroupNode[]; if (sortMode === "manual") return sortGroupNodes(children); return children.sort((a, b) => a.name.localeCompare(b.name)); }, [ buildGroupTree, buildTreeViewGroupTree, customGroups, searchTerm, selectedGroupPath, selectedTags.length, sortGroupNodes, sortMode, ]); const shouldHideEmptyRootHostsSection = useMemo(() => { if (selectedGroupPath || viewMode === "tree") return false; if (searchTerm || selectedTags.length > 0) return false; if (visibleDisplayedHosts.length > 0) return false; return ( displayedGroups.length > 0 || pinnedHosts.length > 0 || (showRecentHosts && recentHosts.length > 0) ); }, [ selectedGroupPath, viewMode, searchTerm, selectedTags.length, visibleDisplayedHosts.length, displayedGroups.length, pinnedHosts.length, showRecentHosts, recentHosts.length, ]); // Known Hosts callbacks - use refs to keep stable references // Store latest values in refs so callbacks don't need to depend on them const knownHostsRef = React.useRef(knownHosts); const onUpdateKnownHostsRef = React.useRef(onUpdateKnownHosts); // Keep refs up to date React.useEffect(() => { knownHostsRef.current = knownHosts; onUpdateKnownHostsRef.current = onUpdateKnownHosts; }); // Stable callbacks that read from refs const handleSaveKnownHost = useCallback((kh: KnownHost) => { onUpdateKnownHostsRef.current(upsertKnownHost(knownHostsRef.current, kh)); }, []); const handleUpdateKnownHost = useCallback((kh: KnownHost) => { onUpdateKnownHostsRef.current( knownHostsRef.current.map((existing) => existing.id === kh.id ? kh : existing, ), ); }, []); const handleDeleteKnownHost = useCallback((id: string) => { onUpdateKnownHostsRef.current( knownHostsRef.current.filter((kh) => kh.id !== id), ); }, []); const handleImportKnownHosts = useCallback((newHosts: KnownHost[]) => { onUpdateKnownHostsRef.current([...knownHostsRef.current, ...newHosts]); }, []); const handleReorderKnownHosts = useCallback((nextKnownHosts: KnownHost[]) => { onUpdateKnownHostsRef.current(nextKnownHosts); }, []); const handleRefreshKnownHosts = useCallback(() => { // Placeholder for system scan }, []); // Memoize the KnownHostsManager element to prevent re-renders when VaultViewInner re-renders const knownHostsManagerElement = useMemo(() => { return ( ); // eslint-disable-next-line react-hooks/exhaustive-deps -- handle* callbacks are stable refs that read from refs }, [knownHosts, hosts, onConvertKnownHost]); return { allGroupPaths, allTags, buildGroupTree, displayedGroups, displayedHosts, findGroupNode, groupedDisplayHosts, handleDeleteTag, handleEditTag, knownHostsManagerElement, pinnedHosts, pinnedRecentIds, recentHosts, shouldHideEmptyRootHostsSection, treeViewGroupTree, treeViewHosts, visibleDisplayedHosts, }; }