[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
521
components/vault/useVaultHostCollections.tsx
Normal file
521
components/vault/useVaultHostCollections.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
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<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
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<string>();
|
||||
|
||||
const traverse = (nodes: Record<string, GroupNode>) => {
|
||||
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<string, GroupNode>; 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<string>([
|
||||
...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<string, Host[]>();
|
||||
|
||||
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<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
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<GroupNode[]>(() => {
|
||||
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<string>();
|
||||
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 (
|
||||
<KnownHostsManager
|
||||
knownHosts={knownHosts}
|
||||
hosts={hosts}
|
||||
onSave={handleSaveKnownHost}
|
||||
onUpdate={handleUpdateKnownHost}
|
||||
onReorder={handleReorderKnownHosts}
|
||||
onDelete={handleDeleteKnownHost}
|
||||
onConvertToHost={onConvertKnownHost}
|
||||
onImportFromFile={handleImportKnownHosts}
|
||||
onRefresh={handleRefreshKnownHosts}
|
||||
/>
|
||||
);
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user