[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
216
components/vault/ImportVaultDialog.progress.test.tsx
Normal file
216
components/vault/ImportVaultDialog.progress.test.tsx
Normal 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=/);
|
||||
});
|
||||
1105
components/vault/ImportVaultDialog.tsx
Normal file
1105
components/vault/ImportVaultDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
102
components/vault/VaultDeleteConfirmDialog.test.tsx
Normal file
102
components/vault/VaultDeleteConfirmDialog.test.tsx
Normal 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",
|
||||
));
|
||||
});
|
||||
112
components/vault/VaultDeleteConfirmDialog.tsx
Normal file
112
components/vault/VaultDeleteConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
components/vault/VaultEntityIcon.tsx
Normal file
37
components/vault/VaultEntityIcon.tsx
Normal 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>
|
||||
);
|
||||
501
components/vault/VaultHostListSection.test.tsx
Normal file
501
components/vault/VaultHostListSection.test.tsx
Normal 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/);
|
||||
});
|
||||
1315
components/vault/VaultHostListSection.tsx
Normal file
1315
components/vault/VaultHostListSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
80
components/vault/VaultPageHeader.tsx
Normal file
80
components/vault/VaultPageHeader.tsx
Normal 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";
|
||||
201
components/vault/VaultTreeRow.test.tsx
Normal file
201
components/vault/VaultTreeRow.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
258
components/vault/VaultTreeRow.tsx
Normal file
258
components/vault/VaultTreeRow.tsx
Normal 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>
|
||||
);
|
||||
1786
components/vault/VaultViewLayout.tsx
Normal file
1786
components/vault/VaultViewLayout.tsx
Normal file
File diff suppressed because it is too large
Load Diff
178
components/vault/VirtualizedHostCollection.test.tsx
Normal file
178
components/vault/VirtualizedHostCollection.test.tsx
Normal 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);
|
||||
});
|
||||
649
components/vault/VirtualizedHostCollection.tsx
Normal file
649
components/vault/VirtualizedHostCollection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
components/vault/useHostTreeInlineGroupActions.test.tsx
Normal file
59
components/vault/useHostTreeInlineGroupActions.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
157
components/vault/useHostTreeInlineGroupActions.ts
Normal file
157
components/vault/useHostTreeInlineGroupActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
53
components/vault/useHostTreeInlineHostActions.ts
Normal file
53
components/vault/useHostTreeInlineHostActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
109
components/vault/useRegisterVaultHostTreeActions.ts
Normal file
109
components/vault/useRegisterVaultHostTreeActions.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
157
components/vault/useVaultGroupDragHandlers.ts
Normal file
157
components/vault/useVaultGroupDragHandlers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
components/vault/useVaultHostCollections.test.ts
Normal file
56
components/vault/useVaultHostCollections.test.ts
Normal 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"]);
|
||||
});
|
||||
521
components/vault/useVaultHostCollections.tsx
Normal file
521
components/vault/useVaultHostCollections.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { upsertKnownHost } from "../../domain/knownHosts";
|
||||
import { sortByVaultOrder, sortVaultStringsByOrder } from "../../domain/vaultOrder";
|
||||
import { matchesHostSearchQuery, matchesSearchQuery } from "../../lib/searchMatcher";
|
||||
import type { GroupConfig, GroupNode, Host, KnownHost } from "../../types";
|
||||
import KnownHostsManager from "../KnownHostsManager";
|
||||
import type { SortMode } from "../ui/sort-dropdown";
|
||||
|
||||
interface UseVaultHostCollectionsOptions {
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
hosts: Host[];
|
||||
knownHosts: KnownHost[];
|
||||
onConvertKnownHost: (knownHost: KnownHost) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateKnownHosts: (knownHosts: KnownHost[]) => void;
|
||||
search: string;
|
||||
selectedGroupPath: string | null;
|
||||
selectedTags: string[];
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showRecentHosts: boolean;
|
||||
sortMode: SortMode;
|
||||
viewMode: "grid" | "list" | "tree";
|
||||
}
|
||||
|
||||
export function hostBelongsToSelectedVaultGroup(host: Host, selectedGroupPath: string): boolean {
|
||||
const hostGroup = host.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
}
|
||||
|
||||
export function filterVaultHostsForDisplay({
|
||||
filteredHosts,
|
||||
searchTerm,
|
||||
selectedGroupPath,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
}: {
|
||||
filteredHosts: Host[];
|
||||
searchTerm: string;
|
||||
selectedGroupPath: string | null;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
}): Host[] {
|
||||
if (selectedGroupPath) {
|
||||
return filteredHosts.filter((host) =>
|
||||
hostBelongsToSelectedVaultGroup(host, selectedGroupPath),
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchTerm && showOnlyUngroupedHostsInRoot) {
|
||||
return filteredHosts.filter((host) => {
|
||||
const hostGroup = (host.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
|
||||
return filteredHosts;
|
||||
}
|
||||
|
||||
export function useVaultHostCollections({
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
knownHosts,
|
||||
onConvertKnownHost,
|
||||
onUpdateHosts,
|
||||
onUpdateKnownHosts,
|
||||
search,
|
||||
selectedGroupPath,
|
||||
selectedTags,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
sortMode,
|
||||
viewMode,
|
||||
}: UseVaultHostCollectionsOptions) {
|
||||
const groupOrderByPath = useMemo(() => {
|
||||
return new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === "number" && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
}, [groupConfigs]);
|
||||
|
||||
const searchTerm = useMemo(() => search.trim(), [search]);
|
||||
const selectedTagSet = useMemo(() => new Set(selectedTags), [selectedTags]);
|
||||
const hasSelectedTags = selectedTags.length > 0;
|
||||
|
||||
const hostMatchesSearchAndTags = useCallback((host: Host): boolean => {
|
||||
if (searchTerm) {
|
||||
const matchesSearch =
|
||||
matchesHostSearchQuery(searchTerm, host) ||
|
||||
matchesSearchQuery(searchTerm, host.username, host.notes);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
if (hasSelectedTags && !host.tags?.some((tag) => selectedTagSet.has(tag))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [hasSelectedTags, searchTerm, selectedTagSet]);
|
||||
|
||||
const filteredHosts = useMemo(
|
||||
() => hosts.filter(hostMatchesSearchAndTags),
|
||||
[hostMatchesSearchAndTags, hosts],
|
||||
);
|
||||
|
||||
const sortHosts = useCallback((input: readonly Host[]): Host[] => {
|
||||
if (sortMode === "manual") return sortByVaultOrder(input);
|
||||
return [...input].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
return a.label.localeCompare(b.label);
|
||||
case "za":
|
||||
return b.label.localeCompare(a.label);
|
||||
case "newest":
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case "oldest":
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case "group": {
|
||||
const groupA = a.group || "";
|
||||
const groupB = b.group || "";
|
||||
const groupCmp = groupA.localeCompare(groupB);
|
||||
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [sortMode]);
|
||||
|
||||
const orderedCustomGroups = useMemo(() => {
|
||||
return sortVaultStringsByOrder(customGroups, groupOrderByPath);
|
||||
}, [customGroups, groupOrderByPath]);
|
||||
|
||||
const sortGroupNodes = useCallback((nodes: GroupNode[]) => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
return [...nodes].sort((a, b) => {
|
||||
const orderA = groupOrderByPath.get(a.path);
|
||||
const orderB = groupOrderByPath.get(b.path);
|
||||
const hasOrderA = typeof orderA === "number" && Number.isFinite(orderA);
|
||||
const hasOrderB = typeof orderB === "number" && Number.isFinite(orderB);
|
||||
if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB;
|
||||
if (hasOrderA) return -1;
|
||||
if (hasOrderB) return 1;
|
||||
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
|
||||
});
|
||||
}, [groupOrderByPath]);
|
||||
|
||||
const countAllHostsInNode = useCallback((node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
node.totalHostCount = count;
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let currentLevel = root;
|
||||
let currentPath = "";
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
if (!currentLevel[part]) {
|
||||
currentLevel[part] = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
children: {},
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
if (host && index === parts.length - 1)
|
||||
currentLevel[part].hosts.push(host);
|
||||
currentLevel = currentLevel[part].children;
|
||||
});
|
||||
};
|
||||
orderedCustomGroups.forEach((path) => insertPath(path));
|
||||
hosts.forEach((host) => insertPath(host.group || "General", host));
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [hosts, orderedCustomGroups, countAllHostsInNode]);
|
||||
|
||||
// Generate all possible group paths from the tree (including all intermediate nodes)
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
|
||||
const traverse = (nodes: Record<string, GroupNode>) => {
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.path) {
|
||||
paths.add(node.path);
|
||||
}
|
||||
if (node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Traverse the tree
|
||||
traverse(buildGroupTree);
|
||||
|
||||
return Array.from(paths).sort();
|
||||
}, [buildGroupTree]);
|
||||
|
||||
const findGroupNode = (path: string | null): GroupNode | null => {
|
||||
if (!path)
|
||||
return {
|
||||
name: "root",
|
||||
path: "",
|
||||
children: buildGroupTree,
|
||||
hosts: [],
|
||||
} as GroupNode;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let current: { children?: Record<string, GroupNode>; hosts?: Host[] } = {
|
||||
children: buildGroupTree,
|
||||
};
|
||||
for (const p of parts) {
|
||||
const next = current.children?.[p];
|
||||
if (!next) return null;
|
||||
current = next;
|
||||
}
|
||||
return current as GroupNode;
|
||||
};
|
||||
|
||||
const displayedHosts = useMemo(() => {
|
||||
const filtered = filterVaultHostsForDisplay({
|
||||
filteredHosts,
|
||||
searchTerm,
|
||||
selectedGroupPath,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
});
|
||||
return sortHosts(filtered);
|
||||
}, [filteredHosts, searchTerm, selectedGroupPath, showOnlyUngroupedHostsInRoot, sortHosts]);
|
||||
|
||||
// Pinned hosts for root-level display (not inside a subgroup)
|
||||
// Respects active search and tag filters
|
||||
const pinnedHosts = useMemo(() => {
|
||||
if (selectedGroupPath) return [];
|
||||
const filtered = filteredHosts.filter((h) => h.pinned);
|
||||
return filtered.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [filteredHosts, selectedGroupPath]);
|
||||
|
||||
// Recently connected hosts for root-level display
|
||||
// Respects active search and tag filters
|
||||
const recentHosts = useMemo(() => {
|
||||
if (selectedGroupPath) return [];
|
||||
const filtered = filteredHosts.filter((h) => h.lastConnectedAt && !h.pinned);
|
||||
return filtered
|
||||
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
|
||||
.slice(0, 6);
|
||||
}, [filteredHosts, selectedGroupPath]);
|
||||
|
||||
const pinnedRecentIds = useMemo(() => new Set<string>([
|
||||
...pinnedHosts.map((host) => host.id),
|
||||
...(showRecentHosts ? recentHosts.map((host) => host.id) : []),
|
||||
]), [pinnedHosts, recentHosts, showRecentHosts]);
|
||||
|
||||
const visibleDisplayedHosts = useMemo(
|
||||
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
|
||||
[displayedHosts, selectedGroupPath, pinnedRecentIds],
|
||||
);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
return sortHosts(filteredHosts);
|
||||
}, [filteredHosts, sortHosts]);
|
||||
|
||||
const groupedDisplayHosts = useMemo(() => {
|
||||
if (sortMode !== "group") return null;
|
||||
const groups: { name: string; hosts: Host[] }[] = [];
|
||||
const groupMap = new Map<string, Host[]>();
|
||||
|
||||
for (const host of visibleDisplayedHosts) {
|
||||
const groupName = host.group || "";
|
||||
if (!groupMap.has(groupName)) {
|
||||
groupMap.set(groupName, []);
|
||||
}
|
||||
groupMap.get(groupName)!.push(host);
|
||||
}
|
||||
|
||||
const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const key of sortedKeys) {
|
||||
groups.push({ name: key, hosts: groupMap.get(key)! });
|
||||
}
|
||||
return groups;
|
||||
}, [sortMode, visibleDisplayedHosts]);
|
||||
|
||||
const buildTreeViewGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let currentLevel = root;
|
||||
let currentPath = "";
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
if (!currentLevel[part]) {
|
||||
currentLevel[part] = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
children: {},
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
if (host && index === parts.length - 1)
|
||||
currentLevel[part].hosts.push(host);
|
||||
currentLevel = currentLevel[part].children;
|
||||
});
|
||||
};
|
||||
if (!searchTerm && selectedTags.length === 0) {
|
||||
orderedCustomGroups.forEach((path) => insertPath(path));
|
||||
}
|
||||
// Use filtered hosts (treeViewHosts) instead of all hosts to respect search/tag filters
|
||||
treeViewHosts.forEach((host) => {
|
||||
if (host.group && host.group.trim() !== "") {
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [treeViewHosts, orderedCustomGroups, countAllHostsInNode, searchTerm, selectedTags.length]);
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
const nodes = Object.values(buildTreeViewGroupTree) as GroupNode[];
|
||||
if (sortMode === "manual") return sortGroupNodes(nodes);
|
||||
return nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [buildTreeViewGroupTree, sortGroupNodes, sortMode]);
|
||||
|
||||
// Compute all unique tags across all hosts
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
hosts.forEach((h) => h.tags?.forEach((t) => tagSet.add(t)));
|
||||
return Array.from(tagSet).sort();
|
||||
}, [hosts]);
|
||||
|
||||
// Handle tag edit - rename tag across all hosts
|
||||
const handleEditTag = useCallback(
|
||||
(oldTag: string, newTag: string) => {
|
||||
if (oldTag === newTag) return;
|
||||
const updatedHosts = hosts.map((host) => {
|
||||
if (host.tags?.includes(oldTag)) {
|
||||
const newTags = host.tags.map((t) => (t === oldTag ? newTag : t));
|
||||
// Remove duplicates in case newTag already exists
|
||||
return { ...host, tags: Array.from(new Set(newTags)) };
|
||||
}
|
||||
return host;
|
||||
});
|
||||
onUpdateHosts(updatedHosts);
|
||||
},
|
||||
[hosts, onUpdateHosts],
|
||||
);
|
||||
|
||||
// Handle tag delete - remove tag from all hosts
|
||||
const handleDeleteTag = useCallback(
|
||||
(tag: string) => {
|
||||
const updatedHosts = hosts.map((host) => {
|
||||
if (host.tags?.includes(tag)) {
|
||||
return { ...host, tags: host.tags.filter((t) => t !== tag) };
|
||||
}
|
||||
return host;
|
||||
});
|
||||
onUpdateHosts(updatedHosts);
|
||||
},
|
||||
[hosts, onUpdateHosts],
|
||||
);
|
||||
|
||||
const displayedGroups = useMemo(() => {
|
||||
const hasActiveFilters = Boolean(searchTerm) || selectedTags.length > 0;
|
||||
const sourceTree = hasActiveFilters ? buildTreeViewGroupTree : buildGroupTree;
|
||||
const findSourceNode = (path: string): GroupNode | null => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let currentLevel = sourceTree;
|
||||
let currentNode: GroupNode | null = null;
|
||||
for (const part of parts) {
|
||||
currentNode = currentLevel[part] ?? null;
|
||||
if (!currentNode) return null;
|
||||
currentLevel = currentNode.children;
|
||||
}
|
||||
return currentNode;
|
||||
};
|
||||
if (!selectedGroupPath) {
|
||||
// Hide "General" group at root level only if it's auto-generated
|
||||
// (not user-created and has no subgroups)
|
||||
const isGeneralUserCreated = customGroups.some(
|
||||
(g) => g === "General" || g.startsWith("General/")
|
||||
);
|
||||
const nodes = (Object.values(sourceTree) as GroupNode[])
|
||||
.filter((node) => {
|
||||
if (node.name !== "General") return true;
|
||||
// Keep General if user explicitly created it or it has subgroups
|
||||
if (isGeneralUserCreated) return true;
|
||||
if (Object.keys(node.children).length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (sortMode === "manual") return sortGroupNodes(nodes);
|
||||
return nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const node = findSourceNode(selectedGroupPath);
|
||||
if (!node || !node.children) return [];
|
||||
const children = Object.values(node.children) as GroupNode[];
|
||||
if (sortMode === "manual") return sortGroupNodes(children);
|
||||
return children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [
|
||||
buildGroupTree,
|
||||
buildTreeViewGroupTree,
|
||||
customGroups,
|
||||
searchTerm,
|
||||
selectedGroupPath,
|
||||
selectedTags.length,
|
||||
sortGroupNodes,
|
||||
sortMode,
|
||||
]);
|
||||
|
||||
const shouldHideEmptyRootHostsSection = useMemo(() => {
|
||||
if (selectedGroupPath || viewMode === "tree") return false;
|
||||
if (searchTerm || selectedTags.length > 0) return false;
|
||||
if (visibleDisplayedHosts.length > 0) return false;
|
||||
return (
|
||||
displayedGroups.length > 0 ||
|
||||
pinnedHosts.length > 0 ||
|
||||
(showRecentHosts && recentHosts.length > 0)
|
||||
);
|
||||
}, [
|
||||
selectedGroupPath,
|
||||
viewMode,
|
||||
searchTerm,
|
||||
selectedTags.length,
|
||||
visibleDisplayedHosts.length,
|
||||
displayedGroups.length,
|
||||
pinnedHosts.length,
|
||||
showRecentHosts,
|
||||
recentHosts.length,
|
||||
]);
|
||||
|
||||
// Known Hosts callbacks - use refs to keep stable references
|
||||
// Store latest values in refs so callbacks don't need to depend on them
|
||||
const knownHostsRef = React.useRef(knownHosts);
|
||||
|
||||
const onUpdateKnownHostsRef = React.useRef(onUpdateKnownHosts);
|
||||
|
||||
// Keep refs up to date
|
||||
React.useEffect(() => {
|
||||
knownHostsRef.current = knownHosts;
|
||||
onUpdateKnownHostsRef.current = onUpdateKnownHosts;
|
||||
});
|
||||
|
||||
// Stable callbacks that read from refs
|
||||
const handleSaveKnownHost = useCallback((kh: KnownHost) => {
|
||||
onUpdateKnownHostsRef.current(upsertKnownHost(knownHostsRef.current, kh));
|
||||
}, []);
|
||||
|
||||
const handleUpdateKnownHost = useCallback((kh: KnownHost) => {
|
||||
onUpdateKnownHostsRef.current(
|
||||
knownHostsRef.current.map((existing) =>
|
||||
existing.id === kh.id ? kh : existing,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteKnownHost = useCallback((id: string) => {
|
||||
onUpdateKnownHostsRef.current(
|
||||
knownHostsRef.current.filter((kh) => kh.id !== id),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleImportKnownHosts = useCallback((newHosts: KnownHost[]) => {
|
||||
onUpdateKnownHostsRef.current([...knownHostsRef.current, ...newHosts]);
|
||||
}, []);
|
||||
|
||||
const handleReorderKnownHosts = useCallback((nextKnownHosts: KnownHost[]) => {
|
||||
onUpdateKnownHostsRef.current(nextKnownHosts);
|
||||
}, []);
|
||||
|
||||
const handleRefreshKnownHosts = useCallback(() => {
|
||||
// Placeholder for system scan
|
||||
}, []);
|
||||
|
||||
// Memoize the KnownHostsManager element to prevent re-renders when VaultViewInner re-renders
|
||||
const knownHostsManagerElement = useMemo(() => {
|
||||
return (
|
||||
<KnownHostsManager
|
||||
knownHosts={knownHosts}
|
||||
hosts={hosts}
|
||||
onSave={handleSaveKnownHost}
|
||||
onUpdate={handleUpdateKnownHost}
|
||||
onReorder={handleReorderKnownHosts}
|
||||
onDelete={handleDeleteKnownHost}
|
||||
onConvertToHost={onConvertKnownHost}
|
||||
onImportFromFile={handleImportKnownHosts}
|
||||
onRefresh={handleRefreshKnownHosts}
|
||||
/>
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- handle* callbacks are stable refs that read from refs
|
||||
}, [knownHosts, hosts, onConvertKnownHost]);
|
||||
|
||||
return {
|
||||
allGroupPaths,
|
||||
allTags,
|
||||
buildGroupTree,
|
||||
displayedGroups,
|
||||
displayedHosts,
|
||||
findGroupNode,
|
||||
groupedDisplayHosts,
|
||||
handleDeleteTag,
|
||||
handleEditTag,
|
||||
knownHostsManagerElement,
|
||||
pinnedHosts,
|
||||
pinnedRecentIds,
|
||||
recentHosts,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
treeViewGroupTree,
|
||||
treeViewHosts,
|
||||
visibleDisplayedHosts,
|
||||
};
|
||||
}
|
||||
2
components/vault/useVaultImportHandlers.ts
Normal file
2
components/vault/useVaultImportHandlers.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/application/state/useVaultImportHandlers` instead. */
|
||||
export { useVaultImportHandlers } from "../../application/state/useVaultImportHandlers";
|
||||
17
components/vault/vaultHostGridLayout.ts
Normal file
17
components/vault/vaultHostGridLayout.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
134
components/vault/vaultReorderDrag.test.ts
Normal file
134
components/vault/vaultReorderDrag.test.ts
Normal 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, []);
|
||||
});
|
||||
345
components/vault/vaultReorderDrag.ts
Normal file
345
components/vault/vaultReorderDrag.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user