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
522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|