Files
donutbrowser/src/components/import-profile-dialog.tsx
T
2026-08-10 09:19:25 +04:00

1083 lines
39 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFileArchive, FaFolder } from "react-icons/fa";
import { LuChevronRight } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedDisclosureChevron,
AnimatedDisclosureContent,
} from "@/components/ui/animated-disclosure";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useGroupEvents } from "@/hooks/use-group-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { fireSprinkleConfetti } from "@/lib/confetti";
import { cn } from "@/lib/utils";
import type {
ArchiveScanResult,
DetectedProfile,
ImportProfileItem,
ProfileImportBatchResult,
ProfileImportProgress,
ProfileImportReport,
WayfernConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
/**
* What an import actually carried, and what it could not.
*
* The counts matter more than they look: an import that reports zero of
* everything is the exact symptom of the bug where copied data landed where
* the browser never reads it, and it used to be indistinguishable from success.
*/
function ImportReportSummary({ report }: { report: ProfileImportReport }) {
const { t } = useTranslation();
// Label-then-value rather than "{{count}} cookies": it keeps the row scannable
// and sidesteps needing correct plural forms in ten languages.
const carried = (
[
["importProfile.reportCookies", report.cookies_migrated],
["importProfile.reportPasswords", report.passwords_migrated],
["importProfile.reportAutofill", report.payment_methods_migrated],
["importProfile.reportExtensions", report.extensions_migrated],
["importProfile.reportHistory", report.history_entries],
["importProfile.reportBookmarks", report.bookmarks],
["importProfile.reportLocalStorage", report.local_storage_origins],
] as const
)
.filter(([, count]) => count > 0)
.map(([key, count]) => `${t(key)} ${count.toLocaleString()}`);
const unrecoverable =
report.cookies_unrecoverable +
report.passwords_unrecoverable +
report.payment_methods_unrecoverable;
return (
<div className="mt-0.5 space-y-0.5 pl-1 text-xs text-muted-foreground">
<p>
{carried.length > 0
? carried.join(" · ")
: t("importProfile.reportNothingCarried")}
</p>
{unrecoverable > 0 && (
<p>
{t("importProfile.reportUnrecoverable", { count: unrecoverable })}
</p>
)}
{report.warnings.map((code) => (
<p key={code} className="text-warning-text">
{t(`importProfile.warnings.${code}`)}
</p>
))}
</div>
);
}
/**
* Fold a retry's results back into the batch it came from.
*
* A retry only resubmits the items that failed, so the previous batch is still
* authoritative for every other row. Replacing it wholesale would make the
* successful imports disappear from the summary.
*/
function mergeImportResults(
previous: ProfileImportBatchResult,
retry: ProfileImportBatchResult,
): ProfileImportBatchResult {
const byPath = new Map(retry.results.map((item) => [item.source_path, item]));
const results = previous.results.map(
(item) => byPath.get(item.source_path) ?? item,
);
const count = (status: string) =>
results.filter((item) => item.status === status).length;
return {
imported_count: count("imported"),
skipped_count: count("skipped"),
failed_count: count("failed"),
results,
};
}
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
subPage?: boolean;
}
type Step = "select" | "configure" | "importing";
type ImportMode = "auto-detect" | "manual";
type DuplicateStrategy = "rename" | "skip";
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
subPage,
}: ImportProfileDialogProps) {
const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState<Step>("select");
const [importMode, setImportMode] = useState<ImportMode>("auto-detect");
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
);
const [scannedProfiles, setScannedProfiles] = useState<DetectedProfile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [manualPath, setManualPath] = useState("");
const [extractedDir, setExtractedDir] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [profileNames, setProfileNames] = useState<Record<string, string>>({});
const [selectedGroupId, setSelectedGroupId] = useState<string>("none");
const [isCreatingGroup, setIsCreatingGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [duplicateStrategy, setDuplicateStrategy] =
useState<DuplicateStrategy>("rename");
// "none" | "round-robin" | a stored proxy id
const [proxyAssignment, setProxyAssignment] = useState<string>("none");
// "none" | a VPN config id (applied to every imported profile)
const [vpnAssignment, setVpnAssignment] = useState<string>("none");
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
// Fingerprint + advanced options collapse behind disclosures — the default
// path is just names + proxy/VPN.
const [showFingerprint, setShowFingerprint] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
const [result, setResult] = useState<ProfileImportBatchResult | null>(null);
const { storedProxies } = useProxyEvents();
const { groups } = useGroupEvents();
const { vpnConfigs } = useVpnEvents();
const reducedMotion = useReducedMotion();
const activeProfiles =
importMode === "auto-detect" ? detectedProfiles : scannedProfiles;
const selectedProfiles = useMemo(
() => activeProfiles.filter((p) => selectedPaths.has(p.path)),
[activeProfiles, selectedPaths],
);
const registerProfileNames = useCallback((profiles: DetectedProfile[]) => {
setProfileNames((prev) => {
const next = { ...prev };
for (const profile of profiles) {
if (next[profile.path] === undefined) {
next[profile.path] = profile.name;
}
}
return next;
});
}, []);
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
try {
const profiles = await invoke<DetectedProfile[]>(
"detect_existing_profiles",
);
setDetectedProfiles(profiles);
registerProfileNames(profiles);
if (profiles.length === 0) {
setImportMode("manual");
}
} catch (error) {
console.error("Failed to detect existing profiles:", error);
toast.error(t("importProfile.detectFailed"));
} finally {
setIsLoading(false);
}
}, [t, registerProfileNames]);
const cleanupExtractedDir = useCallback(async (dir: string | null) => {
if (!dir) return;
try {
await invoke("cleanup_profile_import_scratch", { extractedDir: dir });
} catch (error) {
console.error("Failed to clean up extracted archive:", error);
}
}, []);
const applyScanResult = useCallback(
(profiles: DetectedProfile[]) => {
setScannedProfiles(profiles);
registerProfileNames(profiles);
setSelectedPaths(new Set(profiles.map((p) => p.path)));
if (profiles.length === 0) {
toast.info(t("importProfile.noProfilesInLocation"));
}
},
[registerProfileNames, t],
);
const scanPath = useCallback(
async (path: string) => {
setIsScanning(true);
try {
if (path.toLowerCase().endsWith(".zip")) {
await cleanupExtractedDir(extractedDir);
setExtractedDir(null);
const scan = await invoke<ArchiveScanResult>("scan_profile_archive", {
archivePath: path,
});
setExtractedDir(scan.extracted_dir);
applyScanResult(scan.profiles);
} else {
const profiles = await invoke<DetectedProfile[]>(
"scan_folder_for_profiles",
{ folderPath: path },
);
applyScanResult(profiles);
}
} catch (error) {
console.error("Failed to scan for profiles:", error);
toast.error(translateBackendError(t, error));
} finally {
setIsScanning(false);
}
},
[applyScanResult, cleanupExtractedDir, extractedDir, t],
);
const handleBrowseFolder = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t("importProfile.selectFolderTitle"),
});
if (selected && typeof selected === "string") {
setManualPath(selected);
await scanPath(selected);
}
} catch (error) {
console.error("Failed to open folder dialog:", error);
toast.error(t("importProfile.folderDialogFailed"));
}
};
const handleBrowseArchive = async () => {
try {
const selected = await open({
multiple: false,
title: t("importProfile.selectArchiveTitle"),
filters: [{ name: "ZIP", extensions: ["zip"] }],
});
if (selected && typeof selected === "string") {
setManualPath(selected);
await scanPath(selected);
}
} catch (error) {
console.error("Failed to open archive dialog:", error);
toast.error(t("importProfile.folderDialogFailed"));
}
};
const togglePath = (path: string, checked: boolean) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (checked) {
next.add(path);
} else {
next.delete(path);
}
return next;
});
};
const toggleAll = (checked: boolean) => {
setSelectedPaths(
checked ? new Set(activeProfiles.map((p) => p.path)) : new Set(),
);
};
const proxyIdForIndex = useCallback(
(index: number): string | null => {
if (proxyAssignment === "none") return null;
if (proxyAssignment === "round-robin") {
if (storedProxies.length === 0) return null;
return storedProxies[index % storedProxies.length].id;
}
return proxyAssignment;
},
[proxyAssignment, storedProxies],
);
const handleCreateGroup = async () => {
const name = newGroupName.trim();
if (!name) return;
try {
const group = await invoke<{ id: string; name: string }>(
"create_profile_group",
{ name },
);
setSelectedGroupId(group.id);
setIsCreatingGroup(false);
setNewGroupName("");
} catch (error) {
console.error("Failed to create group:", error);
toast.error(translateBackendError(t, error));
}
};
const handleImport = useCallback(
async (allowRunning = false, retryPaths?: ReadonlySet<string>) => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
// Filter AFTER the map, so a retry keeps the proxy each profile was
// originally assigned by the index-based round-robin.
const items: ImportProfileItem[] = selectedProfiles
.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
allow_running: allowRunning,
}))
.filter((item) => !retryPaths || retryPaths.has(item.source_path));
if (items.length === 0) {
return;
}
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(
previous ? mergeImportResults(previous, batchResult) : batchResult,
);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
},
[
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
result,
t,
],
);
// A source browser that is still running is the one failure the user can fix
// without starting over, so offer the override right where it happened.
const hasRunningBrowserFailure = useMemo(
() =>
(result?.results ?? []).some(
(item) =>
item.status === "failed" &&
item.error?.includes("IMPORT_SOURCE_BROWSER_RUNNING"),
),
[result],
);
const handleClose = () => {
void cleanupExtractedDir(extractedDir);
setCurrentStep("select");
setImportMode(detectedProfiles.length > 0 ? "auto-detect" : "manual");
setScannedProfiles([]);
setManualPath("");
setExtractedDir(null);
setSelectedPaths(new Set());
setProfileNames({});
setSelectedGroupId("none");
setIsCreatingGroup(false);
setNewGroupName("");
setDuplicateStrategy("rename");
setProxyAssignment("none");
setVpnAssignment("none");
setWayfernConfig({});
setShowFingerprint(false);
setShowAdvanced(false);
setProgress(null);
setResult(null);
onClose();
};
useEffect(() => {
if (isOpen) {
void loadDetectedProfiles();
}
}, [isOpen, loadDetectedProfiles]);
useEffect(() => {
if (!isOpen) return;
const unlistenPromise = listen<ProfileImportProgress>(
"profile-import-progress",
(event) => {
setProgress(event.payload);
},
);
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}, [isOpen]);
const allSelected =
activeProfiles.length > 0 && selectedPaths.size >= activeProfiles.length;
const renderProfileList = (profiles: DetectedProfile[]) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="import-select-all"
className="flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
id="import-select-all"
checked={allSelected}
onCheckedChange={(checked) => toggleAll(checked === true)}
/>
{t("importProfile.selectAll")}
</label>
<span className="text-xs text-muted-foreground">
{t("importProfile.selectedCount", { count: selectedPaths.size })}
</span>
</div>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{profiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
const checkboxId = `import-profile-${encodeURIComponent(profile.path)}`;
return (
<label
key={profile.path}
htmlFor={checkboxId}
className="flex cursor-pointer items-center gap-2 rounded-md p-2 hover:bg-muted"
>
<Checkbox
id={checkboxId}
checked={selectedPaths.has(profile.path)}
onCheckedChange={(checked) =>
togglePath(profile.path, checked === true)
}
/>
{IconComponent && <IconComponent className="size-4 shrink-0" />}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{profile.name}</p>
<p className="truncate text-xs text-muted-foreground">
{profile.path}
</p>
</div>
</label>
);
})}
</div>
</div>
);
const progressPercent =
progress && progress.total > 0
? Math.round((progress.completed / progress.total) * 100)
: 0;
return (
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
<DialogContent className="flex max-h-[80vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={cn("space-y-6", subPage && "mx-auto w-full max-w-3xl")}
>
{currentStep === "select" && (
<AnimatedTabs
value={importMode}
onValueChange={(v) => {
setImportMode(v as ImportMode);
setSelectedPaths(new Set());
}}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.scanning")}
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.noneFound")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("importProfile.noneFoundHint")}
</p>
</div>
) : (
renderProfileList(detectedProfiles)
)}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
</h3>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
{t("importProfile.profileFolderPath")}
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualPath}
onChange={(e) => {
setManualPath(e.target.value);
}}
placeholder={t(
"importProfile.profileFolderPlaceholder",
)}
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseArchive()}
title={t("importProfile.selectArchiveTitle")}
>
<FaFileArchive className="size-4" />
</Button>
<LoadingButton
variant="outline"
isLoading={isScanning}
disabled={!manualPath.trim()}
onClick={() => void scanPath(manualPath.trim())}
>
{t("importProfile.scanButton")}
</LoadingButton>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{t("importProfile.manualHint")}
</p>
<p className="mt-2 text-xs break-all text-muted-foreground">
{t("importProfile.examplePaths")}
<br />
macOS: ~/Library/Application
Support/Google/Chrome/Default
<br />
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
<br />
Linux: ~/.config/google-chrome/Default
</p>
</div>
{scannedProfiles.length > 0 &&
renderProfileList(scannedProfiles)}
</div>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && (
<div className="space-y-4">
<Alert>
<AlertDescription>
{t("importProfile.importedAs", {
browser: getBrowserDisplayName("wayfern"),
})}
</AlertDescription>
</Alert>
<div>
<Label className="mb-2">
{t("importProfile.profilesToImport")}
</Label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
{selectedProfiles.map((profile) => (
<div
key={profile.path}
className="flex items-center gap-2"
>
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
title={profile.path}
>
{profile.name}
</span>
<Input
className="flex-1"
aria-label={t("importProfile.newProfileName")}
value={profileNames[profile.path] ?? profile.name}
onChange={(e) => {
setProfileNames((prev) => ({
...prev,
[profile.path]: e.target.value,
}));
}}
placeholder={t(
"importProfile.newProfileNamePlaceholder",
)}
/>
</div>
))}
</div>
</div>
<div>
<Label className="mb-2">
{t("importProfile.proxyOptional")}
</Label>
<Select
value={proxyAssignment}
onValueChange={setProxyAssignment}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noProxy")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noProxy")}
</SelectItem>
{storedProxies.length > 0 && (
<SelectItem value="round-robin">
{t("importProfile.proxyRoundRobin")}
</SelectItem>
)}
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{vpnConfigs.length > 0 && (
<div>
<Label className="mb-2">
{t("importProfile.vpnOptional")}
</Label>
<Select
value={vpnAssignment}
onValueChange={setVpnAssignment}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noVpn")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noVpn")}
</SelectItem>
{vpnConfigs.map((vpn) => (
<SelectItem key={vpn.id} value={vpn.id}>
{vpn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<button
type="button"
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
>
<AnimatedDisclosureChevron open={showAdvanced}>
<LuChevronRight className="size-3.5" />
</AnimatedDisclosureChevron>
{t("importProfile.advancedOptions")}
</button>
<AnimatedDisclosureContent
open={showAdvanced}
className="mt-3 space-y-4"
>
<div>
<Label className="mb-2">
{t("importProfile.groupOptional")}
</Label>
{isCreatingGroup ? (
<div className="flex gap-2">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"importProfile.newGroupNamePlaceholder",
)}
/>
<Button
variant="outline"
disabled={!newGroupName.trim()}
onClick={() => void handleCreateGroup()}
>
{t("common.buttons.create")}
</Button>
<Button
variant="ghost"
onClick={() => {
setIsCreatingGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<Select
value={selectedGroupId}
onValueChange={(value) => {
if (value === "create-new") {
setIsCreatingGroup(true);
} else {
setSelectedGroupId(value);
}
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("importProfile.noGroup")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
<SelectItem value="create-new">
{t("importProfile.createNewGroup")}
</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div>
<Label className="mb-2">
{t("importProfile.duplicateStrategyLabel")}
</Label>
<Select
value={duplicateStrategy}
onValueChange={(value) => {
setDuplicateStrategy(value as DuplicateStrategy);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rename">
{t("importProfile.duplicateRename")}
</SelectItem>
<SelectItem value="skip">
{t("importProfile.duplicateSkip")}
</SelectItem>
</SelectContent>
</Select>
</div>
</AnimatedDisclosureContent>
</div>
<div>
<button
type="button"
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => setShowFingerprint((v) => !v)}
aria-expanded={showFingerprint}
>
<AnimatedDisclosureChevron open={showFingerprint}>
<LuChevronRight className="size-3.5" />
</AnimatedDisclosureChevron>
{t("importProfile.configureFingerprint")}
</button>
<AnimatedDisclosureContent
open={showFingerprint}
className="mt-3"
>
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</AnimatedDisclosureContent>
</div>
</div>
)}
{currentStep === "importing" && (
<div className="space-y-4">
{isImporting && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.importingTitle")}
</h3>
<Progress value={progressPercent} />
{progress && (
<p className="text-sm text-muted-foreground">
{t("importProfile.importProgress", {
completed: progress.completed,
total: progress.total,
})}
{progress.status === "importing" && (
<> {progress.name}</>
)}
</p>
)}
</div>
)}
{result && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.resultsSummary", {
imported: result.imported_count,
skipped: result.skipped_count,
failed: result.failed_count,
})}
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
<div key={item.source_path} className="p-1 text-sm">
<div className="flex items-center gap-2">
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" &&
"text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(
t,
new Error(item.error),
)}
</span>
)}
</div>
{item.report && (
<ImportReportSummary report={item.report} />
)}
</div>
))}
</div>
{hasRunningBrowserFailure && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t("importProfile.closeSourceBrowserHint")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleImport(
true,
new Set(
result.results
.filter(
(item) =>
item.status === "failed" &&
item.error?.includes(
"IMPORT_SOURCE_BROWSER_RUNNING",
),
)
.map((item) => item.source_path),
),
);
}}
>
{t("importProfile.importAnyway")}
</Button>
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
)}
</div>
</div>
<div
className={cn(
"flex shrink-0 items-center justify-end gap-2",
subPage
? "mx-auto w-full max-w-3xl border-t border-border pt-2"
: undefined,
)}
>
{currentStep === "select" && (
<>
{!subPage && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
<RippleButton
disabled={selectedPaths.size === 0}
onClick={() => {
setCurrentStep("configure");
}}
>
{t("importProfile.nextButton")}
</RippleButton>
</>
)}
{currentStep === "configure" && (
<>
<RippleButton
variant="outline"
onClick={() => {
setCurrentStep("select");
}}
>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleImport();
}}
>
{t("importProfile.importButtonCount", {
count: selectedProfiles.length,
})}
</LoadingButton>
</>
)}
{currentStep === "importing" && (
<RippleButton disabled={isImporting} onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
)}
</div>
</DialogContent>
</Dialog>
);
}