import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, CheckCircle2, FileSymlink, FileText, FolderOpen, FolderPlus, FolderTree, Import, LoaderCircle, Plug, } from "lucide-react"; import { useI18n } from "../../application/i18n/I18nProvider"; import type { VaultImportFileEncoding } from "../../application/state/vaultImportFile"; import type { VaultImportOptions } from "../../application/state/vaultImportOptions"; import type { VaultImportProgress } from "../../application/state/vaultImportProgress"; import { usePluginVaultImporter } from "../../application/state/usePluginVaultImporter"; import { buildVaultImportDestination, getVaultImportPickerMode, selectVaultImportFiles, type VaultImportDestinationMode, } from "../../application/state/vaultImportSelection"; import { getVaultCsvTemplate } from "../../domain/vaultImport"; import type { VaultImportDestination, VaultImportFormat, } from "../../domain/vaultImport"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../ui/dialog"; type ImportOption = { format: VaultImportFormat; label: string; iconSrc: string; accept: string; }; const OPTIONS: ImportOption[] = [ { format: "putty", label: "PuTTY", iconSrc: "/import/putty.png", accept: ".reg,.txt,.ini", }, { format: "mobaxterm", label: "MobaXterm", iconSrc: "/import/moba.jpg", accept: ".ini,.mxtsessions,.txt,.mobaconf", }, { format: "csv", label: "CSV", iconSrc: "/import/csv.png", accept: ".csv,.txt", }, { format: "securecrt", label: "SecureCRT", iconSrc: "/import/securecrt.png", accept: ".ini,.txt", }, { format: "ssh_config", label: "ssh_config", iconSrc: "/import/file.png", accept: "*", }, ]; export type ImportOptions = VaultImportOptions; export type ImportVaultDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; onFileSelected: ( format: VaultImportFormat, files: File[], options?: ImportOptions, ) => void; onPluginPreviewCommit: ( preview: NetcattyPluginImporterPreview, destination?: VaultImportDestination, ) => Promise | void; getPluginPreviewAnalysis: (preview: NetcattyPluginImporterPreview) => { duplicateCount: number; validationErrorCount: number; safePreview: import("../../domain/pluginImporter").PluginImporterSafePreview; }; groups?: string[]; }; type Translate = (key: string, values?: Record) => string; type ImportDialogStep = | "format" | "destination" | "ssh-mode" | "moba-encoding" | "securecrt-source"; export function VaultImportDestinationControls({ mode, onModeChange, groups, existingGroup, onExistingGroupChange, existingGroupQuery = existingGroup, onExistingGroupQueryChange, newGroup, onNewGroupChange, t, }: { mode: VaultImportDestinationMode; onModeChange: (mode: VaultImportDestinationMode) => void; groups: string[]; existingGroup: string; onExistingGroupChange: (group: string) => void; existingGroupQuery?: string; onExistingGroupQueryChange?: (query: string) => void; newGroup: string; onNewGroupChange: (group: string) => void; t: Translate; }) { const choices: Array<{ mode: VaultImportDestinationMode; icon: React.ReactNode; }> = [ { mode: "preserve", icon: }, { mode: "existing", icon: }, { mode: "new", icon: }, ]; const existingGroupListId = React.useId(); const matchingGroups = useMemo(() => { const query = existingGroupQuery.trim().toLocaleLowerCase(); return groups .filter((group) => !query || group.toLocaleLowerCase().includes(query)) .slice(0, 50); }, [existingGroupQuery, groups]); return (
{choices.map((choice) => ( ))}
{mode === "existing" && ( <> { const value = event.target.value; onExistingGroupQueryChange?.(value); onExistingGroupChange(groups.includes(value) ? value : ""); }} placeholder={groups.length === 0 ? t("vault.import.destination.noGroups") : t("vault.import.destination.existing")} aria-label={t("vault.import.destination.existing")} autoComplete="off" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" /> {matchingGroups.map((group) => ( )} {mode === "new" && ( onNewGroupChange(event.target.value)} placeholder={t("vault.import.destination.newPlaceholder")} aria-label={t("vault.import.destination.new")} className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground" /> )}
); } const PROGRESS_STAGE_KEYS = { reading: "vault.import.progress.reading", parsing: "vault.import.progress.parsing", preparing: "vault.import.progress.preparing", saving: "vault.import.progress.saving", complete: "vault.import.progress.complete", failed: "vault.import.progress.failed", } as const; export function VaultImportProgressView({ progress, onClose, onCancel, t, }: { progress: VaultImportProgress; onClose: () => void; onCancel?: () => void; t: Translate; }) { const isRunning = progress.status === "running"; const isComplete = progress.status === "complete"; const stageText = t(PROGRESS_STAGE_KEYS[progress.stage]); const completionSummary = isComplete ? t("vault.import.progress.summary", { count: progress.imported ?? 0, skipped: progress.skipped ?? 0, duplicates: progress.duplicates ?? 0, }) : null; const announcement = isRunning ? stageText : [stageText, completionSummary ?? progress.error].filter(Boolean).join(". "); return (
{announcement}
{isComplete ? ( ) : progress.status === "error" ? ( ) : ( )}

{isRunning ? t("vault.import.progress.title") : stageText}

{progress.formatLabel} ·{" "} {progress.totalFiles && progress.totalFiles > 1 ? t("vault.import.progress.fileSummary", { name: progress.fileName, count: progress.totalFiles, }) : progress.fileName}

{stageText} {progress.percent}%
{isRunning && progress.totalFiles && progress.totalFiles > 1 && (
{progress.currentFileName} {t("vault.import.progress.fileCount", { completed: progress.completedFiles ?? 0, total: progress.totalFiles, })}
)}
{isRunning ? (

{t("vault.import.progress.keepOpen")}

) : isComplete ? (

{completionSummary}

) : (

{progress.error}

)} {isRunning && onCancel && ( )} {!isRunning && ( )}
); } export function VaultImportProgressPanel({ progress, onClose, onCancel, t, }: { progress: VaultImportProgress; onClose: () => void; onCancel?: () => void; t: Translate; }) { return (
); } export const ImportVaultDialog: React.FC = ({ open, onOpenChange, onFileSelected, onPluginPreviewCommit, getPluginPreviewAnalysis, groups = [], }) => { const { t } = useI18n(); const fileInputRef = useRef(null); const pendingFormatRef = useRef(null); const pendingOptionsRef = useRef(undefined); const [step, setStep] = useState("format"); const [destinationMode, setDestinationMode] = useState("preserve"); const [existingGroup, setExistingGroup] = useState(groups[0] ?? ""); const [existingGroupQuery, setExistingGroupQuery] = useState(groups[0] ?? ""); const existingGroupQueryRef = useRef(existingGroupQuery); existingGroupQueryRef.current = existingGroupQuery; const [newGroup, setNewGroup] = useState(""); const [mobaMasterPassword, setMobaMasterPassword] = useState(""); const destination = buildVaultImportDestination({ mode: destinationMode, existingGroup, newGroup, availableGroups: groups, }); const destinationSummary = useMemo(() => { if (destinationMode === "preserve") { return t("vault.import.destination.preserve"); } if (destinationMode === "existing") { return existingGroup || t("vault.import.destination.existing"); } return newGroup.trim() || t("vault.import.destination.new"); }, [destinationMode, existingGroup, newGroup, t]); useEffect(() => { const query = existingGroupQueryRef.current; if (query) { setExistingGroup(groups.includes(query) ? query : ""); return; } const next = groups[0] ?? ""; setExistingGroup(next); setExistingGroupQuery(next); }, [groups]); useEffect(() => { if (open) return; setStep("format"); setDestinationMode("preserve"); setExistingGroup(groups[0] ?? ""); setExistingGroupQuery(groups[0] ?? ""); setNewGroup(""); setMobaMasterPassword(""); }, [groups, open]); const pluginImporter = usePluginVaultImporter({ open, onOpenChange, onPluginPreviewCommit, destination, t, }); const downloadCsvTemplate = useCallback(() => { const csv = getVaultCsvTemplate(); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "netcatty-vault-template.csv"; a.click(); URL.revokeObjectURL(url); }, []); const pickFile = useCallback( ( format: VaultImportFormat, accept: string, options?: ImportOptions, secureCrtSource: "folder" | "file" = "folder", ) => { const input = fileInputRef.current; if (!input || !destination) return; const pickerMode = getVaultImportPickerMode(format, secureCrtSource); pendingFormatRef.current = format; pendingOptionsRef.current = { ...options, destination }; input.accept = accept; input.multiple = pickerMode.multiple; if (pickerMode.directory) { input.setAttribute("webkitdirectory", ""); input.setAttribute("directory", ""); } else { input.removeAttribute("webkitdirectory"); input.removeAttribute("directory"); } input.value = ""; input.click(); }, [destination], ); const handleFormatClick = useCallback( (opt: ImportOption) => { if (opt.format === "ssh_config") { setStep("ssh-mode"); } else if (opt.format === "mobaxterm") { setStep("moba-encoding"); } else if (opt.format === "securecrt") { setStep("securecrt-source"); } else { pickFile(opt.format, opt.accept); } }, [pickFile], ); const handleManagedChoice = useCallback( (managed: boolean) => { setStep("format"); pickFile("ssh_config", "*", { managed }); }, [pickFile], ); const handleMobaEncodingChoice = useCallback( (encoding: VaultImportFileEncoding) => { setStep("format"); pickFile("mobaxterm", ".ini,.mxtsessions,.txt,.mobaconf", { encoding, masterPassword: mobaMasterPassword === "" ? undefined : mobaMasterPassword, }); }, [mobaMasterPassword, pickFile], ); const handleSecureCrtChoice = useCallback( (source: "folder" | "file") => { setStep("format"); pickFile("securecrt", ".ini", undefined, source); }, [pickFile], ); const onChangeFile = useCallback( (e: React.ChangeEvent) => { const format = pendingFormatRef.current; const options = pendingOptionsRef.current; if (!format || !e.target.files) return; const files = selectVaultImportFiles(format, e.target.files); if (files.length === 0) return; onFileSelected(format, files, options); onOpenChange(false); e.target.value = ""; pendingOptionsRef.current = undefined; }, [onFileSelected, onOpenChange], ); const handleOpenChange = useCallback( (newOpen: boolean) => { if (!newOpen) { setStep("format"); } pluginImporter.handleOpenChange(newOpen); }, [pluginImporter], ); const previewAnalysis = useMemo( () => pluginImporter.pluginPreview ? getPluginPreviewAnalysis(pluginImporter.pluginPreview) : { duplicateCount: 0, validationErrorCount: 0, safePreview: { items: [], warnings: [], errors: [], omittedItemCount: 0, omittedDiagnosticCount: 0, }, }, [getPluginPreviewAnalysis, pluginImporter.pluginPreview], ); return ( <> {step === "destination" ? (
) : step === "securecrt-source" ? (
) : (
)} {step === "securecrt-source" ? t("vault.import.securecrt.promptTitle") : step === "destination" ? t("vault.import.destination.settings") : t("vault.import.title")} {step !== "destination" && step !== "securecrt-source" && ( {step === "ssh-mode" ? t("vault.import.sshConfig.chooseMode") : step === "moba-encoding" ? t("vault.import.mobaxterm.chooseEncoding") : t("vault.import.desc")} )}
{pluginImporter.pluginPreview ? (
{t("vault.import.plugins.preview")}
{pluginImporter.previewSummary || t("vault.import.plugins.empty")}
{t("vault.import.plugins.summary", { parsed: pluginImporter.pluginPreview.result.parsed, warnings: pluginImporter.pluginPreview.result.warnings, errors: pluginImporter.pluginPreview.result.errors, })}
{previewAnalysis.duplicateCount > 0 ? (
{t("vault.import.plugins.duplicates", { count: previewAnalysis.duplicateCount, })}
) : null} {previewAnalysis.validationErrorCount > 0 ? (
{t("vault.import.plugins.validationErrors", { count: previewAnalysis.validationErrorCount, })}
) : null} {previewAnalysis.safePreview.items.length > 0 ? (
{previewAnalysis.safePreview.items.map( (item, index) => (
{t(`vault.import.plugins.kind.${item.kind}`)} {item.label} {item.detail ? ( {item.detail} ) : null}
), )} {previewAnalysis.safePreview.omittedItemCount > 0 ? (
{t("vault.import.plugins.moreItems", { count: previewAnalysis.safePreview.omittedItemCount, })}
) : null}
) : null} {[ ...previewAnalysis.safePreview.warnings, ...previewAnalysis.safePreview.errors, ].length > 0 ? (
{previewAnalysis.safePreview.warnings.map( (message, index) => (
{message}
), )} {previewAnalysis.safePreview.errors.map( (message, index) => (
{message}
), )} {previewAnalysis.safePreview.omittedDiagnosticCount > 0 ? (
{t("vault.import.plugins.moreDiagnostics", { count: previewAnalysis.safePreview .omittedDiagnosticCount, })}
) : null}
) : null}
) : step === "ssh-mode" ? ( <>
{t("vault.import.sshConfig.modeQuestion")}

{t("vault.import.sshConfig.managedDestinationHint")}

) : step === "moba-encoding" ? ( <>
{t("vault.import.mobaxterm.encodingQuestion")}
{( [ ["auto", "auto", "autoDesc", true], ["utf-8", "utf8", "utf8Desc", false], ["gb18030", "gb18030", "gb18030Desc", false], ] as const ).map(([encoding, labelKey, descKey, recommended]) => ( ))}
setMobaMasterPassword(event.target.value)} placeholder={t("vault.import.mobaxterm.masterPasswordPlaceholder")} />

{t("vault.import.mobaxterm.masterPasswordHint")}

) : step === "securecrt-source" ? ( <>
) : step === "destination" ? ( <> { setDestinationMode(mode); if ( mode === "existing" && !existingGroup && !existingGroupQuery && groups[0] ) { setExistingGroup(groups[0]); setExistingGroupQuery(groups[0]); } // Preserve needs no extra input — finish immediately. if (mode === "preserve") setStep("format"); }} groups={groups} existingGroup={existingGroup} onExistingGroupChange={setExistingGroup} existingGroupQuery={existingGroupQuery} onExistingGroupQueryChange={setExistingGroupQuery} newGroup={newGroup} onNewGroupChange={setNewGroup} t={t} />
{destinationMode !== "preserve" && ( )}
) : ( <>
{t("vault.import.chooseFormat")}
{OPTIONS.map((opt) => ( ))}
{pluginImporter.pluginProviders.length > 0 && ( <>
{t("vault.import.plugins.title")}
{pluginImporter.pluginProviders.map((provider) => ( ))}
)} {pluginImporter.pluginBusy && (
{pluginImporter.pluginProgress ? t("vault.import.plugins.progress", { completed: pluginImporter.pluginProgress.completed, total: pluginImporter.pluginProgress.total ?? "?", message: pluginImporter.pluginProgress.message ?? "", }) : t("vault.import.plugins.loading")}
)} {pluginImporter.pluginError && (
{pluginImporter.pluginError}
)}
)}
); };