[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const dialogSource = readFileSync(new URL('./ImportVaultDialog.tsx', import.meta.url), 'utf8');
const hookSource = readFileSync(new URL('../../application/state/usePluginVaultImporter.ts', import.meta.url), 'utf8');
test('closing the importer dialog invalidates, cancels, and clears an active plugin request', () => {
assert.match(hookSource, /pluginImportGenerationRef\.current \+= 1;/u);
assert.match(hookSource, /const requestId = activePluginImportRequestRef\.current;[\s\S]*activePluginImportRequestRef\.current = null;[\s\S]*pluginExtensionBridge\.cancelRequest\(requestId\)/u);
assert.match(hookSource, /setPluginPreview\(null\);[\s\S]*setPluginProgress\(null\);[\s\S]*setPluginBusy\(false\);/u);
});
test('late plugin importer results cannot repopulate state after close or replacement', () => {
assert.match(hookSource, /const generation = \+\+pluginImportGenerationRef\.current;/u);
assert.match(hookSource, /const isCurrent = \(\) => pluginImportGenerationRef\.current === generation;/u);
assert.match(hookSource, /if \(!selection \|\| !isCurrent\(\)\) return;/u);
assert.match(hookSource, /if \(isCurrent\(\)\) setPluginPreview\(preview\);/u);
assert.match(hookSource, /if \(isCurrent\(\)\) setPluginError/u);
assert.match(hookSource, /if \(selection && !consumed\)[\s\S]*releaseImporterFile\(selection\.selectionToken\)/u);
});
test('importer detection is cancellable before awaiting provider work', () => {
assert.match(
hookSource,
/requestId = crypto\.randomUUID\(\);[\s\S]*activePluginImportRequestRef\.current = requestId;[\s\S]*await pluginExtensionBridge\.detectImporter\(\{\s*requestId,/u,
);
});
test('plugin importer bridge lifecycle is owned by application state, not the dialog component', () => {
assert.match(hookSource, /pluginExtensionBridge\.selectImporterFile/u);
assert.match(hookSource, /pluginExtensionBridge\.parseImporterFile/u);
assert.doesNotMatch(dialogSource, /pluginExtensionBridge/u);
});

View File

@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
VaultImportDestinationControls,
VaultImportProgressPanel,
VaultImportProgressView,
} from "./ImportVaultDialog.tsx";
const messages: Record<string, string> = {
"vault.import.progress.title": "Importing hosts",
"vault.import.progress.reading": "Reading file",
"vault.import.progress.parsing": "Parsing hosts",
"vault.import.progress.preparing": "Preparing changes",
"vault.import.progress.saving": "Saving hosts",
"vault.import.progress.complete": "Import complete",
"vault.import.progress.failed": "Import failed",
"vault.import.progress.summary": "Imported {count} hosts; skipped {skipped}; duplicates {duplicates}.",
"vault.import.progress.keepOpen": "You can keep using Netcatty while this runs.",
"vault.import.progress.fileSummary": "{name} · {count} files",
"vault.import.progress.fileCount": "{completed} of {total} files",
"common.close": "Close",
"common.cancel": "Cancel",
};
const t = (key: string, values?: Record<string, unknown>) => {
let value = messages[key] ?? key;
for (const [name, replacement] of Object.entries(values ?? {})) {
value = value.replace(`{${name}}`, String(replacement));
}
return value;
};
test("vault import progress renders the current background stage and percent", () => {
const html = renderToStaticMarkup(
<VaultImportProgressView
progress={{
status: "running",
stage: "parsing",
percent: 55,
formatLabel: "CSV",
fileName: "hosts.csv",
}}
onClose={() => {}}
onCancel={() => {}}
t={t}
/>,
);
assert.match(html, /Importing hosts/);
assert.match(html, /hosts\.csv/);
assert.match(html, /Parsing hosts/);
assert.match(html, /aria-valuenow="55"/);
assert.match(html, /role="status"/);
assert.match(html, /aria-live="polite"/);
assert.match(html, /<span[^>]*role="status"[^>]*>Parsing hosts<\/span>/);
assert.match(html, />Cancel</);
assert.doesNotMatch(html, />Close</);
});
test("vault import progress keeps the final result visible until the user closes it", () => {
const html = renderToStaticMarkup(
<VaultImportProgressView
progress={{
status: "complete",
stage: "complete",
percent: 100,
formatLabel: "CSV",
fileName: "hosts.csv",
imported: 8000,
skipped: 3,
duplicates: 2,
}}
onClose={() => {}}
t={t}
/>,
);
assert.match(html, /Import complete/);
assert.match(html, /Imported 8000 hosts; skipped 3; duplicates 2\./);
assert.match(
html,
/<span[^>]*role="status"[^>]*>Import complete\. Imported 8000 hosts; skipped 3; duplicates 2\.<\/span>/,
);
assert.match(html, /aria-valuenow="100"/);
assert.match(html, />Close</);
});
test("vault import progress announces the failure reason", () => {
const html = renderToStaticMarkup(
<VaultImportProgressView
progress={{
status: "error",
stage: "failed",
percent: 85,
formatLabel: "CSV",
fileName: "hosts.csv",
error: "Saved Vault data is unreadable",
}}
onClose={() => {}}
t={t}
/>,
);
assert.match(
html,
/<span[^>]*role="status"[^>]*>Import failed\. Saved Vault data is unreadable<\/span>/,
);
});
test("vault import progress shows SecureCRT batch file progress", () => {
const html = renderToStaticMarkup(
<VaultImportProgressView
progress={{
status: "running",
stage: "parsing",
percent: 43,
formatLabel: "SecureCRT",
fileName: "Sessions",
completedFiles: 2,
totalFiles: 3,
currentFileName: "DB.ini",
}}
onClose={() => {}}
t={t}
/>,
);
assert.match(html, /Sessions · 3 files/);
assert.match(html, /2 of 3 files/);
assert.match(html, /DB\.ini/);
});
test("vault import progress is shown in a non-blocking floating panel", () => {
const html = renderToStaticMarkup(
<VaultImportProgressPanel
progress={{
status: "running",
stage: "parsing",
percent: 55,
formatLabel: "CSV",
fileName: "hosts.csv",
}}
onClose={() => {}}
onCancel={() => {}}
t={t}
/>,
);
assert.match(html, /data-vault-import-progress-panel/);
assert.doesNotMatch(html, /role="dialog"/);
assert.match(html, /fixed/);
assert.match(html, /max-h-\[calc\(100vh-2rem\)\]/);
assert.match(html, /overflow-y-auto/);
});
test("vault import destination controls offer preserve, existing, and new groups", () => {
const html = renderToStaticMarkup(
<VaultImportDestinationControls
mode="existing"
onModeChange={() => {}}
groups={["Production", "Staging"]}
existingGroup="Production"
onExistingGroupChange={() => {}}
newGroup=""
onNewGroupChange={() => {}}
t={t}
/>,
);
assert.equal((html.match(/data-import-destination-mode=/g) ?? []).length, 3);
assert.match(html, /Production/);
assert.doesNotMatch(html, /Staging/);
assert.match(html, /vault\.import\.destination\.preserve/);
assert.match(html, /vault\.import\.destination\.existing/);
assert.match(html, /vault\.import\.destination\.new/);
});
test("import format step keeps destination settings off the main card grid", async () => {
// Source-level guard: format tiles must not embed SecureCRT copy, and the
// destination picker lives behind a dedicated footer entry point.
const { readFileSync } = await import("node:fs");
const source = readFileSync(
new URL("./ImportVaultDialog.tsx", import.meta.url),
"utf8",
);
assert.match(source, /data-import-destination-settings="true"/);
assert.match(source, /data-import-securecrt-prompt="true"/);
assert.match(source, /step === "destination"/);
assert.match(source, /FolderTree/);
assert.doesNotMatch(
source,
/data-import-format=\{opt\.format\}[\s\S]*securecrt\.directoryHint/,
);
});
test("vault import destination search caps very large group suggestions", () => {
const groups = Array.from({ length: 1000 }, (_, index) => `Group ${index}`);
const html = renderToStaticMarkup(
<VaultImportDestinationControls
mode="existing"
onModeChange={() => {}}
groups={groups}
existingGroup=""
onExistingGroupChange={() => {}}
newGroup=""
onNewGroupChange={() => {}}
t={t}
/>,
);
assert.equal((html.match(/<option/g) ?? []).length, 50);
assert.match(html, /list=/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { VaultDeleteConfirmDialogContent } from "./VaultDeleteConfirmDialog.tsx";
const getElementChildren = (element: React.ReactElement): React.ReactNode =>
(element.props as { children?: React.ReactNode }).children;
const findElement = (
node: React.ReactNode,
predicate: (element: React.ReactElement) => boolean,
): React.ReactElement | null => {
if (
node === null ||
node === undefined ||
typeof node === "boolean" ||
typeof node === "string" ||
typeof node === "number" ||
typeof node === "bigint"
) {
return null;
}
if (React.isValidElement(node)) {
if (predicate(node)) return node;
return findElement(getElementChildren(node), predicate);
}
const children = React.Children.toArray(node);
for (const child of children) {
const found = findElement(child, predicate);
if (found) return found;
}
return null;
};
const findButtonByLabel = (
root: React.ReactElement,
label: string,
): React.ReactElement<{ onClick?: () => void; disabled?: boolean }> => {
const button = findElement(
root,
(element) => getElementChildren(element) === label,
);
assert.ok(button, `Expected to find button labeled ${label}`);
return button as React.ReactElement<{ onClick?: () => void; disabled?: boolean }>;
};
test("VaultDeleteConfirmDialogContent cancels without confirming", () => {
const events: string[] = [];
const root = VaultDeleteConfirmDialogContent({
title: 'Delete "Office Key"?',
description: "This action cannot be undone.",
cancelLabel: "Cancel",
confirmLabel: "Delete",
onCancel: () => events.push("cancel"),
onConfirm: () => events.push("confirm"),
}) as React.ReactElement;
findButtonByLabel(root, "Cancel").props.onClick?.();
assert.deepEqual(events, ["cancel"]);
});
test("VaultDeleteConfirmDialogContent confirms only from the destructive button", () => {
const events: string[] = [];
const root = VaultDeleteConfirmDialogContent({
title: 'Delete "Office Key"?',
description: "This action cannot be undone.",
cancelLabel: "Cancel",
confirmLabel: "Delete",
onCancel: () => events.push("cancel"),
onConfirm: () => events.push("confirm"),
}) as React.ReactElement;
findButtonByLabel(root, "Delete").props.onClick?.();
assert.deepEqual(events, ["confirm"]);
});
test("VaultDeleteConfirmDialogContent disables both actions while busy", () => {
const root = VaultDeleteConfirmDialogContent({
title: 'Delete "Forward 8080"?',
description: "This action cannot be undone.",
descriptionId: "delete-confirm-description",
cancelLabel: "Cancel",
confirmLabel: "Stop & Delete",
disabled: true,
onCancel: () => undefined,
onConfirm: () => undefined,
}) as React.ReactElement;
assert.equal(findButtonByLabel(root, "Cancel").props.disabled, true);
assert.equal(findButtonByLabel(root, "Stop & Delete").props.disabled, true);
assert.ok(findElement(
root,
(element) => (element.props as { id?: string }).id === "delete-confirm-description",
));
});

View File

@@ -0,0 +1,112 @@
import { AlertTriangle } from "lucide-react";
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
interface VaultDeleteConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
disabled?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
interface VaultDeleteConfirmDialogContentProps {
title: string;
description: string;
descriptionId?: string;
cancelLabel: string;
confirmLabel: string;
disabled?: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export const VaultDeleteConfirmDialogContent: React.FC<VaultDeleteConfirmDialogContentProps> = ({
title,
description,
descriptionId,
cancelLabel,
confirmLabel,
disabled = false,
onCancel,
onConfirm,
}) => {
return (
<>
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="flex min-w-0 items-center gap-2 text-destructive">
<AlertTriangle size={20} className="shrink-0" />
<span className="min-w-0 truncate">{title}</span>
</DialogTitle>
<DialogDescription
id={descriptionId}
className="break-words [overflow-wrap:anywhere]"
>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onCancel}
disabled={disabled}
>
{cancelLabel}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={disabled}
>
{confirmLabel}
</Button>
</DialogFooter>
</>
);
};
export const VaultDeleteConfirmDialog: React.FC<VaultDeleteConfirmDialogProps> = ({
open,
title,
description,
confirmLabel,
disabled = false,
onOpenChange,
onConfirm,
}) => {
const { t } = useI18n();
const descriptionId = React.useId();
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!disabled) onOpenChange(nextOpen);
}}>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[400px]"
aria-describedby={descriptionId}
>
<VaultDeleteConfirmDialogContent
title={title}
description={description}
descriptionId={descriptionId}
cancelLabel={t("common.cancel")}
confirmLabel={confirmLabel ?? t("action.delete")}
disabled={disabled}
onCancel={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,37 @@
import React from "react";
import { cn } from "../../lib/utils";
type VaultEntityIconProps = {
icon: React.ReactNode;
className?: string;
title?: string;
};
export const vaultEntityIconClass =
"h-11 w-11 rounded-xl flex items-center justify-center shrink-0";
export const vaultEntityIconSmClass =
"h-8 w-8 rounded-xl flex items-center justify-center shrink-0";
export const vaultPrimaryIconClass = "bg-primary text-primary-foreground";
export const vaultSnippetIconClass = "bg-sky-700 text-white dark:bg-sky-400 dark:text-slate-950";
export const vaultAutomationScriptIconClass = "bg-violet-700 text-white dark:bg-violet-400 dark:text-slate-950";
export const vaultKeyIconClass = "bg-cyan-600 text-white dark:bg-cyan-400 dark:text-slate-950";
export const vaultCertificateIconClass = "bg-teal-600 text-white dark:bg-teal-400 dark:text-slate-950";
export const vaultIdentityIconClass = "bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950";
export const vaultProxyHttpIconClass = "bg-teal-600 text-white dark:bg-teal-400 dark:text-slate-950";
export const vaultProxySocksIconClass = "bg-sky-600 text-white dark:bg-sky-400 dark:text-slate-950";
export const vaultProxyCommandIconClass = "bg-violet-600 text-white dark:bg-violet-400 dark:text-slate-950";
export const VaultEntityIcon: React.FC<VaultEntityIconProps> = ({
icon,
className,
title,
}) => (
<div
className={cn(vaultEntityIconClass, className)}
title={title}
>
{icon}
</div>
);

View File

@@ -0,0 +1,501 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
CheckSquare,
ClipboardCopy,
Clock,
Copy,
Edit2,
FileSymlink,
FolderPlus,
FolderTree,
LayoutGrid,
Pin,
Plug,
Square,
Star,
Trash2,
} from "lucide-react";
import { getEffectiveHostDistro, sanitizeHost } from "../../domain/host.ts";
import type { GroupNode, Host } from "../../types.ts";
import { DistroAvatar } from "../DistroAvatar.tsx";
import { HostTreeView } from "../HostTreeView.tsx";
import { Badge } from "../ui/badge.tsx";
import { Button } from "../ui/button.tsx";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu.tsx";
import { cn } from "../../lib/utils.ts";
import {
getVaultTreeAutoExpandKey,
VaultHostListSection,
} from "./VaultHostListSection.tsx";
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
});
const makeHost = (id: string, label: string): Host => ({
id,
label,
hostname: "router.example.com",
username: "netops",
port: 22,
os: "linux",
tags: [],
notes: "Maintenance notes",
createdAt: 1,
});
const mainHost = makeHost("main-host", "Main Router");
const pinnedHost = makeHost("pinned-host", "Pinned Router");
const recentHost = makeHost("recent-host", "Recent Router");
const groupedHost = makeHost("grouped-host", "Grouped Router");
const group: GroupNode = {
name: "Production",
path: "production",
children: {},
hosts: [mainHost],
totalHostCount: 1,
};
const noop = () => undefined;
test("tree auto-expansion covers both text and tag filters", () => {
assert.equal(getVaultTreeAutoExpandKey("", []), undefined);
assert.ok(getVaultTreeAutoExpandKey("router", []));
assert.ok(getVaultTreeAutoExpandKey("", ["production"]));
assert.equal(
getVaultTreeAutoExpandKey("router", ["production", "linux"]),
getVaultTreeAutoExpandKey("router", ["linux", "production"]),
);
});
type RenderHostListOptions = {
displayedGroups?: GroupNode[];
displayedHosts?: Host[];
groupedDisplayHosts?: Array<{ name: string; hosts: Host[] }>;
isMultiSelectMode?: boolean;
pinnedHosts?: Host[];
pinnedRecentIds?: Set<string>;
recentHosts?: Host[];
showRecentHosts?: boolean;
selectedGroupPaths?: Set<string>;
selectedHostIds?: Set<string>;
sortMode?: string;
treeViewGroupTree?: GroupNode[];
treeViewHosts?: Host[];
viewMode: "list" | "grid" | "tree";
visibleDisplayedHosts?: Host[];
};
const renderHostList = ({
displayedGroups = [group],
displayedHosts = [mainHost],
groupedDisplayHosts,
isMultiSelectMode = false,
pinnedHosts = [],
pinnedRecentIds = new Set<string>(),
recentHosts = [],
showRecentHosts = false,
selectedGroupPaths = new Set<string>(),
selectedHostIds = new Set<string>(),
sortMode = "az",
treeViewGroupTree = [],
treeViewHosts = [],
viewMode,
visibleDisplayedHosts = [mainHost],
}: RenderHostListOptions) => renderToStaticMarkup(
<VaultHostListSection
ctx={{
Badge,
Boolean,
Button,
cancelInlineGroupEdit: noop,
CheckSquare,
ClipboardCopy,
Clock,
cn,
commitInlineGroupRename: noop,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Copy,
displayedGroups,
displayedHosts,
DistroAvatar,
Edit2,
FileSymlink,
FolderPlus,
FolderTree,
getDropTargetClasses: () => "",
getEffectiveHostDistro,
groupConfigs: [],
groupedDisplayHosts,
handleCopyCredentials: noop,
handleCopyHostname: noop,
handleDuplicateHost: noop,
handleEditGroupConfig: noop,
handleEditHost: noop,
handleHostConnect: noop,
handleUnmanageGroup: noop,
hasHostsSidePanel: false,
hostListScrollRef: React.createRef<HTMLDivElement>(),
HostTreeView,
isHostsSectionActive: true,
isMultiSelectMode,
lastPinnedId: null,
LayoutGrid,
managedGroupPaths: new Set<string>(),
moveGroup: noop,
moveHostToGroup: noop,
onDeleteHost: noop,
Pin,
pinnedHosts,
pinnedRecentIds,
Plug,
recentHosts,
reorderGroup: noop,
reorderHost: noop,
sanitizeHost,
selectedGroupPath: null,
selectedGroupPaths,
selectedHostIds,
sessionCount: 0,
setDeleteTargetPath: noop,
setDragOverDropTarget: noop,
setGroupDragOverDropTarget: noop,
setIsDeleteGroupOpen: noop,
setIsNewFolderOpen: noop,
setLastPinnedId: noop,
setNewFolderName: noop,
setSelectedGroupPath: noop,
setTargetParentPath: noop,
shouldHideEmptyRootHostsSection: false,
showRecentHosts,
sortMode,
splitViewGridStyle: undefined,
Square,
Star,
startInlineDeleteGroup: noop,
startInlineNewGroup: noop,
startInlineRenameGroup: noop,
t: (key: string) => key,
toggleGroupSelection: noop,
toggleHostPinned: noop,
toggleHostSelection: noop,
Trash2,
treeExpandedState: {
expandedPaths: new Set<string>(),
togglePath: noop,
expandAll: noop,
collapseAll: noop,
},
treeViewGroupTree,
treeViewHosts,
viewMode,
visibleDisplayedHosts,
}}
/>,
);
const editButtonIndexForHost = (markup: string, hostId: string) =>
markup.indexOf(`data-vault-host-edit-button="${hostId}"`);
const editButtonIndexForGroup = (markup: string, groupPath: string) =>
markup.indexOf(`data-vault-group-edit-button="${groupPath}"`);
const assertListHostPlacement = (markup: string, host: Host) => {
const listLabelIndex = markup.indexOf(host.label);
const listEditIndex = editButtonIndexForHost(markup, host.id);
const listNotesIndex = markup.indexOf('aria-label="Host notes"', listLabelIndex);
assert.ok(listLabelIndex >= 0);
assert.ok(listEditIndex > listLabelIndex);
assert.ok(listNotesIndex > listEditIndex);
};
const assertGridHostPlacement = (markup: string, host: Host) => {
const gridLabelIndex = markup.indexOf(host.label);
const gridNotesIndex = markup.indexOf('aria-label="Host notes"', gridLabelIndex);
const gridEditIndex = editButtonIndexForHost(markup, host.id);
assert.ok(gridLabelIndex >= 0);
assert.ok(gridNotesIndex > gridLabelIndex);
assert.ok(gridEditIndex > gridNotesIndex);
};
const assertListGroupPlacement = (markup: string, groupNode: GroupNode) => {
const listLabelIndex = markup.indexOf(groupNode.name);
const listEditIndex = editButtonIndexForGroup(markup, groupNode.path);
const listCountIndex = markup.indexOf("vault.groups.hostsCount", listLabelIndex);
assert.ok(listLabelIndex >= 0);
assert.ok(listEditIndex > listLabelIndex);
assert.ok(listCountIndex > listEditIndex);
};
const assertGridGroupPlacement = (markup: string, groupNode: GroupNode) => {
const gridLabelIndex = markup.indexOf(groupNode.name);
const gridCountIndex = markup.indexOf("vault.groups.hostsCount", gridLabelIndex);
const gridEditIndex = editButtonIndexForGroup(markup, groupNode.path);
assert.ok(gridLabelIndex >= 0);
assert.ok(gridCountIndex > gridLabelIndex);
assert.ok(gridEditIndex > gridCountIndex);
};
test("VaultHostListSection keeps list edit actions beside host labels in all list sections without changing grid", () => {
const listMarkup = renderHostList({
viewMode: "list",
displayedGroups: [],
displayedHosts: [mainHost, pinnedHost, recentHost],
pinnedHosts: [pinnedHost],
recentHosts: [recentHost],
showRecentHosts: true,
visibleDisplayedHosts: [mainHost],
});
assertListHostPlacement(listMarkup, pinnedHost);
assertListHostPlacement(listMarkup, recentHost);
assertListHostPlacement(listMarkup, mainHost);
const gridMarkup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [mainHost, pinnedHost, recentHost],
pinnedHosts: [pinnedHost],
recentHosts: [recentHost],
showRecentHosts: true,
visibleDisplayedHosts: [mainHost],
});
assertGridHostPlacement(gridMarkup, pinnedHost);
assertGridHostPlacement(gridMarkup, recentHost);
assertGridHostPlacement(gridMarkup, mainHost);
});
test("VaultHostListSection keeps grouped host edit actions beside labels without changing grid", () => {
const listMarkup = renderHostList({
viewMode: "list",
displayedGroups: [],
displayedHosts: [groupedHost],
groupedDisplayHosts: [{ name: "Routers", hosts: [groupedHost] }],
sortMode: "group",
visibleDisplayedHosts: [],
});
assertListHostPlacement(listMarkup, groupedHost);
const gridMarkup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [groupedHost],
groupedDisplayHosts: [{ name: "Routers", hosts: [groupedHost] }],
sortMode: "group",
visibleDisplayedHosts: [],
});
assertGridHostPlacement(gridMarkup, groupedHost);
});
test("VaultHostListSection keeps list group edit action beside the group label without changing grid", () => {
const listMarkup = renderHostList({
viewMode: "list",
displayedGroups: [group],
displayedHosts: [],
visibleDisplayedHosts: [],
});
assertListGroupPlacement(listMarkup, group);
const gridMarkup = renderHostList({
viewMode: "grid",
displayedGroups: [group],
displayedHosts: [],
visibleDisplayedHosts: [],
});
assertGridGroupPlacement(gridMarkup, group);
});
test("VaultHostListSection exposes selectable groups to keyboard and assistive technology", () => {
const markup = renderHostList({
viewMode: "list",
displayedGroups: [group],
displayedHosts: [],
visibleDisplayedHosts: [],
isMultiSelectMode: true,
selectedGroupPaths: new Set([group.path]),
});
assert.match(markup, /data-group-path="production"[^>]*role="checkbox"/);
assert.match(markup, /data-group-path="production"[^>]*aria-checked="true"/);
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
});
test("VaultHostListSection exposes normal group cards to keyboard", () => {
const markup = renderHostList({
viewMode: "list",
displayedGroups: [group],
displayedHosts: [],
visibleDisplayedHosts: [],
});
assert.match(markup, /data-group-path="production"[^>]*role="button"/);
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
});
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
const markup = renderHostList({
viewMode: "list",
displayedGroups: [],
displayedHosts: [mainHost],
visibleDisplayedHosts: [mainHost],
isMultiSelectMode: true,
selectedHostIds: new Set([mainHost.id]),
});
assert.match(markup, /data-host-id="main-host"[^>]*role="checkbox"/);
assert.match(markup, /data-host-id="main-host"[^>]*aria-checked="true"/);
assert.match(markup, /data-host-id="main-host"[^>]*tabindex="0"/);
});
test("VaultHostListSection exposes grouped hosts to keyboard and assistive technology", () => {
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [groupedHost],
groupedDisplayHosts: [{ name: "Production", hosts: [groupedHost] }],
visibleDisplayedHosts: [groupedHost],
isMultiSelectMode: true,
selectedHostIds: new Set([groupedHost.id]),
sortMode: "group",
});
assert.match(markup, /data-host-id="grouped-host"[^>]*role="checkbox"/);
assert.match(markup, /data-host-id="grouped-host"[^>]*aria-checked="true"/);
assert.match(markup, /data-host-id="grouped-host"[^>]*tabindex="0"/);
});
test("VaultHostListSection virtualizes large grid collections without hiding search results", () => {
const hosts = Array.from({ length: 300 }, (_, index) => (
makeHost(`bulk-${index}`, `Bulk ${index}`)
));
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: hosts,
visibleDisplayedHosts: hosts,
});
const renderedHosts = (markup.match(/data-vault-grid-item="main:/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.doesNotMatch(markup, /vault\.hosts\.showMore/);
});
test("VaultHostListSection virtualizes large pinned collections", () => {
const hosts = Array.from({ length: 300 }, (_, index) => (
{ ...makeHost(`pinned-${index}`, `Pinned ${index}`), pinned: true }
));
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [],
visibleDisplayedHosts: [],
pinnedHosts: hosts,
});
const renderedHosts = (markup.match(/data-vault-grid-item="pinned:/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.match(markup, /data-vault-virtual-collection="grid"/);
});
test("VaultHostListSection virtualizes large recently connected collections", () => {
const hosts = Array.from({ length: 300 }, (_, index) => (
makeHost(`recent-${index}`, `Recent ${index}`)
));
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [],
visibleDisplayedHosts: [],
recentHosts: hosts,
showRecentHosts: true,
});
const renderedHosts = (markup.match(/data-vault-grid-item="recent:/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.match(markup, /data-vault-virtual-collection="grid"/);
});
test("VaultHostListSection virtualizes large group collections", () => {
const groups = Array.from({ length: 300 }, (_, index): GroupNode => ({
name: `Group ${index}`,
path: `group-${index}`,
children: {},
hosts: [],
totalHostCount: 0,
}));
const markup = renderHostList({
viewMode: "grid",
displayedGroups: groups,
displayedHosts: [],
visibleDisplayedHosts: [],
});
const renderedGroups = (markup.match(/data-vault-grid-item="group:/g) ?? []).length;
assert.ok(renderedGroups > 0);
assert.ok(renderedGroups < 100);
assert.match(markup, /data-vault-virtual-collection="grid"/);
});
test("VaultHostListSection preserves grouped totals while virtualizing rendered cards", () => {
const hosts = Array.from({ length: 300 }, (_, index) => (
makeHost(`grouped-bulk-${index}`, `Grouped Bulk ${index}`)
));
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: hosts,
groupedDisplayHosts: [{ name: "Large group", hosts }],
sortMode: "group",
visibleDisplayedHosts: [],
});
const renderedHosts = (markup.match(/data-vault-grid-item="grouped:/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.match(markup, /\(300\)/);
});
test("VaultHostListSection exposes hostname copy in host context menus, not as a hover button", () => {
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /data-vault-host-copy-hostname-button/);
assert.doesNotMatch(source, /renderHostCopyHostnameButton/);
assert.match(source, /terminal\.statusbar\.copyHostname\.label/);
assert.match(source, /handleCopyHostname\(host\)/);
assert.match(source, /!isPluginHostProtocol\(host\.protocol\)/);
});
test("VaultHostListSection opens dual-pane SFTP from host context menus", () => {
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
assert.match(source, /OpenDualPaneSftpMenuItem/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import { Search } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Input } from "../ui/input";
interface VaultPageHeaderProps {
children: React.ReactNode;
className?: string;
contentClassName?: string;
dataSection?: string;
}
export function VaultPageHeader({
children,
className,
contentClassName,
dataSection,
}: VaultPageHeaderProps) {
return (
<header
className={cn(
"relative shrink-0 bg-background/95 app-drag after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-bottom after:[transform:scaleY(.5)] after:bg-border/40 after:content-['']",
className,
)}
data-section={dataSection}
>
<div
className={cn(
"h-14 px-4 py-2 flex items-center gap-3 app-no-drag",
contentClassName,
)}
>
{children}
</div>
</header>
);
}
interface VaultHeaderSearchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
className?: string;
inputClassName?: string;
rightAdornment?: React.ReactNode;
}
export function VaultHeaderSearch({
className,
inputClassName,
rightAdornment,
...props
}: VaultHeaderSearchProps) {
return (
<div className={cn("relative min-w-[100px]", className)}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
{...props}
className={cn(
"pl-9 h-10 bg-secondary border-border/60 text-sm",
rightAdornment && "pr-9",
inputClassName,
)}
/>
{rightAdornment && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{rightAdornment}
</div>
)}
</div>
);
}
export const vaultHeaderSecondaryButtonClass =
"h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40";
export const vaultHeaderIconButtonClass = "h-10 w-10";
export const vaultSectionTitleClass = "text-base font-semibold text-muted-foreground";

View File

@@ -0,0 +1,201 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
VaultTreeGroupRow,
VaultTreeInlineRenameInput,
VaultTreeItemRow,
} from "./VaultTreeRow.tsx";
test("VaultTreeGroupRow exposes shared selected and expanded tree row state", () => {
const markup = renderToStaticMarkup(
<VaultTreeGroupRow
name="Production"
depth={1}
expanded={true}
selected={true}
count={3}
onClick={() => undefined}
onToggle={() => undefined}
/>,
);
assert.match(markup, /data-vault-tree-row="group"/);
assert.match(markup, /data-selected="true"/);
assert.match(markup, /data-expanded="true"/);
assert.match(markup, /Production/);
assert.match(markup, /3/);
});
test("VaultTreeGroupRow can render an action beside the group label", () => {
const markup = renderToStaticMarkup(
<VaultTreeGroupRow
name="Production"
depth={1}
count={3}
labelActions={<button data-label-action="edit">Edit</button>}
actions={<span data-row-action="count">Row action</span>}
/>,
);
const labelIndex = markup.indexOf("Production");
const labelActionIndex = markup.indexOf('data-label-action="edit"');
const countIndex = markup.indexOf(">3<", labelActionIndex);
const rowActionIndex = markup.indexOf('data-row-action="count"', countIndex);
assert.ok(labelIndex >= 0);
assert.ok(labelActionIndex > labelIndex);
assert.ok(countIndex > labelActionIndex);
assert.ok(rowActionIndex > countIndex);
});
test("VaultTreeItemRow exposes shared selected item state", () => {
const markup = renderToStaticMarkup(
<VaultTreeItemRow
label="Failover checklist"
depth={2}
selected={true}
onClick={() => undefined}
/>,
);
assert.match(markup, /data-vault-tree-row="item"/);
assert.match(markup, /data-selected="true"/);
assert.match(markup, /Failover checklist/);
});
test("VaultTree rows can tighten the icon-to-label gap per surface", () => {
const groupMarkup = renderToStaticMarkup(
<VaultTreeGroupRow name="Production" depth={0} iconClassName="mr-1" />,
);
const itemMarkup = renderToStaticMarkup(
<VaultTreeItemRow label="Failover checklist" depth={0} iconClassName="mr-1" />,
);
assert.match(groupMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
assert.match(itemMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
assert.doesNotMatch(groupMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
assert.doesNotMatch(itemMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
});
test("VaultTree labels use CJK-safe line-height under truncate", () => {
const groupMarkup = renderToStaticMarkup(
<VaultTreeGroupRow name="服务器配置" depth={0} count={1} />,
);
const itemMarkup = renderToStaticMarkup(
<VaultTreeItemRow label="机器安全检查报告" depth={0} />,
);
// leading-none clips CJK (PingFang etc.) when truncate applies overflow:hidden.
assert.match(groupMarkup, /leading-5/);
assert.doesNotMatch(groupMarkup, /leading-none/);
assert.match(itemMarkup, /leading-5/);
assert.doesNotMatch(itemMarkup, /leading-none/);
assert.match(groupMarkup, /flex min-w-0 flex-1 items-center/);
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
assert.match(groupMarkup, /min-w-0 truncate translate-y-px/);
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5 translate-y-px/);
assert.doesNotMatch(groupMarkup, /-translate-y-px/);
assert.doesNotMatch(itemMarkup, /-translate-y-px/);
});
test("VaultTreeItemRow does not clip the inline rename input", () => {
const markup = renderToStaticMarkup(
<VaultTreeItemRow
label="机器安全检查报告"
depth={0}
editing={true}
onRenameCommit={() => undefined}
onRenameCancel={() => undefined}
/>,
);
const document = new JSDOM(markup).window.document;
const input = document.querySelector<HTMLInputElement>('[data-vault-tree-inline-edit="true"]');
assert.ok(input);
assert.equal(input.parentElement?.classList.contains("h-5"), false);
});
test("VaultTree labels expose full title and keep actions from shrinking", () => {
const groupMarkup = renderToStaticMarkup(
<VaultTreeGroupRow
name="Dokploy服务信息"
depth={0}
actions={<button type="button" data-row-action="menu"></button>}
/>,
);
const itemMarkup = renderToStaticMarkup(
<VaultTreeItemRow
label="Security Audit Report - 完整版"
depth={1}
actions={<button type="button" data-row-action="menu"></button>}
/>,
);
assert.match(groupMarkup, /title="Dokploy服务信息"/);
assert.match(groupMarkup, /min-w-0 truncate/);
assert.match(groupMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
assert.match(itemMarkup, /title="Security Audit Report - 完整版"/);
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
assert.match(itemMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
});
test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
const markup = renderToStaticMarkup(
<VaultTreeInlineRenameInput
initialName="Ops"
onCommit={() => undefined}
onCancel={() => undefined}
/>,
);
assert.match(markup, /data-vault-tree-inline-edit="true"/);
assert.match(markup, /value="Ops"/);
});
test("VaultTreeInlineRenameInput can retry after an asynchronous commit failure", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(
<VaultTreeInlineRenameInput
initialName="Ops"
onCommit={async () => {
attempts += 1;
return attempts > 1;
}}
onCancel={() => undefined}
/>,
);
});
const input = renderer!.root.findByType("input");
const pressEnter = async () => {
input.props.onKeyDown({
key: "Enter",
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
};
await act(pressEnter);
await act(pressEnter);
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});

View File

@@ -0,0 +1,258 @@
import { ChevronRight, FileText, Folder, FolderOpen } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils";
type VaultTreeInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
};
export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProps> = ({
initialName,
onCommit,
onCancel,
className,
style,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
return (
<input
ref={inputRef}
data-vault-tree-inline-edit="true"
data-inline-group-edit="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
void commit();
}
if (event.key === "Escape") {
event.preventDefault();
cancel();
}
}}
className={cn(
"min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30",
className,
)}
style={style}
/>
);
};
type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
name: string;
depth: number;
expanded?: boolean;
selected?: boolean;
count?: number;
hasChildren?: boolean;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
actions?: React.ReactNode;
labelActions?: React.ReactNode;
icon?: React.ReactNode;
iconSize?: number;
iconClassName?: string;
meta?: React.ReactNode;
rowRef?: React.Ref<HTMLDivElement>;
onToggle?: () => void;
};
export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
name,
depth,
expanded = false,
selected = false,
count,
hasChildren,
editing = false,
editingInitialName,
onRenameCommit,
onRenameCancel,
actions,
labelActions,
icon,
iconSize = 18,
iconClassName,
meta,
rowRef,
className,
style,
...props
}) => {
const canExpand = hasChildren ?? Boolean(count);
return (
<div
ref={rowRef}
className={cn(
"vault-drop-indicator-row group flex h-7 min-w-0 items-center px-2 text-sm font-medium cursor-pointer transition-colors select-none rounded-md",
selected
? "bg-secondary text-foreground"
: "hover:bg-secondary/60",
className,
)}
style={{ paddingLeft: depth * 16 + 4, ...style }}
data-vault-tree-row="group"
data-selected={selected ? "true" : "false"}
data-expanded={expanded ? "true" : "false"}
{...props}
>
<div className="mr-1 flex w-4 shrink-0 items-center justify-center text-muted-foreground">
{canExpand && (
<div className={cn("transition-transform duration-200", expanded ? "rotate-90" : "")}>
<ChevronRight size={14} />
</div>
)}
</div>
<div className={cn("mr-2 flex shrink-0 items-center text-current", iconClassName)}>
{icon ?? (expanded ? (
<FolderOpen size={iconSize} strokeWidth={1.9} />
) : (
<Folder size={iconSize} strokeWidth={1.9} />
))}
</div>
{editing && onRenameCommit && onRenameCancel ? (
<VaultTreeInlineRenameInput
initialName={editingInitialName ?? name}
onCommit={onRenameCommit}
onCancel={onRenameCancel}
className="flex-1 font-semibold"
/>
) : (
// leading-5 (not leading-none): CJK fallbacks like PingFang paint outside a 1.0 em box and get clipped by truncate.
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-5">
<span className="min-w-0 truncate translate-y-px" title={name}>{name}</span>
{labelActions ? <span className="shrink-0">{labelActions}</span> : null}
</span>
)}
{meta ? <div className="shrink-0">{meta}</div> : null}
{typeof count === "number" && count > 0 && (
<span className="shrink-0 rounded-full border border-border bg-background/50 px-1.5 py-0 text-[10px] opacity-70">
{count}
</span>
)}
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
);
};
type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
label: string;
depth: number;
selected?: boolean;
icon?: React.ReactNode;
iconClassName?: string;
leading?: React.ReactNode;
detail?: React.ReactNode;
actions?: React.ReactNode;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
content?: React.ReactNode;
};
export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
label,
depth,
selected = false,
icon,
iconClassName,
leading,
detail,
actions,
editing = false,
editingInitialName,
onRenameCommit,
onRenameCancel,
content,
className,
style,
...props
}) => (
<div
className={cn(
"vault-drop-indicator-row group flex h-7 min-w-0 items-center px-2 text-sm cursor-pointer transition-colors select-none rounded-md",
selected
? "bg-secondary text-foreground"
: "hover:bg-secondary/40",
className,
)}
style={{ paddingLeft: depth * 16 + 4, ...style }}
data-vault-tree-row="item"
data-selected={selected ? "true" : "false"}
{...props}
>
{leading ?? <div className="mr-1 w-4 shrink-0" />}
<div className={cn("mr-2 flex shrink-0 items-center", !icon && "text-muted-foreground", iconClassName)}>
{icon ?? <FileText size={14} />}
</div>
{content ?? (
<div className="min-w-0 flex-1 overflow-hidden">
{editing && onRenameCommit && onRenameCancel ? (
<VaultTreeInlineRenameInput
initialName={editingInitialName ?? label}
onCommit={onRenameCommit}
onCancel={onRenameCancel}
/>
) : (
// leading-5 keeps CJK glyphs inside the line box under truncate overflow.
<div className="min-w-0 flex-1 truncate leading-5 translate-y-px" title={label}>{label}</div>
)}
{detail && <div className="truncate text-xs leading-4 text-muted-foreground">{detail}</div>}
</div>
)}
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
getNextRaggedRowPosition,
getNextVirtualHostIndex,
resolveVirtualFocusRequest,
shouldApplyVirtualHostDomFocus,
VirtualizedGroupedHostCollection,
VirtualizedHostCollection,
} from "./VirtualizedHostCollection.tsx";
import { getVaultHostGridColumnCount } from "./vaultHostGridLayout.ts";
test("virtual focus retries when an active item enters a collection", () => {
assert.deepEqual(resolveVirtualFocusRequest({
activeItemKey: "host-1",
lastRequestedKey: "host-1",
itemIndexByKey: new Map(),
}), { status: "missing" });
assert.deepEqual(resolveVirtualFocusRequest({
activeItemKey: "host-1",
lastRequestedKey: null,
itemIndexByKey: new Map([["host-1", 3]]),
}), { status: "request", key: "host-1", index: 3 });
});
test("virtual host DOM focus does not steal from outside controls", () => {
const inside = { tagName: "BUTTON" } as unknown as Element;
const searchInput = { tagName: "INPUT" } as unknown as Element;
const body = { tagName: "BODY" } as unknown as Element;
const collectionRoot = {
contains(node: Node) {
return node === inside;
},
};
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: inside,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: searchInput,
}), false);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: body,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot: null,
activeElement: inside,
}), false);
});
test("host grids use the same fixed card-width column calculation", () => {
assert.equal(getVaultHostGridColumnCount(219), 1);
assert.equal(getVaultHostGridColumnCount(452), 2);
assert.equal(getVaultHostGridColumnCount(684), 3);
assert.equal(getVaultHostGridColumnCount(916), 4);
assert.equal(getVaultHostGridColumnCount(1600), 4);
});
test("virtualized host keyboard navigation crosses rows and collection edges", () => {
assert.equal(getNextVirtualHostIndex({
currentIndex: 1,
itemCount: 20,
columns: 4,
viewMode: "grid",
key: "ArrowDown",
}), 5);
assert.equal(getNextVirtualHostIndex({
currentIndex: 5,
itemCount: 20,
columns: 4,
viewMode: "grid",
key: "ArrowLeft",
}), 4);
assert.equal(getNextVirtualHostIndex({
currentIndex: 18,
itemCount: 20,
columns: 4,
viewMode: "list",
key: "End",
}), 19);
assert.equal(getNextVirtualHostIndex({
currentIndex: 0,
itemCount: 20,
columns: 1,
viewMode: "list",
key: "ArrowUp",
}), 0);
});
test("grouped grid navigation preserves the actual column across ragged rows", () => {
assert.deepEqual(getNextRaggedRowPosition({
rowLengths: [1, 3, 3],
currentRow: 0,
currentColumn: 0,
direction: 1,
}), { row: 1, column: 0 });
assert.deepEqual(getNextRaggedRowPosition({
rowLengths: [3, 1, 3],
currentRow: 0,
currentColumn: 2,
direction: 1,
}), { row: 1, column: 0 });
});
test("virtualized host grid renders only the viewport window for a large collection", () => {
const items = Array.from({ length: 8000 }, (_, index) => ({ id: `host-${index}` }));
const html = renderToStaticMarkup(
<VirtualizedHostCollection
items={items}
itemKey={(item) => item.id}
scrollRef={React.createRef<HTMLDivElement>()}
viewMode="grid"
ariaLabel="Hosts"
renderItem={(item) => <div data-host-id={item.id} />}
/>,
);
const renderedHosts = (html.match(/data-host-id=/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.match(html, /data-vault-virtual-row=/);
assert.match(html, /role="grid"/);
assert.match(html, /aria-rowcount="2000"/);
assert.match(html, /role="gridcell"/);
});
test("virtualized host list keeps one fixed-height item per row", () => {
const items = Array.from({ length: 8000 }, (_, index) => ({ id: `host-${index}` }));
const html = renderToStaticMarkup(
<VirtualizedHostCollection
items={items}
itemKey={(item) => item.id}
scrollRef={React.createRef<HTMLDivElement>()}
viewMode="list"
ariaLabel="Hosts"
renderItem={(item) => <div data-host-id={item.id} />}
/>,
);
const renderedHosts = (html.match(/data-host-id=/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 40);
assert.match(html, /grid-template-columns:repeat\(1, minmax\(0, 1fr\)\)/);
assert.match(html, /role="list"/);
assert.match(html, /aria-setsize="8000"/);
});
test("grouped host grid uses one virtual window across every group", () => {
const groups = Array.from({ length: 80 }, (_, groupIndex) => ({
name: `group-${groupIndex}`,
hosts: Array.from({ length: 100 }, (_, hostIndex) => ({
id: `host-${groupIndex}-${hostIndex}`,
})),
}));
const html = renderToStaticMarkup(
<VirtualizedGroupedHostCollection
groups={groups}
itemKey={(item) => item.id}
scrollRef={React.createRef<HTMLDivElement>()}
viewMode="grid"
ariaLabel="Hosts"
renderGroupHeader={(group) => <div data-group-name={group.name} />}
renderItem={(item) => <div data-host-id={item.id} />}
/>,
);
const renderedHosts = (html.match(/data-host-id=/g) ?? []).length;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.equal((html.match(/data-vault-virtual-grouped-collection=/g) ?? []).length, 1);
});

View File

@@ -0,0 +1,649 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import React from "react";
import {
getVaultHostGridColumnCount,
VAULT_HOST_GRID_GAP,
} from "./vaultHostGridLayout";
const GRID_CARD_HEIGHT = 68;
const LIST_ROW_HEIGHT = 56;
const INITIAL_VIEWPORT_HEIGHT = 800;
const OVERSCAN_ROWS = 3;
export type VirtualizedHostViewMode = "grid" | "list";
export function getVaultHostColumnCount(
width: number,
viewMode: VirtualizedHostViewMode,
): number {
if (viewMode !== "grid") return 1;
return getVaultHostGridColumnCount(width);
}
export function getNextVirtualHostIndex({
currentIndex,
itemCount,
columns,
viewMode,
key,
}: {
currentIndex: number;
itemCount: number;
columns: number;
viewMode: VirtualizedHostViewMode;
key: string;
}): number | null {
let nextIndex = currentIndex;
if (key === "Home") nextIndex = 0;
else if (key === "End") nextIndex = itemCount - 1;
else if (key === "ArrowLeft" && viewMode === "grid") nextIndex -= 1;
else if (key === "ArrowRight" && viewMode === "grid") nextIndex += 1;
else if (key === "ArrowUp") nextIndex -= viewMode === "grid" ? columns : 1;
else if (key === "ArrowDown") nextIndex += viewMode === "grid" ? columns : 1;
else return null;
return Math.max(0, Math.min(itemCount - 1, nextIndex));
}
export function getNextRaggedRowPosition({
rowLengths,
currentRow,
currentColumn,
direction,
}: {
rowLengths: number[];
currentRow: number;
currentColumn: number;
direction: -1 | 1;
}): { row: number; column: number } {
if (rowLengths.length === 0) return { row: 0, column: 0 };
const row = Math.max(0, Math.min(rowLengths.length - 1, currentRow + direction));
return {
row,
column: Math.max(0, Math.min(rowLengths[row] - 1, currentColumn)),
};
}
export function resolveVirtualFocusRequest({
activeItemKey,
lastRequestedKey,
itemIndexByKey,
}: {
activeItemKey: React.Key | null | undefined;
lastRequestedKey: string | null;
itemIndexByKey: ReadonlyMap<string, number>;
}):
| { status: "inactive" | "missing" | "unchanged" }
| { status: "request"; key: string; index: number } {
if (activeItemKey === null || activeItemKey === undefined) return { status: "inactive" };
const key = String(activeItemKey);
const index = itemIndexByKey.get(key);
if (index === undefined) return { status: "missing" };
if (lastRequestedKey === key) return { status: "unchanged" };
return { status: "request", key, index };
}
/**
* Virtual collections may retry DOM focus when a remembered active item
* re-enters the filtered set. Never steal focus from controls outside the
* collection (e.g. the vault host search input).
*/
export function shouldApplyVirtualHostDomFocus(input: {
collectionRoot: { contains: (node: Node) => boolean } | null | undefined;
activeElement: Element | null | undefined;
}): boolean {
const { collectionRoot, activeElement } = input;
if (!collectionRoot) return false;
if (!activeElement) return true;
const tagName = activeElement.tagName;
if (tagName === "BODY" || tagName === "HTML") return true;
return collectionRoot.contains(activeElement);
}
export function VirtualizedHostCollection<T>({
items,
itemKey,
renderItem,
scrollRef,
viewMode,
layoutKey,
ariaLabel,
onActiveItemChange,
activeItemKey,
onBoundaryNavigation,
onDragOver,
onDrop,
}: {
items: T[];
itemKey: (item: T) => React.Key;
renderItem: (item: T) => React.ReactNode;
scrollRef: React.RefObject<HTMLDivElement | null>;
viewMode: VirtualizedHostViewMode;
layoutKey?: React.Key;
ariaLabel?: string;
onActiveItemChange?: (item: T) => void;
activeItemKey?: React.Key | null;
onBoundaryNavigation?: (direction: "previous" | "next") => void;
onDragOver?: React.DragEventHandler<HTMLDivElement>;
onDrop?: React.DragEventHandler<HTMLDivElement>;
}) {
const rootRef = React.useRef<HTMLDivElement>(null);
const pendingFocusKeyRef = React.useRef<string | null>(null);
const lastRequestedActiveKeyRef = React.useRef<string | null>(null);
const [containerWidth, setContainerWidth] = React.useState(
viewMode === "grid" ? 1280 : 0,
);
const [scrollMargin, setScrollMargin] = React.useState(0);
const columns = getVaultHostColumnCount(containerWidth, viewMode);
const rowCount = Math.ceil(items.length / columns);
const rowHeight = viewMode === "grid" ? GRID_CARD_HEIGHT : LIST_ROW_HEIGHT;
const rowGap = viewMode === "grid" ? VAULT_HOST_GRID_GAP : 0;
const itemIndexByKey = React.useMemo(() => new Map(
items.map((item, index) => [String(itemKey(item)), index]),
), [itemKey, items]);
const getRowKey = React.useCallback((rowIndex: number) => {
const firstItem = items[rowIndex * columns];
return firstItem === undefined ? rowIndex : itemKey(firstItem);
}, [columns, itemKey, items]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
gap: rowGap,
getItemKey: getRowKey,
overscan: OVERSCAN_ROWS,
scrollMargin,
initialRect: typeof window === "undefined"
? { width: 1280, height: INITIAL_VIEWPORT_HEIGHT }
: undefined,
});
React.useLayoutEffect(() => {
const root = rootRef.current;
const scrollElement = scrollRef.current;
if (!root || !scrollElement) return;
const measure = () => {
const nextWidth = root.clientWidth;
const rootRect = root.getBoundingClientRect();
const scrollRect = scrollElement.getBoundingClientRect();
const nextScrollMargin = rootRect.top - scrollRect.top + scrollElement.scrollTop;
setContainerWidth((current) => current === nextWidth ? current : nextWidth);
setScrollMargin((current) => current === nextScrollMargin ? current : nextScrollMargin);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
observer.observe(scrollElement);
return () => observer.disconnect();
}, [items.length, layoutKey, scrollRef, viewMode]);
React.useLayoutEffect(() => {
virtualizer.measure();
}, [columns, items.length, virtualizer, viewMode]);
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>(
"[data-host-id], [data-vault-focus-target]",
);
if (!focusTarget) return false;
focusTarget.focus();
return true;
}, []);
React.useLayoutEffect(() => {
const key = pendingFocusKeyRef.current;
if (!key || !focusRenderedItem(key)) return;
pendingFocusKeyRef.current = null;
});
React.useLayoutEffect(() => {
const request = resolveVirtualFocusRequest({
activeItemKey,
lastRequestedKey: lastRequestedActiveKeyRef.current,
itemIndexByKey,
});
if (request.status === "inactive" || request.status === "missing") {
lastRequestedActiveKeyRef.current = null;
return;
}
if (request.status === "unchanged") return;
lastRequestedActiveKeyRef.current = request.key;
pendingFocusKeyRef.current = request.key;
virtualizer.scrollToIndex(Math.floor(request.index / columns), { align: "auto" });
queueMicrotask(() => {
if (focusRenderedItem(request.key)) pendingFocusKeyRef.current = null;
});
}, [activeItemKey, columns, focusRenderedItem, itemIndexByKey, virtualizer]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const wrapper = target.closest<HTMLElement>("[data-vault-item-key]");
const currentIndex = wrapper
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
: undefined;
if (currentIndex === undefined) return;
const nextIndex = getNextVirtualHostIndex({
currentIndex,
itemCount: items.length,
columns,
viewMode,
key: event.key,
});
if (nextIndex === null) return;
if (nextIndex === currentIndex) {
const direction = event.key === "ArrowUp" || event.key === "ArrowLeft"
? "previous"
: event.key === "ArrowDown" || event.key === "ArrowRight"
? "next"
: null;
if (direction && onBoundaryNavigation) {
event.preventDefault();
onBoundaryNavigation(direction);
}
return;
}
event.preventDefault();
const nextItem = items[nextIndex];
const nextKey = String(itemKey(nextItem));
pendingFocusKeyRef.current = nextKey;
onActiveItemChange?.(nextItem);
virtualizer.scrollToIndex(Math.floor(nextIndex / columns), { align: "auto" });
queueMicrotask(() => {
if (focusRenderedItem(nextKey)) pendingFocusKeyRef.current = null;
});
};
return (
<div
ref={rootRef}
className="relative min-w-0"
style={{ height: virtualizer.getTotalSize() }}
data-vault-virtual-collection={viewMode}
role={viewMode === "grid" ? "grid" : "list"}
aria-rowcount={viewMode === "grid" ? rowCount : undefined}
aria-colcount={viewMode === "grid" ? columns : undefined}
aria-label={ariaLabel}
onDragOver={onDragOver}
onDrop={onDrop}
onKeyDownCapture={handleKeyDown}
onFocusCapture={(event) => {
const wrapper = (event.target as HTMLElement).closest<HTMLElement>("[data-vault-item-key]");
const index = wrapper
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
: undefined;
if (index !== undefined) onActiveItemChange?.(items[index]);
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const rowStart = virtualRow.index * columns;
const rowItems = items.slice(rowStart, rowStart + columns);
return (
<div
key={virtualRow.key}
data-vault-virtual-row={virtualRow.index}
className="absolute left-0 top-0 grid w-full min-w-0"
style={{
height: rowHeight,
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
columnGap: viewMode === "grid" ? VAULT_HOST_GRID_GAP : 0,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
role={viewMode === "grid" ? "row" : undefined}
aria-rowindex={viewMode === "grid" ? virtualRow.index + 1 : undefined}
>
{rowItems.map((item, columnIndex) => (
<div
key={itemKey(item)}
className="contents"
data-vault-item-key={String(itemKey(item))}
role={viewMode === "grid" ? "gridcell" : "listitem"}
aria-colindex={viewMode === "grid" ? columnIndex + 1 : undefined}
aria-posinset={viewMode === "list" ? rowStart + columnIndex + 1 : undefined}
aria-setsize={viewMode === "list" ? items.length : undefined}
>
{renderItem(item)}
</div>
))}
</div>
);
})}
</div>
);
}
type VirtualizedHostGroup<T> = {
name: string;
hosts: T[];
};
type VirtualizedGroupedRow<T> =
| { kind: "header"; group: VirtualizedHostGroup<T> }
| {
kind: "hosts";
group: VirtualizedHostGroup<T>;
hosts: T[];
hostStartIndex: number;
};
export function VirtualizedGroupedHostCollection<T>({
groups,
itemKey,
renderGroupHeader,
renderItem,
scrollRef,
viewMode,
layoutKey,
ariaLabel,
onActiveItemChange,
activeItemKey,
onBoundaryNavigation,
}: {
groups: Array<VirtualizedHostGroup<T>>;
itemKey: (item: T) => React.Key;
renderGroupHeader: (group: VirtualizedHostGroup<T>) => React.ReactNode;
renderItem: (item: T, group: VirtualizedHostGroup<T>) => React.ReactNode;
scrollRef: React.RefObject<HTMLDivElement | null>;
viewMode: VirtualizedHostViewMode;
layoutKey?: React.Key;
ariaLabel?: string;
onActiveItemChange?: (item: T) => void;
activeItemKey?: React.Key | null;
onBoundaryNavigation?: (direction: "previous" | "next") => void;
}) {
const rootRef = React.useRef<HTMLDivElement>(null);
const pendingFocusKeyRef = React.useRef<string | null>(null);
const lastRequestedActiveKeyRef = React.useRef<string | null>(null);
const [containerWidth, setContainerWidth] = React.useState(
viewMode === "grid" ? 1280 : 0,
);
const [scrollMargin, setScrollMargin] = React.useState(0);
const columns = getVaultHostColumnCount(containerWidth, viewMode);
const rows = React.useMemo(() => {
const result: Array<VirtualizedGroupedRow<T>> = [];
let hostStartIndex = 0;
for (const group of groups) {
result.push({ kind: "header", group });
for (let index = 0; index < group.hosts.length; index += columns) {
result.push({
kind: "hosts",
group,
hosts: group.hosts.slice(index, index + columns),
hostStartIndex,
});
hostStartIndex += Math.min(columns, group.hosts.length - index);
}
}
return result;
}, [columns, groups]);
const totalHostCount = React.useMemo(
() => groups.reduce((count, group) => count + group.hosts.length, 0),
[groups],
);
const flatItems = React.useMemo(() => groups.flatMap((group) => group.hosts), [groups]);
const itemIndexByKey = React.useMemo(() => new Map(
flatItems.map((item, index) => [String(itemKey(item)), index]),
), [flatItems, itemKey]);
const hostRows = React.useMemo(() => rows.flatMap((row, rowIndex) => (
row.kind === "hosts" ? [{ row, rowIndex }] : []
)), [rows]);
const hostRowLengths = React.useMemo(
() => hostRows.map(({ row }) => row.hosts.length),
[hostRows],
);
const hostPositionByKey = React.useMemo(() => {
const positions = new Map<string, { hostRowIndex: number; column: number }>();
hostRows.forEach(({ row }, hostRowIndex) => {
row.hosts.forEach((item, column) => {
positions.set(String(itemKey(item)), { hostRowIndex, column });
});
});
return positions;
}, [hostRows, itemKey]);
const virtualRowIndexByItemIndex = React.useMemo(() => {
const indices = new Map<number, number>();
hostRows.forEach(({ row, rowIndex }) => {
row.hosts.forEach((_, column) => {
indices.set(row.hostStartIndex + column, rowIndex);
});
});
return indices;
}, [hostRows]);
const hostRowHeight = viewMode === "grid" ? GRID_CARD_HEIGHT + VAULT_HOST_GRID_GAP : LIST_ROW_HEIGHT;
const headerRowHeight = viewMode === "grid" ? 56 : 44;
const getRowKey = React.useCallback((rowIndex: number) => {
const row = rows[rowIndex];
if (!row) return rowIndex;
if (row.kind === "header") return `group:${row.group.name}`;
return `hosts:${row.group.name}:${String(itemKey(row.hosts[0]))}`;
}, [itemKey, rows]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => rows[index]?.kind === "header"
? headerRowHeight
: hostRowHeight,
getItemKey: getRowKey,
overscan: OVERSCAN_ROWS,
scrollMargin,
initialRect: typeof window === "undefined"
? { width: 1280, height: INITIAL_VIEWPORT_HEIGHT }
: undefined,
});
React.useLayoutEffect(() => {
const root = rootRef.current;
const scrollElement = scrollRef.current;
if (!root || !scrollElement) return;
const measure = () => {
const nextWidth = root.clientWidth;
const rootRect = root.getBoundingClientRect();
const scrollRect = scrollElement.getBoundingClientRect();
const nextScrollMargin = rootRect.top - scrollRect.top + scrollElement.scrollTop;
setContainerWidth((current) => current === nextWidth ? current : nextWidth);
setScrollMargin((current) => current === nextScrollMargin ? current : nextScrollMargin);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
observer.observe(scrollElement);
return () => observer.disconnect();
}, [groups.length, layoutKey, scrollRef, viewMode]);
React.useLayoutEffect(() => {
virtualizer.measure();
}, [columns, rows.length, virtualizer, viewMode]);
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>("[data-host-id]");
if (!focusTarget) return false;
focusTarget.focus();
return true;
}, []);
React.useLayoutEffect(() => {
const key = pendingFocusKeyRef.current;
if (!key || !focusRenderedItem(key)) return;
pendingFocusKeyRef.current = null;
});
React.useLayoutEffect(() => {
const request = resolveVirtualFocusRequest({
activeItemKey,
lastRequestedKey: lastRequestedActiveKeyRef.current,
itemIndexByKey,
});
if (request.status === "inactive" || request.status === "missing") {
lastRequestedActiveKeyRef.current = null;
return;
}
if (request.status === "unchanged") return;
lastRequestedActiveKeyRef.current = request.key;
const rowIndex = virtualRowIndexByItemIndex.get(request.index) ?? -1;
pendingFocusKeyRef.current = request.key;
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
queueMicrotask(() => {
if (focusRenderedItem(request.key)) pendingFocusKeyRef.current = null;
});
}, [activeItemKey, focusRenderedItem, itemIndexByKey, virtualizer, virtualRowIndexByItemIndex]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const wrapper = target.closest<HTMLElement>("[data-vault-item-key]");
const currentIndex = wrapper
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
: undefined;
if (currentIndex === undefined) return;
let nextIndex: number | null;
if (viewMode === "grid" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
const currentKey = wrapper?.dataset.vaultItemKey ?? "";
const currentPosition = hostPositionByKey.get(currentKey);
const currentHostRowIndex = currentPosition?.hostRowIndex ?? 0;
const currentColumn = currentPosition?.column ?? 0;
const direction = event.key === "ArrowDown" ? 1 : -1;
const targetPosition = getNextRaggedRowPosition({
rowLengths: hostRowLengths,
currentRow: currentHostRowIndex,
currentColumn,
direction,
});
const targetHostRow = hostRows[targetPosition.row];
const targetItem = targetHostRow?.row.hosts[targetPosition.column];
nextIndex = targetItem === undefined
? currentIndex
: itemIndexByKey.get(String(itemKey(targetItem))) ?? currentIndex;
} else {
nextIndex = getNextVirtualHostIndex({
currentIndex,
itemCount: flatItems.length,
columns,
viewMode,
key: event.key,
});
}
if (nextIndex === null) return;
if (nextIndex === currentIndex) {
const direction = event.key === "ArrowUp"
? "previous"
: event.key === "ArrowDown"
? "next"
: null;
if (direction && onBoundaryNavigation) {
event.preventDefault();
onBoundaryNavigation(direction);
}
return;
}
event.preventDefault();
const nextItem = flatItems[nextIndex];
const nextKey = String(itemKey(nextItem));
const rowIndex = virtualRowIndexByItemIndex.get(nextIndex) ?? -1;
pendingFocusKeyRef.current = nextKey;
onActiveItemChange?.(nextItem);
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
queueMicrotask(() => {
if (focusRenderedItem(nextKey)) pendingFocusKeyRef.current = null;
});
};
return (
<div
ref={rootRef}
className="relative min-w-0"
style={{ height: virtualizer.getTotalSize() }}
data-vault-virtual-grouped-collection={viewMode}
role={viewMode === "grid" ? "grid" : "list"}
aria-rowcount={viewMode === "grid" ? rows.length : undefined}
aria-colcount={viewMode === "grid" ? columns : undefined}
aria-label={ariaLabel}
onKeyDownCapture={handleKeyDown}
onFocusCapture={(event) => {
const wrapper = (event.target as HTMLElement).closest<HTMLElement>("[data-vault-item-key]");
const index = wrapper
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
: undefined;
if (index !== undefined) onActiveItemChange?.(flatItems[index]);
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<div
key={virtualRow.key}
data-vault-virtual-row={virtualRow.index}
className="absolute left-0 top-0 w-full min-w-0"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
role={viewMode === "grid" ? "row" : undefined}
aria-rowindex={viewMode === "grid" ? virtualRow.index + 1 : undefined}
>
{row.kind === "header" ? (
<div
className="flex h-full items-end pb-3"
role={viewMode === "grid" ? "gridcell" : "heading"}
aria-colspan={viewMode === "grid" ? columns : undefined}
aria-level={viewMode === "list" ? 4 : undefined}
>
{renderGroupHeader(row.group)}
</div>
) : (
<div
className="grid w-full min-w-0"
style={{
height: viewMode === "grid" ? GRID_CARD_HEIGHT : LIST_ROW_HEIGHT,
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
columnGap: viewMode === "grid" ? VAULT_HOST_GRID_GAP : 0,
}}
>
{row.hosts.map((item, columnIndex) => (
<div
key={itemKey(item)}
className="contents"
data-vault-item-key={String(itemKey(item))}
role={viewMode === "grid" ? "gridcell" : "listitem"}
aria-colindex={viewMode === "grid" ? columnIndex + 1 : undefined}
aria-posinset={viewMode === "list" ? row.hostStartIndex + columnIndex + 1 : undefined}
aria-setsize={viewMode === "list" ? totalHostCount : undefined}
>
{renderItem(item, row.group)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
import { useHostTreeInlineGroupActions } from './useHostTreeInlineGroupActions';
test('inline group rename stays open when the Vault commit is superseded', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let startRename: ((groupPath: string) => void) | undefined;
let commitRename: ((name: string) => Promise<boolean>) | undefined;
const selectedPaths: Array<string | null> = [];
const Probe = () => {
const actions = useHostTreeInlineGroupActions({
customGroups: ['prod'],
hosts: [],
managedSources: [],
onUpdateCustomGroups: () => undefined,
onCommitGroupPathChange: async () => ({ ok: false, superseded: true }),
selectedGroupPath: 'prod',
setSelectedGroupPath: (path) => selectedPaths.push(path),
ensurePathExpanded: () => undefined,
unnamedGroupLabel: 'New group',
t: (key) => key,
});
startRename = actions.startInlineRenameGroup;
commitRename = actions.commitInlineGroupRename;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
act(() => {
startRename?.('prod');
});
await act(async () => {
await commitRename?.('production');
});
assert.deepEqual(selectedPaths, []);
assert.equal(hostTreeInlineGroupEditStore.getEdit()?.groupPath, 'prod');
} finally {
hostTreeInlineGroupEditStore.clear();
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});

View File

@@ -0,0 +1,157 @@
import { useCallback } from 'react';
import { hostTreeInlineGroupDeleteStore } from '../../application/state/hostTreeInlineGroupDeleteStore';
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
import { hostTreeInlineHostEditStore } from '../../application/state/hostTreeInlineHostEditStore';
import {
allocateUnnamedGroupPath,
applyGroupPathRename,
ensureAncestorPathsExpanded,
groupDisplayName,
} from '../../domain/hostGroupPathMutations';
import type { Host, ManagedSource } from '../../types';
import { toast } from '../ui/toast';
type UseHostTreeInlineGroupActionsParams = {
customGroups: string[];
hosts: Host[];
managedSources: ManagedSource[];
onUpdateCustomGroups: (groups: string[]) => void;
onCommitGroupPathChange: (
sourcePath: string,
nextPath: string,
) => Promise<
| { ok: true }
| { ok: false; error?: string; superseded?: true }
>;
selectedGroupPath: string | null;
setSelectedGroupPath: (path: string | null) => void;
ensurePathExpanded: (path: string) => void;
unnamedGroupLabel: string;
t: (key: string) => string;
};
export function useHostTreeInlineGroupActions({
customGroups,
hosts,
managedSources,
onUpdateCustomGroups,
onCommitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
ensurePathExpanded,
unnamedGroupLabel,
t,
}: UseHostTreeInlineGroupActionsParams) {
const startInlineNewGroup = useCallback((parentPath?: string) => {
hostTreeInlineHostEditStore.clear();
const parent = parentPath ?? null;
const { name, path } = allocateUnnamedGroupPath(customGroups, parent, unnamedGroupLabel);
onUpdateCustomGroups(Array.from(new Set([...customGroups, path])));
if (parent) {
ensureAncestorPathsExpanded(parent, ensurePathExpanded);
ensurePathExpanded(parent);
}
hostTreeInlineGroupEditStore.startEdit({
groupPath: path,
initialName: name,
isNew: true,
});
}, [customGroups, ensurePathExpanded, onUpdateCustomGroups, unnamedGroupLabel]);
const startInlineRenameGroup = useCallback((groupPath: string) => {
hostTreeInlineHostEditStore.clear();
hostTreeInlineGroupEditStore.startEdit({
groupPath,
initialName: groupDisplayName(groupPath),
isNew: false,
});
}, []);
const cancelInlineGroupEdit = useCallback(() => {
const edit = hostTreeInlineGroupEditStore.getEdit();
if (!edit) return;
if (edit.isNew) {
onUpdateCustomGroups(customGroups.filter((groupPath) => groupPath !== edit.groupPath));
}
hostTreeInlineGroupEditStore.clear();
}, [customGroups, onUpdateCustomGroups]);
const commitInlineGroupRename = useCallback(async (rawName: string): Promise<boolean> => {
const edit = hostTreeInlineGroupEditStore.getEdit();
if (!edit) return false;
const result = applyGroupPathRename({
renameTargetPath: edit.groupPath,
nextName: rawName,
customGroups,
hosts,
managedSources,
});
if (result.ok === false) {
if (result.error === 'unchanged') {
hostTreeInlineGroupEditStore.clear();
return true;
}
if (result.error === 'required') {
if (edit.isNew) {
cancelInlineGroupEdit();
return true;
}
toast.error(t('vault.groups.errors.required'));
return false;
}
if (result.error === 'invalidChars') {
toast.error(t('vault.groups.errors.invalidChars'));
return false;
}
if (result.error === 'duplicatePath') {
toast.error(t('vault.groups.errors.duplicatePath'));
return false;
}
return false;
}
const committed = await onCommitGroupPathChange(edit.groupPath, result.nextPath);
if (!committed.ok) {
toast.error(committed.error || t('common.error'));
return false;
}
if (
selectedGroupPath
&& (selectedGroupPath === edit.groupPath
|| selectedGroupPath.startsWith(`${edit.groupPath}/`))
) {
const suffix = selectedGroupPath === edit.groupPath
? ''
: selectedGroupPath.slice(edit.groupPath.length);
setSelectedGroupPath(result.nextPath + suffix);
}
hostTreeInlineGroupEditStore.clear();
return true;
}, [
cancelInlineGroupEdit,
customGroups,
hosts,
managedSources,
onCommitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
t,
]);
const startInlineDeleteGroup = useCallback((groupPath: string) => {
hostTreeInlineGroupDeleteStore.open(groupPath);
}, []);
return {
startInlineNewGroup,
startInlineRenameGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
startInlineDeleteGroup,
};
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react';
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
import { hostTreeInlineHostEditStore } from '../../application/state/hostTreeInlineHostEditStore';
import { applyHostLabelRename } from '../../domain/host';
import type { Host } from '../../types';
import { toast } from '../ui/toast';
type UseHostTreeInlineHostActionsParams = {
hosts: Host[];
onUpdateHosts: (hosts: Host[]) => void;
t: (key: string) => string;
};
export function useHostTreeInlineHostActions({
hosts,
onUpdateHosts,
t,
}: UseHostTreeInlineHostActionsParams) {
const startInlineRenameHost = useCallback((host: Host) => {
hostTreeInlineGroupEditStore.clear();
hostTreeInlineHostEditStore.startEdit({
hostId: host.id,
initialName: host.label,
});
}, []);
const cancelInlineHostEdit = useCallback(() => {
hostTreeInlineHostEditStore.clear();
}, []);
const commitInlineHostRename = useCallback((rawName: string) => {
const edit = hostTreeInlineHostEditStore.getEdit();
if (!edit) return;
const result = applyHostLabelRename(hosts, edit.hostId, rawName);
if (!result.ok) {
toast.error(t('vault.hosts.errors.nameRequired'));
return;
}
if (result.changed) {
onUpdateHosts(result.hosts);
}
hostTreeInlineHostEditStore.clear();
}, [hosts, onUpdateHosts, t]);
return {
startInlineRenameHost,
commitInlineHostRename,
cancelInlineHostEdit,
};
}

View File

@@ -0,0 +1,109 @@
import { useEffect } from 'react';
import { activeTabStore } from '../../application/state/activeTabStore';
import {
vaultHostTreeActionsStore,
type VaultHostTreeActions,
} from '../../application/state/vaultHostTreeActionsStore';
import type { Host } from '../../types';
import type { VaultOrderPosition } from '../../domain/vaultOrder';
type RegisterVaultHostTreeActionsParams = {
handleCopyCredentials: (host: Host) => void;
handleCopyHostname?: (host: Host) => void;
handleDuplicateHost: (host: Host) => void;
startInlineRenameHost: (host: Host) => void;
onDeleteHost: (hostId: string) => void;
handleUnmanageGroup?: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
reorderHost: (sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => void;
reorderGroup: (sourcePath: string, targetPath: string, position: VaultOrderPosition) => boolean;
managedGroupPaths?: Set<string>;
startInlineNewGroup: (parentPath?: string) => void;
startInlineRenameGroup: (groupPath: string) => void;
startInlineDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
};
function focusVaultTab() {
activeTabStore.setActiveTabId('vault');
}
function withVaultFocus<T extends (...args: never[]) => void>(fn: T): T {
return ((...args: Parameters<T>) => {
focusVaultTab();
fn(...args);
}) as T;
}
export function useRegisterVaultHostTreeActions({
handleCopyCredentials,
handleCopyHostname,
handleDuplicateHost,
startInlineRenameHost,
onDeleteHost,
handleUnmanageGroup,
moveHostToGroup,
moveGroup,
reorderHost,
reorderGroup,
managedGroupPaths,
startInlineNewGroup,
startInlineRenameGroup,
startInlineDeleteGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
commitInlineHostRename,
cancelInlineHostEdit,
}: RegisterVaultHostTreeActionsParams) {
useEffect(() => {
const actions: VaultHostTreeActions = {
onCopyCredentials: handleCopyCredentials,
onCopyHostname: handleCopyHostname,
onDuplicateHost: withVaultFocus(handleDuplicateHost),
onRenameHost: startInlineRenameHost,
onDeleteHost: (host) => onDeleteHost(host.id),
onNewGroup: startInlineNewGroup,
onRenameGroup: startInlineRenameGroup,
onDeleteGroup: startInlineDeleteGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
commitInlineHostRename,
cancelInlineHostEdit,
moveHostToGroup,
moveGroup,
reorderHost,
reorderGroup,
managedGroupPaths,
onUnmanageGroup: handleUnmanageGroup
? withVaultFocus(handleUnmanageGroup)
: undefined,
};
vaultHostTreeActionsStore.setActions(actions);
return () => vaultHostTreeActionsStore.setActions(null);
}, [
cancelInlineGroupEdit,
cancelInlineHostEdit,
commitInlineGroupRename,
commitInlineHostRename,
handleCopyCredentials,
handleCopyHostname,
handleDuplicateHost,
handleUnmanageGroup,
managedGroupPaths,
moveGroup,
moveHostToGroup,
reorderGroup,
reorderHost,
onDeleteHost,
startInlineRenameHost,
startInlineDeleteGroup,
startInlineNewGroup,
startInlineRenameGroup,
]);
}

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "../../lib/utils";
import type { Host, ManagedSource } from "../../types";
import { toast } from "../ui/toast";
type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
interface UseVaultGroupDragHandlersOptions {
hosts: Host[];
managedSources: ManagedSource[];
onUnmanageSource?: (sourceId: string) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateManagedSources: (sources: ManagedSource[]) => void;
t: (key: string, values?: Record<string, unknown>) => string;
}
export function useVaultGroupDragHandlers({
hosts,
managedSources,
onUnmanageSource,
onUpdateHosts,
onUpdateManagedSources,
t,
}: UseVaultGroupDragHandlersOptions) {
const [dragOverDropTarget, setDragOverDropTarget] = useState<DropTarget | null>(null);
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
};
}, []);
const managedGroupPaths = useMemo(() => {
return new Set(managedSources.map(s => s.groupName));
}, [managedSources]);
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === "root") return true;
if (b.kind === "root") return false;
return a.path === b.path;
}, []);
const pulseDropTarget = useCallback((target: DropTarget) => {
setConfirmedDropTarget(target);
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
dropTargetPulseTimeoutRef.current = window.setTimeout(() => {
setConfirmedDropTarget((current) => (isSameDropTarget(current, target) ? null : current));
dropTargetPulseTimeoutRef.current = null;
}, 900);
}, [isSameDropTarget]);
const setGroupDragOverDropTarget = useCallback((path: string | null) => {
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const movedHost = {
...hostToMove,
group: targetGroup,
};
const updatedHost = (() => {
const h = movedHost;
// Only SSH hosts can be managed (SSH config only supports SSH)
const canBeManaged = !h.protocol || h.protocol === "ssh";
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
let label = h.label;
if (targetManagedSource && canBeManaged && label) {
label = label.replace(/\s/g, '');
}
return {
...h,
label,
group: targetGroup,
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
};
})();
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
setDragOverDropTarget(null);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
"!bg-[#e7ebf0] dark:!bg-white/[0.10]",
isSameDropTarget(confirmedDropTarget, target) &&
"!bg-[#dde3ea] dark:!bg-white/[0.14]",
);
const handleUnmanageGroup = useCallback((groupPath: string) => {
const source = managedSources.find(s => s.groupName === groupPath);
if (!source) return;
// Clear managedSourceId from hosts first
const updatedHosts = hosts.map(h =>
h.managedSourceId === source.id
? { ...h, managedSourceId: undefined }
: h
);
onUpdateHosts(updatedHosts);
// Remove the source association without modifying the SSH config file
// This preserves the user's file contents while stopping sync
if (onUnmanageSource) {
onUnmanageSource(source.id);
} else {
// Fallback if onUnmanageSource not available
const updatedSources = managedSources.filter(s => s.id !== source.id);
onUpdateManagedSources(updatedSources);
}
toast.success(t("vault.managedSource.unmanageSuccess"));
}, [managedSources, hosts, onUpdateHosts, onUpdateManagedSources, onUnmanageSource, t]);
return {
getDropTargetClasses,
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
};
}

View File

@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "../../types.ts";
import { filterVaultHostsForDisplay } from "./useVaultHostCollections.tsx";
const host = (id: string, label: string, group = ""): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: "root",
port: 22,
os: "linux",
tags: [],
createdAt: 1,
group,
});
test("root host search can still show matches from any group", () => {
const matchingHost = host("prod-db", "Prod DB", "Production");
const result = filterVaultHostsForDisplay({
filteredHosts: [matchingHost],
searchTerm: "prod",
selectedGroupPath: null,
showOnlyUngroupedHostsInRoot: true,
});
assert.deepEqual(result.map((item) => item.id), ["prod-db"]);
});
test("selected group view does not show search matches from another group", () => {
const matchingHostInOtherGroup = host("prod-db", "Prod DB", "Production");
const result = filterVaultHostsForDisplay({
filteredHosts: [matchingHostInOtherGroup],
searchTerm: "prod",
selectedGroupPath: "Staging",
showOnlyUngroupedHostsInRoot: false,
});
assert.deepEqual(result, []);
});
test("selected General group includes ungrouped hosts while search is active", () => {
const ungroupedHost = host("local", "Local");
const result = filterVaultHostsForDisplay({
filteredHosts: [ungroupedHost],
searchTerm: "local",
selectedGroupPath: "General",
showOnlyUngroupedHostsInRoot: false,
});
assert.deepEqual(result.map((item) => item.id), ["local"]);
});

View 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,
};
}

View File

@@ -0,0 +1,2 @@
/** @deprecated Import from `@/application/state/useVaultImportHandlers` instead. */
export { useVaultImportHandlers } from "../../application/state/useVaultImportHandlers";

View File

@@ -0,0 +1,17 @@
export const VAULT_HOST_GRID_GAP = 12;
export const VAULT_HOST_GRID_MIN_CARD_WIDTH = 220;
export const VAULT_HOST_GRID_MAX_COLUMNS = 4;
export function getVaultHostGridColumnCount(width: number): number {
if (!Number.isFinite(width) || width <= 0) return 1;
return Math.min(
VAULT_HOST_GRID_MAX_COLUMNS,
Math.max(
1,
Math.floor(
(width + VAULT_HOST_GRID_GAP)
/ (VAULT_HOST_GRID_MIN_CARD_WIDTH + VAULT_HOST_GRID_GAP),
),
),
);
}

View File

@@ -0,0 +1,134 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getVaultDropIntent,
getVaultDropPosition,
handleVaultHostDropToGroup,
handleVaultRootDrop,
} from "./vaultReorderDrag.ts";
const makeElement = (rect: Partial<DOMRect>): HTMLElement => ({
getBoundingClientRect: () => ({
left: 100,
right: 200,
top: 40,
bottom: 100,
width: 100,
height: 60,
x: 100,
y: 40,
toJSON: () => ({}),
...rect,
}),
}) as HTMLElement;
test("vault drop position uses horizontal halves in grid and vertical halves in list", () => {
const element = makeElement({});
assert.equal(getVaultDropPosition(element, 120, 90, true), "before");
assert.equal(getVaultDropPosition(element, 180, 50, true), "after");
assert.equal(getVaultDropPosition(element, 180, 50, false), "before");
assert.equal(getVaultDropPosition(element, 120, 90, false), "after");
});
test("vault drop intent uses edges for sorting and middle for nesting", () => {
const element = makeElement({});
assert.equal(getVaultDropIntent(element, 110, 70, true), "before");
assert.equal(getVaultDropIntent(element, 190, 70, true), "after");
assert.equal(getVaultDropIntent(element, 150, 70, true), "inside");
assert.equal(getVaultDropIntent(element, 150, 45, false), "before");
assert.equal(getVaultDropIntent(element, 150, 95, false), "after");
assert.equal(getVaultDropIntent(element, 150, 70, false), "inside");
});
test("root host drop resets host drag state after moving the host to all hosts", () => {
const calls: string[] = [];
handleVaultRootDrop({
dataTransfer: {
getData: (type: string) => (type === "host-id" ? "host-1" : ""),
},
preventDefault: () => calls.push("preventDefault"),
setDragOverDropTarget: (target) => calls.push(`drop:${String(target)}`),
moveGroup: (groupPath, targetPath) => {
calls.push(`group:${groupPath}:${String(targetPath)}`);
},
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.deepEqual(calls, [
"preventDefault",
"drop:null",
"host:host-1:null",
"resetHostDragState",
]);
});
test("root group drop moves the group to all hosts without resetting host drag state", () => {
const calls: string[] = [];
handleVaultRootDrop({
dataTransfer: {
getData: (type: string) => (type === "group-path" ? "team/prod" : ""),
},
preventDefault: () => calls.push("preventDefault"),
setDragOverDropTarget: (target) => calls.push(`drop:${String(target)}`),
moveGroup: (groupPath, targetPath) => {
calls.push(`group:${groupPath}:${String(targetPath)}`);
},
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.deepEqual(calls, [
"preventDefault",
"drop:null",
"group:team/prod:null",
]);
});
test("group host drop resets host drag state after moving the host to the group", () => {
const calls: string[] = [];
const handled = handleVaultHostDropToGroup({
dataTransfer: {
getData: (type: string) => (type === "host-id" ? "host-1" : ""),
},
groupPath: "team/prod",
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.equal(handled, true);
assert.deepEqual(calls, [
"host:host-1:team/prod",
"resetHostDragState",
]);
});
test("group drop without a host leaves host drag state alone", () => {
const calls: string[] = [];
const handled = handleVaultHostDropToGroup({
dataTransfer: {
getData: () => "",
},
groupPath: "team/prod",
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,345 @@
import React from "react";
export type VaultDropPosition = "before" | "after";
export type VaultDropIntent = VaultDropPosition | "inside";
export type VaultDropAxis = "x" | "y";
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
export const getVaultDropPosition = (
element: HTMLElement,
clientX: number,
clientY: number,
isGrid = false,
): VaultDropPosition => {
const rect = element.getBoundingClientRect();
if (isGrid) return clientX < rect.left + rect.width / 2 ? "before" : "after";
return clientY < rect.top + rect.height / 2 ? "before" : "after";
};
export const getVaultDropIntent = (
element: HTMLElement,
clientX: number,
clientY: number,
isGrid: boolean,
): VaultDropIntent => {
const rect = element.getBoundingClientRect();
if (isGrid) {
const edgeSize = Math.max(18, Math.min(36, rect.width * 0.22));
if (clientX <= rect.left + edgeSize) return "before";
if (clientX >= rect.right - edgeSize) return "after";
return "inside";
}
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
if (clientY <= rect.top + edgeSize) return "before";
if (clientY >= rect.bottom - edgeSize) return "after";
return "inside";
};
export const hasVaultDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export const handleVaultRootDrop = ({
dataTransfer,
preventDefault,
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
preventDefault: () => void;
setDragOverDropTarget: (target: null) => void;
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
preventDefault();
setDragOverDropTarget(null);
const groupPath = dataTransfer.getData("group-path");
const hostId = dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (hostId) {
moveHostToGroup(hostId, null);
resetHostDragState();
}
};
export const handleVaultHostDropToGroup = ({
dataTransfer,
groupPath,
moveHostToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
groupPath: string | null;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
const hostId = dataTransfer.getData("host-id");
if (!hostId) return false;
moveHostToGroup(hostId, groupPath);
resetHostDragState();
return true;
};
let activeVaultDropIndicator: HTMLElement | null = null;
export const clearVaultDropIndicator = () => {
activeVaultDropIndicator?.removeAttribute("data-vault-drop-position");
activeVaultDropIndicator?.removeAttribute("data-vault-drop-axis");
activeVaultDropIndicator = null;
};
export const markVaultDropIndicator = (
target: HTMLElement,
position: VaultDropPosition,
axis: VaultDropAxis = "y",
) => {
if (target.dataset.vaultDropPosition === position && target.dataset.vaultDropAxis === axis) return;
clearVaultDropIndicator();
target.dataset.vaultDropPosition = position;
target.dataset.vaultDropAxis = axis;
activeVaultDropIndicator = target;
};
export const markVaultInsideDropIndicator = (target: HTMLElement) => {
if (target.dataset.vaultDropPosition === "inside") return;
clearVaultDropIndicator();
target.dataset.vaultDropPosition = "inside";
activeVaultDropIndicator = target;
};
export const useVaultGridLayoutAnimation = (
containerRef: React.RefObject<HTMLElement | null>,
) => {
const previousRectsRef = React.useRef<Map<string, DOMRect> | null>(null);
const prepare = React.useCallback(() => {
const container = containerRef.current;
if (!container || typeof window === "undefined") return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const rects = new Map<string, DOMRect>();
container.querySelectorAll<HTMLElement>("[data-vault-grid-item]").forEach((element) => {
const key = element.dataset.vaultGridItem;
if (!key) return;
rects.set(key, element.getBoundingClientRect());
});
previousRectsRef.current = rects;
}, [containerRef]);
useIsomorphicLayoutEffect(() => {
const previousRects = previousRectsRef.current;
if (!previousRects || previousRects.size === 0) return;
previousRectsRef.current = null;
const container = containerRef.current;
if (!container) return;
container.querySelectorAll<HTMLElement>("[data-vault-grid-item]").forEach((element) => {
const key = element.dataset.vaultGridItem;
if (!key || typeof element.animate !== "function") return;
const previous = previousRects.get(key);
if (!previous) return;
const next = element.getBoundingClientRect();
const deltaX = previous.left - next.left;
const deltaY = previous.top - next.top;
if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return;
element.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
{ transform: "translate(0, 0)" },
],
{
duration: 180,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
});
});
return prepare;
};
export const useVaultItemReorder = ({
containerRef,
viewMode,
dragType,
targetAttribute,
onReorder,
disabled = false,
}: {
containerRef: React.RefObject<HTMLElement | null>;
viewMode: "grid" | "list" | string;
dragType: string;
targetAttribute: string;
onReorder: (
sourceId: string,
targetId: string,
position: VaultDropPosition,
) => void;
disabled?: boolean;
}) => {
const lastPreviewReorderRef = React.useRef<string | null>(null);
const draggingIdRef = React.useRef<string | null>(null);
const [draggingId, setDraggingId] = React.useState<string | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(containerRef);
const selector = `[${targetAttribute}]`;
const reset = React.useCallback(() => {
lastPreviewReorderRef.current = null;
draggingIdRef.current = null;
setDraggingId(null);
clearVaultDropIndicator();
}, []);
const handleDragOverCapture = React.useCallback((event: React.DragEvent<HTMLElement>) => {
if (disabled) return;
const target = (event.target as Element | null)?.closest(selector);
if (!(target instanceof HTMLElement)) return;
if (!draggingIdRef.current && !hasVaultDragType(event.dataTransfer, dragType)) return;
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
const targetId = target.getAttribute(targetAttribute);
if (!sourceId || !targetId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
if (sourceId === targetId) return;
const isGrid = viewMode === "grid";
const position = getVaultDropPosition(target, event.clientX, event.clientY, isGrid);
if (!isGrid) {
markVaultDropIndicator(target, position);
return;
}
const previewKey = `${sourceId}:${targetId}:${position}`;
if (lastPreviewReorderRef.current === previewKey) return;
prepareGridLayoutAnimation();
lastPreviewReorderRef.current = previewKey;
onReorder(sourceId, targetId, position);
}, [
disabled,
dragType,
onReorder,
prepareGridLayoutAnimation,
selector,
targetAttribute,
viewMode,
]);
const handleDragOver = React.useCallback((event: React.DragEvent<HTMLElement>) => {
if (disabled || viewMode === "grid") return;
const target = (event.target as Element | null)?.closest(selector);
if (!(target instanceof HTMLElement)) return;
if (!draggingIdRef.current && !hasVaultDragType(event.dataTransfer, dragType)) return;
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
const targetId = target.getAttribute(targetAttribute);
if (!sourceId || !targetId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
if (sourceId === targetId) return;
markVaultDropIndicator(
target,
getVaultDropPosition(target, event.clientX, event.clientY),
);
}, [disabled, dragType, selector, targetAttribute, viewMode]);
const handleDropCapture = React.useCallback((event: React.DragEvent<HTMLElement>) => {
if (disabled) return;
const target = (event.target as Element | null)?.closest(selector);
clearVaultDropIndicator();
if (!(target instanceof HTMLElement)) {
reset();
return;
}
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
const targetId = target.getAttribute(targetAttribute);
if (!sourceId || !targetId) {
lastPreviewReorderRef.current = null;
draggingIdRef.current = null;
setDraggingId(null);
return;
}
event.preventDefault();
event.stopPropagation();
if (sourceId === targetId) {
lastPreviewReorderRef.current = null;
draggingIdRef.current = null;
setDraggingId(null);
return;
}
const position = getVaultDropPosition(
target,
event.clientX,
event.clientY,
viewMode === "grid",
);
const previewKey = `${sourceId}:${targetId}:${position}`;
if (viewMode !== "grid" || lastPreviewReorderRef.current !== previewKey) {
prepareGridLayoutAnimation();
onReorder(sourceId, targetId, position);
}
lastPreviewReorderRef.current = null;
draggingIdRef.current = null;
setDraggingId(null);
}, [
disabled,
dragType,
onReorder,
prepareGridLayoutAnimation,
reset,
selector,
targetAttribute,
viewMode,
]);
const getItemReorderProps = React.useCallback((id: string, gridItemKey = id) => ({
[targetAttribute]: id,
"data-vault-grid-item": gridItemKey,
"data-vault-reorder-grid": viewMode === "grid" ? "true" : undefined,
"data-vault-reorder-dragging": draggingId === id ? "true" : undefined,
draggable: !disabled,
onDragStart: (event: React.DragEvent<HTMLElement>) => {
if (disabled) return;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(dragType, id);
draggingIdRef.current = id;
setDraggingId(id);
// The live grid preview can re-parent the dragged node (virtualised rows,
// section changes), and React then unmounts it. dragend still fires on the
// detached node but no longer reaches handleDragEndCapture on the
// container, so bind the cleanup natively on the source node itself.
const sourceNode = event.currentTarget as HTMLElement;
const handleNativeDragEnd = () => {
sourceNode.removeEventListener("dragend", handleNativeDragEnd);
reset();
};
sourceNode.addEventListener("dragend", handleNativeDragEnd);
},
}), [disabled, dragType, draggingId, reset, targetAttribute, viewMode]);
return {
handleDragOverCapture,
handleDragOver,
handleDropCapture,
handleDragEndCapture: reset,
getItemReorderProps,
};
};