[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

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

View File

@@ -0,0 +1,691 @@
/**
* CloudSyncSettings - End-to-End Encrypted Cloud Sync UI
*
* Handles:
* - Master key setup (gatekeeper screen)
* - Provider connections (GitHub, Google, OneDrive)
* - Sync status and conflict resolution
*/
import React, { useState, useCallback } from 'react';
import {
AlertTriangle,
Check,
Cloud,
CloudOff,
Copy,
Download,
ExternalLink,
Eye,
EyeOff,
Github,
Loader2,
RefreshCw,
Settings,
Shield,
ShieldCheck,
X,
} from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useCloudSync } from '../../application/state/useCloudSync';
import { type CloudProvider, type ConflictInfo, type SyncChangeEntityKey, type SyncEntityChangeCounts, formatLastSync } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { ConfirmDialog } from '../ui/confirm-dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { toast } from '../ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// ============================================================================
// Provider Icons
// ============================================================================
export const GoogleDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M7.71 3.5L1.15 15l3.43 6 6.55-11.5L7.71 3.5zm1.73 0l6.55 11.5H23L16.45 3.5H9.44zM8 15l-3.43 6h13.72l3.43-6H8z" />
</svg>
);
export const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M10.5 18.5c0 .55-.45 1-1 1h-5c-2.21 0-4-1.79-4-4 0-1.86 1.28-3.41 3-3.86v-.14c0-2.21 1.79-4 4-4 1.1 0 2.1.45 2.82 1.18A5.003 5.003 0 0 1 15 4c2.76 0 5 2.24 5 5 0 .16 0 .32-.02.47A4.5 4.5 0 0 1 24 13.5c0 2.49-2.01 4.5-4.5 4.5h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c1.38 0 2.5-1.12 2.5-2.5s-1.12-2.5-2.5-2.5H19c-.28 0-.5-.22-.5-.5 0-2.21-1.79-4-4-4-1.87 0-3.44 1.28-3.88 3.02-.09.37-.41.63-.79.63-1.66 0-3 1.34-3 3v.5c0 .28-.22.5-.5.5-1.38 0-2.5 1.12-2.5 2.5s1.12 2.5 2.5 2.5h5c.55 0 1 .45 1 1z" />
</svg>
);
// ============================================================================
// Toggle Component
// ============================================================================
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input"
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
// ============================================================================
// Status Dot Component
// ============================================================================
interface StatusDotProps {
status: 'connected' | 'syncing' | 'error' | 'disconnected' | 'connecting';
className?: string;
}
export const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
disconnected: 'bg-muted-foreground/50',
};
return (
<span className={cn('inline-block w-2 h-2 rounded-full', colors[status], className)} />
);
};
// ============================================================================
// Gatekeeper Screen (NO_KEY state)
// ============================================================================
interface GatekeeperScreenProps {
onSetupComplete: () => void;
}
export const GatekeeperScreen: React.FC<GatekeeperScreenProps> = ({ onSetupComplete }) => {
const { t } = useI18n();
const { setupMasterKey } = useCloudSync();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const passwordStrength = React.useMemo(() => {
if (password.length < 8) return { level: 0, text: t('cloudSync.passwordStrength.tooShort') };
let score = 0;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[a-z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return { level: 1, text: t('cloudSync.passwordStrength.weak') };
if (score <= 3) return { level: 2, text: t('cloudSync.passwordStrength.moderate') };
if (score <= 4) return { level: 3, text: t('cloudSync.passwordStrength.strong') };
return { level: 4, text: t('cloudSync.passwordStrength.veryStrong') };
}, [password, t]);
const canSubmit = password.length >= 8 && password === confirmPassword && acknowledged;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setIsLoading(true);
setError(null);
try {
await setupMasterKey(password, confirmPassword);
toast.success(t('cloudSync.gate.enabledToast'));
onSetupComplete();
} catch (err) {
setError(err instanceof Error ? err.message : t('cloudSync.gate.setupFailed'));
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
<Shield className="w-10 h-10 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{t('cloudSync.gate.title')}</h2>
<p className="text-sm text-muted-foreground max-w-md mb-8">
{t('cloudSync.gate.desc')}
</p>
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<div className="space-y-2">
<Label className="text-left block">{t('cloudSync.gate.masterKey')}</Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('cloudSync.gate.placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{password.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full transition-all',
passwordStrength.level === 1 && 'w-1/4 bg-red-500',
passwordStrength.level === 2 && 'w-2/4 bg-yellow-500',
passwordStrength.level === 3 && 'w-3/4 bg-green-500',
passwordStrength.level === 4 && 'w-full bg-green-600',
)}
/>
</div>
<span className="text-xs text-muted-foreground">{passwordStrength.text}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-left block">{t('cloudSync.gate.confirmMasterKey')}</Label>
<Input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('cloudSync.gate.confirmPlaceholder')}
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-500 text-left">{t('cloudSync.gate.mismatch')}</p>
)}
</div>
<label className="flex items-start gap-3 p-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/50 cursor-pointer text-left">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5 accent-red-500"
/>
<span className="text-xs text-red-700 dark:text-red-400">
{t('cloudSync.gate.warning')}
</span>
</label>
{error && (
<p className="text-sm text-red-500 text-left">{error}</p>
)}
<Button
type="submit"
disabled={!canSubmit || isLoading}
className="w-full gap-2"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ShieldCheck size={16} />
)}
{t('cloudSync.gate.enableVault')}
</Button>
</form>
</div>
);
};
// ============================================================================
// Provider Card Component
// ============================================================================
interface ProviderCardProps {
provider: CloudProvider;
name: string;
icon: React.ReactNode;
isConnected: boolean;
isSyncing: boolean;
isConnecting?: boolean;
account?: { name?: string; email?: string; avatarUrl?: string };
lastSync?: number;
error?: string;
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
extraActions?: React.ReactNode;
}
export const ProviderCard: React.FC<ProviderCardProps> = ({
provider: _provider,
name,
icon,
isConnected,
isSyncing,
isConnecting,
account,
lastSync,
error,
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
extraActions,
}) => {
const { t } = useI18n();
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
const formatLastSyncLabel = (timestamp?: number): string => {
if (!timestamp) return t('cloudSync.lastSync.never');
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return t('cloudSync.lastSync.justNow');
if (diff < 3600000) return t('cloudSync.lastSync.minutesAgo', { minutes: Math.floor(diff / 60000) });
return formatLastSync(timestamp);
};
const status = error
? 'error'
: isSyncing
? 'syncing'
: isConnected
? 'connected'
: isConnecting
? 'connecting'
: 'disconnected';
return (
<div className={cn(
"flex items-center gap-4 p-4 rounded-lg border transition-colors",
isConnected ? "bg-card" : "bg-muted/30",
error && "border-red-300 dark:border-red-900"
)}>
<div className={cn(
"w-12 h-12 rounded-lg flex items-center justify-center",
isConnected ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{name}</span>
<StatusDot status={status} />
</div>
{isConnected && account ? (
<div className="flex items-center gap-2 mt-1">
{account.avatarUrl && (
<img
src={account.avatarUrl}
alt=""
className="w-4 h-4 rounded-full"
referrerPolicy="no-referrer"
crossOrigin="anonymous"
/>
)}
<span className="text-xs text-muted-foreground truncate">
{account.name || account.email}
</span>
<span className="text-xs text-muted-foreground">
· {formatLastSyncLabel(lastSync)}
</span>
</div>
) : error ? (
<Tooltip>
<TooltipTrigger asChild>
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
{error}
</p>
</TooltipTrigger>
<TooltipContent>{error}</TooltipContent>
</Tooltip>
) : (
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Button
size="sm"
variant="ghost"
onClick={onSync}
disabled={isSyncing}
className="gap-1"
>
{isSyncing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
{t('cloudSync.provider.sync')}
</Button>
{extraActions}
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={onEdit}
className="gap-1"
>
<Settings size={14} />
{t('action.edit')}
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => setDisconnectConfirmOpen(true)}
className="text-muted-foreground hover:text-red-500"
aria-label={t('cloudSync.provider.disconnect')}
>
<CloudOff size={14} />
</Button>
</>
) : isConnecting && onCancelConnect ? (
<Button
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1 min-w-[136px] justify-center"
>
<X size={14} />
{t('common.cancel')}
</Button>
) : (
<Button
size="sm"
onClick={() => { onConnect(); }}
className="gap-1 min-w-[136px] justify-center"
disabled={disabled || isConnecting}
>
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.connect')}
</Button>
)}
</div>
<ConfirmDialog
open={disconnectConfirmOpen}
title={t('cloudSync.provider.disconnect.confirmTitle', { name })}
message={t('cloudSync.provider.disconnect.confirmMessage', { name })}
confirmLabel={t('cloudSync.provider.disconnect.confirmAction')}
destructive
onOpenChange={setDisconnectConfirmOpen}
onConfirm={() => {
setDisconnectConfirmOpen(false);
onDisconnect();
}}
/>
</div>
);
};
// ============================================================================
// GitHub Device Flow Modal
// ============================================================================
interface GitHubDeviceFlowModalProps {
isOpen: boolean;
userCode: string;
verificationUri: string;
isPolling: boolean;
onClose: () => void;
}
export const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
isOpen,
userCode,
verificationUri,
isPolling,
onClose,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const copyCode = useCallback(() => {
navigator.clipboard.writeText(userCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [userCode]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground"
>
<X size={18} />
</button>
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-[#24292e] flex items-center justify-center mx-auto mb-4">
<Github className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-semibold mb-2">{t('cloudSync.githubFlow.title')}</h3>
<p className="text-sm text-muted-foreground mb-6">
{t('cloudSync.githubFlow.desc')}
</p>
<div className="bg-muted rounded-lg p-4 mb-4">
<div className="font-mono text-2xl font-bold tracking-widest mb-2">
{userCode}
</div>
<Button size="sm" variant="ghost" onClick={copyCode} className="gap-2">
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? t('cloudSync.githubFlow.copied') : t('cloudSync.githubFlow.copyCode')}
</Button>
</div>
<Button
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
className="w-full gap-2 mb-4"
>
<ExternalLink size={14} />
{t('cloudSync.githubFlow.openGitHub')}
</Button>
{isPolling && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t('cloudSync.githubFlow.waiting')}
</div>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// Conflict Resolution Modal
// ============================================================================
interface ConflictModalProps {
open: boolean;
conflict: ConflictInfo | null;
onResolve: (resolution: 'USE_LOCAL' | 'USE_REMOTE') => void;
onClose: () => void;
}
const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
hosts: 'cloudSync.conflict.entity.hosts',
keys: 'cloudSync.conflict.entity.keys',
identities: 'cloudSync.conflict.entity.identities',
proxyProfiles: 'cloudSync.conflict.entity.proxyProfiles',
snippets: 'cloudSync.conflict.entity.snippets',
notes: 'cloudSync.conflict.entity.notes',
noteGroups: 'cloudSync.conflict.entity.noteGroups',
customGroups: 'cloudSync.conflict.entity.customGroups',
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
settings: 'cloudSync.conflict.entity.settings',
};
export const ConflictModal: React.FC<ConflictModalProps> = ({
open,
conflict,
onResolve,
onClose,
}) => {
const { t, resolvedLocale } = useI18n();
if (!open || !conflict) return null;
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString(resolvedLocale || undefined);
};
const changeRows = conflict.changeSummary
? (Object.entries(conflict.changeSummary.byEntity) as Array<[SyncChangeEntityKey, SyncEntityChangeCounts | undefined]>)
.flatMap(([entityType, counts]) => {
if (!counts) return [];
return [{
entityType,
localTotal: counts.added.local + counts.modified.local + counts.deleted.local,
remoteTotal: counts.added.remote + counts.modified.remote + counts.deleted.remote,
conflictTotal: conflict.changeSummary?.conflicts.filter((item) => item.entityType === entityType).length ?? 0,
}];
})
.filter((row) => row.localTotal > 0 || row.remoteTotal > 0 || row.conflictTotal > 0)
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-xl w-full max-w-lg max-h-[calc(100vh-2rem)] p-6 relative flex flex-col">
<button
onClick={onClose}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground"
>
<X size={18} />
</button>
<div className="flex-1 overflow-y-auto pr-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-amber-500" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-semibold">{t('cloudSync.conflict.title')}</h3>
<p className="text-sm text-muted-foreground">
{t('cloudSync.conflict.desc')}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.local')}</div>
<div className="text-sm font-medium">v{conflict.localVersion}</div>
<div className="text-xs text-muted-foreground mt-1 break-words">
{formatDate(conflict.localUpdatedAt)}
</div>
{conflict.localDeviceName && (
<div className="text-xs text-muted-foreground break-words">
{conflict.localDeviceName}
</div>
)}
</div>
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.cloud')}</div>
<div className="text-sm font-medium">v{conflict.remoteVersion}</div>
<div className="text-xs text-muted-foreground mt-1 break-words">
{formatDate(conflict.remoteUpdatedAt)}
</div>
{conflict.remoteDeviceName && (
<div className="text-xs text-muted-foreground break-words">
{conflict.remoteDeviceName}
</div>
)}
</div>
</div>
{changeRows.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3 mb-6 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('cloudSync.conflict.detailsTitle')}
</div>
<div className="space-y-2">
{changeRows.map((row) => (
<div key={row.entityType} className="grid gap-1 text-sm">
<span className="font-medium break-words">
{t(CONFLICT_ENTITY_LABEL_KEYS[row.entityType])}
</span>
<span className="text-xs text-muted-foreground break-words">
{t('cloudSync.conflict.detailsCounts', {
local: row.localTotal,
cloud: row.remoteTotal,
conflicts: row.conflictTotal,
})}
</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2 pt-4 shrink-0">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => onResolve('USE_LOCAL')}
>
<Cloud size={14} />
{t('cloudSync.conflict.keepLocal')}
</Button>
<Button
className="w-full gap-2"
onClick={() => onResolve('USE_REMOTE')}
>
<Download size={14} />
{t('cloudSync.conflict.useCloud')}
</Button>
</div>
</div>
</div>
);
};
// ============================================================================
// Main Dashboard (UNLOCKED state)
// ============================================================================

View File

@@ -0,0 +1,691 @@
import React, { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { Database, Github, History, Plug, Server, Trash2 } from 'lucide-react';
import type {
CloudProvider,
ConvergentFieldConflict,
ConvergentMigrationPreview,
SyncPayload,
} from '../../domain/sync';
import { isBuiltinCloudProvider } from '../../domain/sync';
import { isPluginCloudProviderId } from '../../domain/cloudProviderIds';
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from '../../domain/pluginSyncConnect';
import { planPluginSyncCredential, syncConfigurationSchemaWithoutSecretRequirements } from '../../domain/pluginSyncCredential';
import { pluginConfigurationMatchesSchema } from '../../domain/pluginConfigurationSchema';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { toast } from '../ui/toast';
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
import { LocalBackupsPanel } from './CloudSyncLocalBackupsPanel';
import { ConvergentSyncPanel } from './ConvergentSyncPanel';
import { SettingsAnchor } from '../settings/settings-ui';
type SyncController = ReturnType<typeof useCloudSync>;
type Translate = (key: string, values?: Record<string, string | number>) => string;
interface CloudSyncDashboardTabsProps {
activeTab: 'providers' | 'status';
setActiveTab: Dispatch<SetStateAction<'providers' | 'status'>>;
t: Translate;
sync: SyncController;
resolvedLocale: string | null;
localBackupsRef: RefObject<HTMLDivElement | null>;
isConnectDisabled: (provider: CloudProvider) => boolean;
handleConnectGitHub: () => Promise<void>;
handleConnectGoogle: () => Promise<void>;
handleConnectOneDrive: () => Promise<void>;
openWebdavDialog: () => void;
openS3Dialog: () => void;
handleOpenHistory: () => Promise<void>;
handleSync: (provider: CloudProvider) => Promise<void>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
setShowClearLocalDialog: Dispatch<SetStateAction<boolean>>;
convergentConfig: { enabled: boolean; initialized: boolean };
convergentPreview: ConvergentMigrationPreview | null;
convergentBusy: boolean;
convergentError: string | null;
convergentConflicts: ConvergentFieldConflict[];
onToggleConvergent: (enabled: boolean) => void | Promise<void>;
onConfirmConvergentMigration: () => void | Promise<void>;
onCancelConvergentMigration: () => void;
onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
onDowngradeConvergent: () => void | Promise<void>;
/** Disconnect other legacy providers before connecting a plugin backend. */
disconnectOtherProviders?: (current: CloudProvider) => Promise<void>;
}
export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
activeTab,
setActiveTab,
t,
sync,
resolvedLocale,
localBackupsRef,
isConnectDisabled,
handleConnectGitHub,
handleConnectGoogle,
handleConnectOneDrive,
openWebdavDialog,
openS3Dialog,
handleOpenHistory,
handleSync,
onApplyPayload,
onApplyLocalPayload,
setShowClearLocalDialog,
convergentConfig,
convergentPreview,
convergentBusy,
convergentError,
convergentConflicts,
onToggleConvergent,
onConfirmConvergentMigration,
onCancelConvergentMigration,
onResolveConvergentConflict,
onDowngradeConvergent,
disconnectOtherProviders,
}) => {
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
id: string;
title: string;
configurationSchema?: unknown;
}>>([]);
const [pluginConnectBusy, setPluginConnectBusy] = useState<string | null>(null);
const [pluginConfigDialog, setPluginConfigDialog] = useState<{
providerId: string;
title: string;
configurationSchema?: unknown;
} | null>(null);
const [pluginConfigText, setPluginConfigText] = useState('{}');
const [pluginConfigError, setPluginConfigError] = useState<string | null>(null);
const [pluginConfigSaving, setPluginConfigSaving] = useState(false);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const listed = await pluginExtensionBridge.listProviders('sync');
if (cancelled) return;
setPluginSyncProviders(
(listed ?? []).map((entry) => {
const nested = (entry as {
provider?: {
id?: string;
label?: string;
configurationSchema?: unknown;
};
pluginDisplayName?: string;
}).provider;
const id = String(nested?.id ?? '');
return {
id,
title: String(
nested?.label
?? (entry as { pluginDisplayName?: string }).pluginDisplayName
?? id
?? 'Plugin sync',
),
configurationSchema: nested?.configurationSchema,
};
}).filter((entry) => entry.id.length > 0 && isPluginCloudProviderId(entry.id)),
);
} catch {
if (!cancelled) setPluginSyncProviders([]);
}
};
void refresh();
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
void refresh();
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
const openPluginConfigDialog = (
providerId: string,
title: string,
configurationSchema: unknown | undefined,
) => {
const connection = sync.providers[providerId];
// Include falsy scalars and JSON null so Edit can re-save them.
const seed = hasPluginProviderStoredConfig(connection) ? connection!.config : {};
setPluginConfigText(JSON.stringify(seed, null, 2));
setPluginConfigError(null);
setPluginConfigDialog({ providerId, title, configurationSchema });
};
const runPluginConnect = async (providerId: string, configuration: unknown): Promise<boolean> => {
setPluginConnectBusy(providerId);
try {
if (disconnectOtherProviders) {
await disconnectOtherProviders(providerId as CloudProvider);
}
const credentialPlan = planPluginSyncCredential(configuration, {
configurationSchema: pluginSyncProviders.find((entry) => entry.id === providerId)
?.configurationSchema,
});
const stored = sync.providers[providerId]?.credential;
const existingCredential =
stored
&& typeof stored === 'object'
&& stored.kind === 'secret'
&& typeof stored.id === 'string'
? stored as { kind: 'secret'; id: string; key: string }
: undefined;
if (credentialPlan.secrets.length > 0) {
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
'../../infrastructure/services/adapters/pluginSyncIpcHost'
);
// Put secrets before connect; roll back just-created keys if connect fails
// so a rejected password/token is not left readable in plugin_secrets.
// Overwrites restore the previous plaintext from the host stash.
await storePluginSyncSecretsThenConnect({
providerId,
secrets: credentialPlan.secrets.map((secret) => ({
secretKey: secret.secretKey,
value: secret.value,
})),
putSecret: putPluginSyncSecret,
deleteSecrets: deletePluginSyncSecrets,
restoreSecrets: restorePluginSyncSecrets,
connect: async (credential) => {
await sync.connectPluginProvider(
providerId,
credentialPlan.configuration,
credential,
);
},
});
} else {
await sync.connectPluginProvider(
providerId,
credentialPlan.configuration,
existingCredential,
);
}
toast.success(t('cloudSync.connect.plugin.success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message, t('cloudSync.connect.plugin.failedTitle'));
return false;
} finally {
setPluginConnectBusy(null);
}
};
const handlePluginConnect = async (providerId: string) => {
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
const connection = sync.providers[providerId];
// Config may be a valid falsy scalar or JSON null — property presence, not truthiness.
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
const plan = planPluginSyncConnect({
configurationSchema: listed?.configurationSchema,
storedConfig: connection?.config,
hasStoredConfig,
});
if (plan.action === 'prompt') {
openPluginConfigDialog(
providerId,
listed?.title ?? providerId,
listed?.configurationSchema,
);
return;
}
await runPluginConnect(providerId, plan.configuration);
};
const handleSavePluginConfig = async () => {
if (!pluginConfigDialog) return;
let configuration: unknown;
try {
configuration = JSON.parse(pluginConfigText) as unknown;
} catch {
setPluginConfigError(t('cloudSync.pluginConfig.invalidJson'));
return;
}
if (pluginConfigDialog.configurationSchema !== undefined) {
const connection = sync.providers[pluginConfigDialog.providerId];
const hasStoredSecret = connection?.credential != null
&& typeof connection.credential === 'object'
&& (connection.credential as { kind?: string }).kind === 'secret';
// Edit seeds stripped config; required secret fields stay satisfied by the
// durable SecretRef until the user re-enters plaintext secrets.
const schema = hasStoredSecret
? syncConfigurationSchemaWithoutSecretRequirements(pluginConfigDialog.configurationSchema)
: pluginConfigDialog.configurationSchema;
if (!pluginConfigurationMatchesSchema(schema, configuration)) {
setPluginConfigError(t('cloudSync.pluginConfig.schemaInvalid'));
return;
}
}
setPluginConfigError(null);
setPluginConfigSaving(true);
try {
const ok = await runPluginConnect(pluginConfigDialog.providerId, configuration);
if (ok) setPluginConfigDialog(null);
// On failure toast already shown; keep dialog open for correction.
} finally {
setPluginConfigSaving(false);
}
};
const pluginProviderIds = new Set<string>([
...pluginSyncProviders.map((entry) => entry.id),
...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)),
]);
return (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="space-y-3">
<SettingsAnchor anchorId="sync-providers" className="space-y-3">
<ProviderCard
provider="github"
name="GitHub Gist"
icon={<Github size={24} />}
isConnected={isProviderReadyForSync(sync.providers.github)}
isSyncing={sync.providers.github.status === 'syncing'}
isConnecting={sync.providers.github.status === 'connecting'}
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={isConnectDisabled('github')}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
extraActions={
isProviderReadyForSync(sync.providers.github) ? (
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
<History size={14} />
{t('cloudSync.revisionHistory.viewButton')}
</Button>
) : undefined
}
/>
<ProviderCard
provider="google"
name="Google Drive"
icon={<GoogleDriveIcon className="w-6 h-6" />}
isConnected={isProviderReadyForSync(sync.providers.google)}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={
sync.providers.google.status === 'connecting' ||
sync.pendingBrowserAuthProvider === 'google'
}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={isConnectDisabled('google')}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
<ProviderCard
provider="onedrive"
name="Microsoft OneDrive"
icon={<OneDriveIcon className="w-6 h-6" />}
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={
sync.providers.onedrive.status === 'connecting' ||
sync.pendingBrowserAuthProvider === 'onedrive'
}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={
sync.providers.onedrive.error
? cleanOneDriveErrorMessage(sync.providers.onedrive.error)
: undefined
}
disabled={isConnectDisabled('onedrive')}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>
<ProviderCard
provider="webdav"
name={t('cloudSync.provider.webdav')}
icon={<Server size={24} />}
isConnected={isProviderReadyForSync(sync.providers.webdav)}
isSyncing={sync.providers.webdav.status === 'syncing'}
isConnecting={sync.providers.webdav.status === 'connecting'}
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={isConnectDisabled('webdav')}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
onSync={() => handleSync('webdav')}
/>
<ProviderCard
provider="s3"
name={t('cloudSync.provider.s3')}
icon={<Database size={24} />}
isConnected={isProviderReadyForSync(sync.providers.s3)}
isSyncing={sync.providers.s3.status === 'syncing'}
isConnecting={sync.providers.s3.status === 'connecting'}
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={isConnectDisabled('s3')}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}
onSync={() => handleSync('s3')}
/>
{[...pluginProviderIds].sort().map((providerId) => {
const connection = sync.providers[providerId];
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
const title = listed?.title ?? providerId;
const connected = connection ? isProviderReadyForSync(connection) : false;
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
const needsConfig = planPluginSyncConnect({
configurationSchema: listed?.configurationSchema,
storedConfig: connection?.config,
hasStoredConfig,
}).action === 'prompt';
return (
<ProviderCard
key={providerId}
provider={providerId as CloudProvider}
name={title}
icon={<Plug size={24} />}
isConnected={connected}
isSyncing={connection?.status === 'syncing'}
isConnecting={
connection?.status === 'connecting'
|| pluginConnectBusy === providerId
}
account={connection?.account
? {
// Never load plugin-supplied avatar URLs in the main
// renderer (offline plugin network boundary).
id: connection.account.id,
name: connection.account.name,
email: connection.account.email,
}
: undefined}
lastSync={connection?.lastSync}
error={connection?.error}
disabled={isConnectDisabled(providerId as CloudProvider)}
onEdit={
connected || needsConfig || hasStoredConfig
? () => openPluginConfigDialog(
providerId,
title,
listed?.configurationSchema,
)
: undefined
}
onConnect={() => {
void handlePluginConnect(providerId);
}}
onDisconnect={() => sync.disconnectProvider(providerId as CloudProvider)}
onSync={() => handleSync(providerId as CloudProvider)}
/>
);
})}
<Dialog
open={pluginConfigDialog != null}
onOpenChange={(open) => {
if (!open && !pluginConfigSaving) setPluginConfigDialog(null);
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>
{t('cloudSync.pluginConfig.title', {
name: pluginConfigDialog?.title ?? '',
})}
</DialogTitle>
<DialogDescription>
{t('cloudSync.pluginConfig.desc')}
</DialogDescription>
</DialogHeader>
<textarea
value={pluginConfigText}
onChange={(event) => {
setPluginConfigText(event.target.value);
if (pluginConfigError) setPluginConfigError(null);
}}
spellCheck={false}
disabled={pluginConfigSaving}
className="min-h-40 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('cloudSync.pluginConfig.label')}
/>
{pluginConfigError ? (
<p role="alert" className="text-xs text-destructive">{pluginConfigError}</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pluginConfigSaving}
onClick={() => setPluginConfigDialog(null)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
disabled={pluginConfigSaving}
onClick={() => { void handleSavePluginConfig(); }}
>
{pluginConfigSaving
? t('cloudSync.provider.connecting')
: t('cloudSync.provider.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsAnchor>
</TabsContent>
<TabsContent value="status" className="space-y-4">
<ConvergentSyncPanel
t={t}
resolvedLocale={resolvedLocale}
config={convergentConfig}
preview={convergentPreview}
busy={convergentBusy}
error={convergentError}
conflicts={convergentConflicts}
onToggle={onToggleConvergent}
onConfirmMigration={onConfirmConvergentMigration}
onCancelMigration={onCancelConvergentMigration}
onResolveConflict={onResolveConvergentConflict}
onDowngrade={onDowngradeConvergent}
/>
<SettingsAnchor anchorId="sync-auto-sync" className="p-4 rounded-lg border bg-card">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{t('cloudSync.autoSync.title')}</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.autoSync.desc')}
</div>
</div>
<Toggle
checked={sync.autoSyncEnabled}
onChange={(enabled) => sync.setAutoSync(enabled)}
disabled={!sync.hasAnyConnectedProvider}
/>
</div>
</SettingsAnchor>
<SettingsAnchor anchorId="sync-strategy" className="p-4 rounded-lg border bg-card space-y-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.strategy.title')}</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.strategy.desc')}
</div>
</div>
<Select
value={sync.syncStrategy}
onValueChange={(value) => sync.setSyncStrategy(value as typeof sync.syncStrategy)}
>
<SelectTrigger
aria-label={t('cloudSync.strategy.title')}
className="h-10"
>
{sync.syncStrategy === 'preferCloud'
? t('cloudSync.strategy.preferCloud')
: sync.syncStrategy === 'preferLocal'
? t('cloudSync.strategy.preferLocal')
: t('cloudSync.strategy.smartMerge')}
</SelectTrigger>
<SelectContent className="max-w-[min(520px,var(--radix-select-trigger-width))]">
<SelectItem value="smartMerge" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.smartMerge')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.smartMergeDesc')}
</div>
</div>
</SelectItem>
<SelectItem value="preferCloud" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.preferCloud')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.preferCloudDesc')}
</div>
</div>
</SelectItem>
<SelectItem value="preferLocal" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.preferLocal')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.preferLocalDesc')}
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
</SettingsAnchor>
{sync.hasAnyConnectedProvider && (
<div className="space-y-3">
{/* Version Info Cards */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg border bg-card">
<div className="text-xs text-muted-foreground mb-1">{t('cloudSync.status.localVersion')}</div>
<div className="text-lg font-semibold">v{sync.localVersion}</div>
<div className="text-xs text-muted-foreground">
{sync.localUpdatedAt
? new Date(sync.localUpdatedAt).toLocaleString(resolvedLocale || undefined)
: t('cloudSync.lastSync.never')}
</div>
</div>
<div className="p-3 rounded-lg border bg-card">
<div className="text-xs text-muted-foreground mb-1">{t('cloudSync.status.remoteVersion')}</div>
<div className="text-lg font-semibold">v{sync.remoteVersion}</div>
<div className="text-xs text-muted-foreground">
{sync.remoteUpdatedAt
? new Date(sync.remoteUpdatedAt).toLocaleString(resolvedLocale || undefined)
: t('cloudSync.lastSync.never')}
</div>
</div>
</div>
{/* Sync History */}
{sync.syncHistory.length > 0 && (
<div className="rounded-lg border bg-card">
<div className="px-3 py-2 border-b border-border/60">
<div className="text-sm font-medium">{t('cloudSync.history.title')}</div>
</div>
<div className="max-h-48 overflow-y-auto">
{sync.syncHistory.slice(0, 10).map((entry) => (
<div key={entry.id} className="px-3 py-2 flex items-center gap-2 border-b border-border/30 last:border-b-0">
<div className={cn(
"w-2 h-2 rounded-full shrink-0",
entry.success ? "bg-green-500" : "bg-red-500"
)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium capitalize">
{entry.action === 'upload'
? t('cloudSync.history.upload')
: entry.action === 'download'
? t('cloudSync.history.download')
: t('cloudSync.history.resolved')}
</span>
<span className="text-xs text-muted-foreground">
v{entry.localVersion}
</span>
</div>
<div className="text-[10px] text-muted-foreground truncate">
{new Date(entry.timestamp).toLocaleString(resolvedLocale || undefined)}
{entry.deviceName && ` · ${entry.deviceName}`}
</div>
</div>
{entry.error && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-red-500 truncate max-w-24 cursor-default">
{t('cloudSync.history.error')}
</span>
</TooltipTrigger>
<TooltipContent>{entry.error}</TooltipContent>
</Tooltip>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
<SettingsAnchor anchorId="sync-local-backups">
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
/>
</div>
</SettingsAnchor>
{/* Clear Local Data */}
<SettingsAnchor anchorId="sync-clear-local" className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{t('cloudSync.clearLocal.title')}</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.clearLocal.desc')}
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setShowClearLocalDialog(true)}
>
<Trash2 size={14} className="mr-1" />
{t('cloudSync.clearLocal.button')}
</Button>
</div>
</SettingsAnchor>
</TabsContent>
</Tabs>
);
};

View File

@@ -0,0 +1,940 @@
import React, { type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { toast } from '../ui/toast';
import { ConflictModal, GitHubDeviceFlowModal } from './CloudSyncControls';
type SyncController = ReturnType<typeof useCloudSync>;
type TextValues = Record<string, string | number>;
type Translate = (key: string, values?: TextValues) => string;
type StringSetter = Dispatch<SetStateAction<string>>;
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
type HistoryPreview = {
sha: string;
payload: SyncPayload;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
deviceName?: string;
version?: number;
} | null;
interface CloudSyncDialogsProps {
t: Translate;
sync: SyncController;
showGitHubModal: boolean;
gitHubUserCode: string;
gitHubVerificationUri: string;
isPollingGitHub: boolean;
activeGitHubAttemptIdRef: MutableRefObject<number | null>;
setShowGitHubModal: BooleanSetter;
setIsPollingGitHub: BooleanSetter;
endPendingConnect: (provider: CloudProvider) => void;
showConflictModal: boolean;
setShowConflictModal: BooleanSetter;
handleResolveConflict: (resolution: ConflictResolution) => Promise<void>;
showHistoryModal: boolean;
setShowHistoryModal: BooleanSetter;
historyError: string | null;
historyLoading: boolean;
historyPreview: HistoryPreview;
setHistoryPreview: Dispatch<SetStateAction<HistoryPreview>>;
historyPreviewLoading: boolean;
historyRevisions: Array<{ version: string; date: Date }>;
handlePreviewRevision: (sha: string) => Promise<void>;
handleRestoreRevision: () => Promise<void>;
showWebdavDialog: boolean;
setShowWebdavDialog: BooleanSetter;
webdavEndpoint: string;
setWebdavEndpoint: StringSetter;
webdavAuthType: WebDAVAuthType;
setWebdavAuthType: Dispatch<SetStateAction<WebDAVAuthType>>;
webdavUsername: string;
setWebdavUsername: StringSetter;
webdavPassword: string;
setWebdavPassword: StringSetter;
webdavToken: string;
setWebdavToken: StringSetter;
showWebdavSecret: boolean;
setShowWebdavSecret: BooleanSetter;
webdavAllowInsecure: boolean;
setWebdavAllowInsecure: BooleanSetter;
webdavError: string | null;
webdavErrorDetail: string | null;
isSavingWebdav: boolean;
handleSaveWebdav: () => Promise<void>;
showS3Dialog: boolean;
setShowS3Dialog: BooleanSetter;
s3Endpoint: string;
setS3Endpoint: StringSetter;
s3Region: string;
setS3Region: StringSetter;
s3Bucket: string;
setS3Bucket: StringSetter;
s3AccessKeyId: string;
setS3AccessKeyId: StringSetter;
s3SecretAccessKey: string;
setS3SecretAccessKey: StringSetter;
s3SessionToken: string;
setS3SessionToken: StringSetter;
s3Prefix: string;
setS3Prefix: StringSetter;
s3ForcePathStyle: boolean;
setS3ForcePathStyle: BooleanSetter;
s3AllowInsecure: boolean;
setS3AllowInsecure: BooleanSetter;
showS3Secret: boolean;
setShowS3Secret: BooleanSetter;
s3Error: string | null;
s3ErrorDetail: string | null;
isSavingS3: boolean;
handleSaveS3: () => Promise<void>;
showChangeKeyDialog: boolean;
setShowChangeKeyDialog: BooleanSetter;
currentMasterKey: string;
setCurrentMasterKey: StringSetter;
newMasterKey: string;
setNewMasterKey: StringSetter;
confirmNewMasterKey: string;
setConfirmNewMasterKey: StringSetter;
showMasterKey: boolean;
setShowMasterKey: BooleanSetter;
changeKeyError: string | null;
setChangeKeyError: Dispatch<SetStateAction<string | null>>;
isChangingKey: boolean;
setIsChangingKey: BooleanSetter;
showUnlockDialog: boolean;
setShowUnlockDialog: BooleanSetter;
unlockMasterKey: string;
setUnlockMasterKey: StringSetter;
showUnlockMasterKey: boolean;
setShowUnlockMasterKey: BooleanSetter;
unlockError: string | null;
setUnlockError: Dispatch<SetStateAction<string | null>>;
isUnlocking: boolean;
setIsUnlocking: BooleanSetter;
showClearLocalDialog: boolean;
setShowClearLocalDialog: BooleanSetter;
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
onClearLocalData?: () => void;
ensureSyncablePayload: (payload: SyncPayload) => boolean;
showForcePushConfirm: boolean;
setShowForcePushConfirm: BooleanSetter;
blockedFinding: Extract<ShrinkFinding, { suspicious: true }> | null;
setBlockedFinding: Dispatch<SetStateAction<Extract<ShrinkFinding, { suspicious: true }> | null>>;
}
export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
t,
sync,
showGitHubModal,
gitHubUserCode,
gitHubVerificationUri,
isPollingGitHub,
activeGitHubAttemptIdRef,
setShowGitHubModal,
setIsPollingGitHub,
endPendingConnect,
showConflictModal,
setShowConflictModal,
handleResolveConflict,
showHistoryModal,
setShowHistoryModal,
historyError,
historyLoading,
historyPreview,
setHistoryPreview,
historyPreviewLoading,
historyRevisions,
handlePreviewRevision,
handleRestoreRevision,
showWebdavDialog,
setShowWebdavDialog,
webdavEndpoint,
setWebdavEndpoint,
webdavAuthType,
setWebdavAuthType,
webdavUsername,
setWebdavUsername,
webdavPassword,
setWebdavPassword,
webdavToken,
setWebdavToken,
showWebdavSecret,
setShowWebdavSecret,
webdavAllowInsecure,
setWebdavAllowInsecure,
webdavError,
webdavErrorDetail,
isSavingWebdav,
handleSaveWebdav,
showS3Dialog,
setShowS3Dialog,
s3Endpoint,
setS3Endpoint,
s3Region,
setS3Region,
s3Bucket,
setS3Bucket,
s3AccessKeyId,
setS3AccessKeyId,
s3SecretAccessKey,
setS3SecretAccessKey,
s3SessionToken,
setS3SessionToken,
s3Prefix,
setS3Prefix,
s3ForcePathStyle,
setS3ForcePathStyle,
s3AllowInsecure,
setS3AllowInsecure,
showS3Secret,
setShowS3Secret,
s3Error,
s3ErrorDetail,
isSavingS3,
handleSaveS3,
showChangeKeyDialog,
setShowChangeKeyDialog,
currentMasterKey,
setCurrentMasterKey,
newMasterKey,
setNewMasterKey,
confirmNewMasterKey,
setConfirmNewMasterKey,
showMasterKey,
setShowMasterKey,
changeKeyError,
setChangeKeyError,
isChangingKey,
setIsChangingKey,
showUnlockDialog,
setShowUnlockDialog,
unlockMasterKey,
setUnlockMasterKey,
showUnlockMasterKey,
setShowUnlockMasterKey,
unlockError,
setUnlockError,
isUnlocking,
setIsUnlocking,
showClearLocalDialog,
setShowClearLocalDialog,
onBuildPayload,
onApplyPayload,
onApplyConvergentPayload,
onClearLocalData,
ensureSyncablePayload,
showForcePushConfirm,
setShowForcePushConfirm,
blockedFinding,
setBlockedFinding
}) => (
<>
{/* Modals */}
<GitHubDeviceFlowModal
isOpen={showGitHubModal}
userCode={gitHubUserCode}
verificationUri={gitHubVerificationUri}
isPolling={isPollingGitHub}
onClose={() => {
activeGitHubAttemptIdRef.current = null;
setShowGitHubModal(false);
setIsPollingGitHub(false);
endPendingConnect('github');
sync.cancelOAuthConnect();
}}
/>
<ConflictModal
open={showConflictModal}
conflict={sync.currentConflict}
onResolve={handleResolveConflict}
onClose={() => setShowConflictModal(false)}
/>
{/* Gist Revision History Modal (#679) */}
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History size={18} />
{t('cloudSync.revisionHistory.title')}
</DialogTitle>
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
</DialogHeader>
{historyError && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
{historyError}
</div>
)}
{historyLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : historyPreview ? (
// Preview of a selected revision
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="rounded-lg border p-4 space-y-2">
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
{historyPreview.deviceName && (
<div className="text-xs text-muted-foreground">
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
{historyPreview.version != null && ` · v${historyPreview.version}`}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
<span className="font-medium">{historyPreview.preview.hostCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
<span className="font-medium">{historyPreview.preview.keyCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.notes')}</span>
<span className="font-medium">{historyPreview.preview.noteCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
<span className="font-medium">{historyPreview.preview.identityCount}</span>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
{t('common.back')}
</Button>
<Button onClick={handleRestoreRevision} className="gap-1">
<Download size={14} />
{t('cloudSync.revisionHistory.restoreButton')}
</Button>
</DialogFooter>
</div>
) : (
// Revision list
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
{historyRevisions.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
{t('cloudSync.revisionHistory.empty')}
</div>
) : (
<div className="space-y-1 px-1">
{historyRevisions.map((rev, index) => (
<button
key={rev.version}
onClick={() => handlePreviewRevision(rev.version)}
disabled={historyPreviewLoading}
className={cn(
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
"hover:bg-muted/50 border border-transparent hover:border-border",
index === 0 && "bg-primary/5 border-primary/20",
)}
>
<div>
<div className="font-medium">
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
</div>
<div className="text-xs text-muted-foreground">
{rev.date.toLocaleString()}
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{rev.version.slice(0, 7)}
</div>
</button>
))}
</div>
)}
</div>
)}
{historyPreviewLoading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
<Loader2 size={24} className="animate-spin" />
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
<DialogHeader>
<DialogTitle>{t('cloudSync.webdav.title')}</DialogTitle>
<DialogDescription>{t('cloudSync.webdav.desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('cloudSync.webdav.endpoint')}</Label>
<Input
value={webdavEndpoint}
onChange={(e) => setWebdavEndpoint(e.target.value)}
placeholder="https://dav.example.com/remote.php/webdav/"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.webdav.authType')}</Label>
<Select value={webdavAuthType} onValueChange={(value) => setWebdavAuthType(value as WebDAVAuthType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="basic">{t('cloudSync.webdav.auth.basic')}</SelectItem>
<SelectItem value="digest">{t('cloudSync.webdav.auth.digest')}</SelectItem>
<SelectItem value="token">{t('cloudSync.webdav.auth.token')}</SelectItem>
</SelectContent>
</Select>
</div>
{webdavAuthType !== 'token' ? (
<>
<div className="space-y-2">
<Label>{t('cloudSync.webdav.username')}</Label>
<Input
value={webdavUsername}
onChange={(e) => setWebdavUsername(e.target.value)}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.webdav.password')}</Label>
<Input
type={showWebdavSecret ? 'text' : 'password'}
value={webdavPassword}
onChange={(e) => setWebdavPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
</>
) : (
<div className="space-y-2">
<Label>{t('cloudSync.webdav.token')}</Label>
<Input
type={showWebdavSecret ? 'text' : 'password'}
value={webdavToken}
onChange={(e) => setWebdavToken(e.target.value)}
/>
</div>
)}
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={showWebdavSecret}
onChange={(e) => setShowWebdavSecret(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.webdav.showSecret')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={webdavAllowInsecure}
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.webdav.allowInsecure')}
</label>
{webdavError && (
<p className="text-sm text-red-500">{webdavError}</p>
)}
{webdavErrorDetail && (
<pre className="text-xs text-red-400 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-2">
{webdavErrorDetail}
</pre>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowWebdavDialog(false)}
disabled={isSavingWebdav}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSaveWebdav}
disabled={isSavingWebdav}
className="gap-2"
>
{isSavingWebdav ? <Loader2 size={16} className="animate-spin" /> : <Cloud size={16} />}
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showS3Dialog} onOpenChange={setShowS3Dialog}>
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-y-auto z-[70]">
<DialogHeader>
<DialogTitle>{t('cloudSync.s3.title')}</DialogTitle>
<DialogDescription>{t('cloudSync.s3.desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('cloudSync.s3.endpoint')}</Label>
<Input
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="https://s3.example.com"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('cloudSync.s3.region')}</Label>
<Input
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
placeholder="us-east-1"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.s3.bucket')}</Label>
<Input
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
placeholder="netcatty-backups"
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.s3.accessKeyId')}</Label>
<Input
value={s3AccessKeyId}
onChange={(e) => setS3AccessKeyId(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.s3.secretAccessKey')}</Label>
<Input
type={showS3Secret ? 'text' : 'password'}
value={s3SecretAccessKey}
onChange={(e) => setS3SecretAccessKey(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.s3.sessionToken')}</Label>
<Input
type={showS3Secret ? 'text' : 'password'}
value={s3SessionToken}
onChange={(e) => setS3SessionToken(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.s3.prefix')}</Label>
<Input
value={s3Prefix}
onChange={(e) => setS3Prefix(e.target.value)}
placeholder="backups/netcatty"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={s3ForcePathStyle}
onChange={(e) => setS3ForcePathStyle(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.s3.forcePathStyle')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={s3AllowInsecure}
onChange={(e) => setS3AllowInsecure(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.s3.allowInsecure')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={showS3Secret}
onChange={(e) => setShowS3Secret(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.s3.showSecret')}
</label>
{s3Error && (
<p className="text-sm text-red-500">{s3Error}</p>
)}
{s3ErrorDetail && (
<pre className="text-xs text-red-400 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-2">
{s3ErrorDetail}
</pre>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowS3Dialog(false)}
disabled={isSavingS3}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSaveS3}
disabled={isSavingS3}
className="gap-2"
>
{isSavingS3 ? <Loader2 size={16} className="animate-spin" /> : <Database size={16} />}
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showChangeKeyDialog} onOpenChange={setShowChangeKeyDialog}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{t('cloudSync.changeKey.title')}</DialogTitle>
<DialogDescription>
{t('cloudSync.changeKey.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('cloudSync.changeKey.current')}</Label>
<Input
type={showMasterKey ? 'text' : 'password'}
value={currentMasterKey}
onChange={(e) => setCurrentMasterKey(e.target.value)}
placeholder={t('cloudSync.changeKey.currentPlaceholder')}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.changeKey.new')}</Label>
<Input
type={showMasterKey ? 'text' : 'password'}
value={newMasterKey}
onChange={(e) => setNewMasterKey(e.target.value)}
placeholder={t('cloudSync.changeKey.newPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('cloudSync.changeKey.confirmNew')}</Label>
<Input
type={showMasterKey ? 'text' : 'password'}
value={confirmNewMasterKey}
onChange={(e) => setConfirmNewMasterKey(e.target.value)}
placeholder={t('cloudSync.changeKey.confirmPlaceholder')}
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={showMasterKey}
onChange={(e) => setShowMasterKey(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.changeKey.showKeys')}
</label>
{changeKeyError && (
<p className="text-sm text-red-500">{changeKeyError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowChangeKeyDialog(false)}
disabled={isChangingKey}
>
{t('common.cancel')}
</Button>
<Button
onClick={async () => {
setChangeKeyError(null);
if (!currentMasterKey || !newMasterKey || !confirmNewMasterKey) {
setChangeKeyError(t('cloudSync.changeKey.fillAll'));
return;
}
if (newMasterKey.length < 8) {
setChangeKeyError(t('cloudSync.changeKey.minLength'));
return;
}
if (newMasterKey !== confirmNewMasterKey) {
setChangeKeyError(t('cloudSync.changeKey.notMatch'));
return;
}
let payloadForReencrypt: SyncPayload | null = null;
if (sync.hasAnyConnectedProvider) {
const payload = await onBuildPayload();
if (!ensureSyncablePayload(payload)) {
setChangeKeyError(t('sync.credentialsUnavailable'));
return;
}
payloadForReencrypt = payload;
}
setIsChangingKey(true);
try {
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
if (!ok) {
setChangeKeyError(t('cloudSync.changeKey.incorrectCurrent'));
return;
}
if (payloadForReencrypt) {
await sync.syncNow(payloadForReencrypt, {
applyConvergentPayload: onApplyConvergentPayload,
});
}
toast.success(t('cloudSync.changeKey.updatedToast'));
setShowChangeKeyDialog(false);
} catch (error) {
setChangeKeyError(error instanceof Error ? error.message : t('cloudSync.changeKey.failed'));
} finally {
setIsChangingKey(false);
}
}}
disabled={isChangingKey}
className="gap-2"
>
{isChangingKey ? <Loader2 size={16} className="animate-spin" /> : <Key size={16} />}
{t('cloudSync.changeKey.updateButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUnlockDialog} onOpenChange={setShowUnlockDialog}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{t('cloudSync.unlock.title')}</DialogTitle>
<DialogDescription>
{t('cloudSync.unlock.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('cloudSync.unlock.masterKey')}</Label>
<Input
type={showUnlockMasterKey ? 'text' : 'password'}
value={unlockMasterKey}
onChange={(e) => setUnlockMasterKey(e.target.value)}
placeholder={t('cloudSync.unlock.placeholder')}
autoFocus
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={showUnlockMasterKey}
onChange={(e) => setShowUnlockMasterKey(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.unlock.showKey')}
</label>
{unlockError && (
<p className="text-sm text-red-500">{unlockError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowUnlockDialog(false)}
disabled={isUnlocking}
>
{t('cloudSync.unlock.notNow')}
</Button>
<Button
onClick={async () => {
setUnlockError(null);
if (!unlockMasterKey) {
setUnlockError(t('cloudSync.unlock.empty'));
return;
}
setIsUnlocking(true);
try {
const ok = await sync.unlock(unlockMasterKey);
if (!ok) {
setUnlockError(t('cloudSync.unlock.incorrect'));
return;
}
toast.success(t('cloudSync.unlock.readyToast'));
setShowUnlockDialog(false);
setUnlockMasterKey('');
} catch (error) {
setUnlockError(error instanceof Error ? error.message : t('cloudSync.unlock.failed'));
} finally {
setIsUnlocking(false);
}
}}
disabled={isUnlocking}
className="gap-2"
>
{isUnlocking ? <Loader2 size={16} className="animate-spin" /> : <ShieldCheck size={16} />}
{t('cloudSync.unlock.unlockButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Clear Local Data Confirmation Dialog */}
<Dialog open={showClearLocalDialog} onOpenChange={setShowClearLocalDialog}>
<DialogContent className="sm:max-w-[400px] z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t('cloudSync.clearLocal.dialog.title')}
</DialogTitle>
<DialogDescription>
{t('cloudSync.clearLocal.dialog.desc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowClearLocalDialog(false)}
>
{t('cloudSync.clearLocal.dialog.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
onClearLocalData?.();
sync.resetLocalVersion();
setShowClearLocalDialog(false);
toast.success(t('cloudSync.clearLocal.toast.desc'), t('cloudSync.clearLocal.toast.title'));
}}
>
<Trash2 size={14} className="mr-1" />
{t('cloudSync.clearLocal.dialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Force-push confirmation modal (Task 8) */}
{showForcePushConfirm && blockedFinding && (
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm">
{t('sync.forcePush.body', {
lost: blockedFinding.lost,
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
})}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
{t('sync.forcePush.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const localPayload = await onBuildPayload();
if (!ensureSyncablePayload(localPayload)) {
setShowForcePushConfirm(false);
return;
}
setShowForcePushConfirm(false);
try {
const results = await sync.syncNow(localPayload, {
overrideShrink: true,
applyConvergentPayload: onApplyConvergentPayload,
});
// Apply any merged payload BEFORE clearing the banner. If a merge happened
// during force-push (remote changed), the merged result is what the cloud
// now has — applying it to local state prevents the next sync from
// re-deleting the remote additions we just merged in.
for (const result of results.values()) {
if (result.mergedPayload && !result.mergedPayloadApplied) {
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
await Promise.resolve(onApplyPayload(portableMerged));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
recordDownload: true,
});
}
break; // All providers share the same merged payload
}
}
const syncResults = Array.from(results.values()) as SyncResult[];
const allOk = syncResults.every((r) => r.success);
if (allOk) {
setBlockedFinding(null);
} else {
// Surface the failure but KEEP the banner so the user can retry or
// restore. Find the first error string to display.
const firstError = syncResults
.find((r) => !r.success)
?.error ?? t('sync.toast.errorTitle');
toast.error(firstError, t('sync.toast.errorTitle'));
}
} catch (err) {
toast.error(String(err), t('sync.toast.errorTitle'));
}
}}
>
{t('sync.forcePush.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);

View File

@@ -0,0 +1,446 @@
/**
* CloudSyncSettings - End-to-End Encrypted Cloud Sync UI
*
* Handles:
* - Master key setup (gatekeeper screen)
* - Provider connections (GitHub, Google, OneDrive)
* - Sync status and conflict resolution
*/
import React, { useState, useEffect } from 'react';
import {
AlertTriangle,
Download,
FolderOpen,
Loader2,
RefreshCw,
} from 'lucide-react';
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
import {
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
} from '../../application/localVaultBackups';
import { useI18n } from '../../application/i18n/I18nProvider';
import { type SyncPayload } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { toast } from '../ui/toast';
// ============================================================================
interface LocalBackupsPanelProps {
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
/**
* When true, the panel hides the Restore button entirely — e.g. while the
* master key has not been configured yet, a restore would land credentials
* on disk in plaintext (I3). Listing is still allowed so users can see that
* their history exists.
*/
restoreDisabledReason?: 'no-master-key' | null;
}
export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
onApplyPayload,
restoreDisabledReason = null,
}) => {
const { t, resolvedLocale } = useI18n();
const {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
// users from wiping their vault with a single accidental click (I2).
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
(typeof backups)[number] | null
>(null);
useEffect(() => {
setMaxBackupsInput(String(maxBackups));
}, [maxBackups]);
const formatTimestamp = (timestamp: number) =>
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
reason === 'app_version_change'
? t('cloudSync.localBackups.reason.appVersionChange')
: t('cloudSync.localBackups.reason.beforeRestore');
const handleSaveMaxBackups = async () => {
// Validate BEFORE calling setMaxBackups, which hands off to the
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
// modes must be surfaced rather than silently clamped, because
// both produce a misleading "saved" toast:
//
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
// sanitize clamps to the default (20). A user who meant to
// clear the field then re-type would see their retention
// silently reset to 20 with a success message.
//
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
// still reports success, but the visible error string says
// "between 1 and 100", so the user has no idea their value
// was changed. Reject explicitly instead.
//
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
// in vaultBackupBridge.cjs so renderer and bridge agree.
const parsed = Number(maxBackupsInput);
const inRange =
Number.isFinite(parsed) &&
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
if (!inRange || maxBackupsInput.trim() === '') {
toast.error(
t('cloudSync.localBackups.maxInvalid'),
t('sync.toast.errorTitle'),
);
return;
}
setIsSavingMaxBackups(true);
try {
const next = await setMaxBackups(parsed);
setMaxBackupsInput(String(next));
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
} finally {
setIsSavingMaxBackups(false);
}
};
const handleOpenBackupDirectory = async () => {
try {
await openBackupDirectory();
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
}
};
const performRestore = async (backupId: string) => {
setRestoringBackupId(backupId);
try {
// Hold the cross-window restore barrier around both the load
// and the apply so another window's auto-sync cannot push a
// pre-restore snapshot concurrently. See `withRestoreBarrier`
// in application/localVaultBackups.ts for the read-side in
// useAutoSync.
//
// In-memory React state refresh is implicit: `onApplyPayload`
// (supplied by the hosting screen) routes through
// `applySyncPayload` → `importDataFromString` → store writes
// → the hook-store listeners in `useVaultState` /
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
// lists here because a future refactor that decouples those
// stores from the apply path would silently break the UI
// refresh in a way that's only visible after a manual
// restart. Any change to that chain must either preserve
// store-listener notification OR add an explicit
// `rehydrateAllFromStorage` call here — do not assume
// restore is "just" a payload swap.
await withRestoreBarrier(async () => {
const detail = await readBackup(backupId);
if (!detail) {
throw new Error(t('cloudSync.localBackups.restoreMissing'));
}
await Promise.resolve(onApplyPayload(detail.payload));
});
await refreshBackups();
toast.success(t('cloudSync.localBackups.restoreSuccess'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.localBackups.restoreFailedTitle'),
);
} finally {
setRestoringBackupId(null);
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.retentionDesc')}
</div>
</div>
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
<div className="flex items-end gap-2 md:justify-end">
<Input
type="number"
min={1}
max={100}
value={maxBackupsInput}
onChange={(e) => setMaxBackupsInput(e.target.value)}
className="w-28"
/>
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
{t('common.save')}
</Button>
</div>
</div>
</div>
</div>
{!restoreAllowed && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
<span className="font-medium">
{t('cloudSync.localBackups.lockedTitle')}
</span>
</div>
{t('cloudSync.localBackups.lockedDesc')}
</div>
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
{t('settings.system.refresh')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenBackupDirectory()}
className="gap-1"
>
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
</div>
</div>
{backups.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
{t('cloudSync.localBackups.empty')}
</div>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{backup.syncDataVersion
? `v${backup.syncDataVersion}`
: formatTimestamp(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(backup.reason)}</span>
{backup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(backup.createdAt)}</span>
</>
)}
{backup.sourceAppVersion && backup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.counts', {
hosts: String(backup.preview.hostCount),
keys: String(backup.preview.keyCount),
snippets: String(backup.preview.snippetCount),
notes: String(backup.preview.noteCount ?? 0),
})}
</div>
</div>
{restoreAllowed && (
<Button
size="sm"
variant="outline"
onClick={() => setPendingRestoreBackup(backup)}
// Disable every row while ANY restore is in
// flight. Each restore runs a full
// `applyProtectedSyncPayload` — multiple
// localStorage writes + the apply-in-progress
// sentinel. `withRestoreBarrier` serializes
// across windows but does NOT serialize
// same-window re-entry, so two overlapping
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restore')}
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
<Dialog
open={pendingRestoreBackup !== null}
onOpenChange={(open) => {
if (!open) setPendingRestoreBackup(null);
}}
>
<DialogContent className="sm:max-w-[440px] z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t('cloudSync.localBackups.restoreConfirmTitle')}
</DialogTitle>
<DialogDescription>
{t('cloudSync.localBackups.restoreConfirmDesc')}
</DialogDescription>
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="font-medium">
{pendingRestoreBackup.syncDataVersion
? `v${pendingRestoreBackup.syncDataVersion}`
: formatTimestamp(pendingRestoreBackup.createdAt)}
</div>
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
{pendingRestoreBackup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
</>
)}
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: pendingRestoreBackup.sourceAppVersion,
to: pendingRestoreBackup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
hosts: String(pendingRestoreBackup.preview.hostCount),
keys: String(pendingRestoreBackup.preview.keyCount),
snippets: String(pendingRestoreBackup.preview.snippetCount),
notes: String(pendingRestoreBackup.preview.noteCount ?? 0),
})}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingRestoreBackup;
if (!target) return;
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restoreConfirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ConvergentSyncPanel } from './ConvergentSyncPanel.tsx';
const t = (key: string, values?: Record<string, string | number>) =>
key === 'cloudSync.convergent.conflict.secretSet'
? 'SECRET_SET'
: `${key}${values?.count === undefined ? '' : `:${values.count}`}`;
test('secret conflict candidates never render their value', () => {
const markup = renderToStaticMarkup(
<ConvergentSyncPanel
t={t}
resolvedLocale="en"
config={{ enabled: true, initialized: true }}
preview={null}
busy={false}
error={null}
conflicts={[{
address: { kind: 'entity-field', collection: 'keys', entityId: 'key-1', field: 'privateKey' },
candidates: [{
dot: { deviceId: 'device-a', counter: 1 },
hlc: { wallTime: 1, logical: 0 },
tombstone: false,
value: 'PRIVATE-CONTENT-MUST-NOT-RENDER',
selected: true,
}],
}]}
onToggle={() => {}}
onConfirmMigration={() => {}}
onCancelMigration={() => {}}
onResolveConflict={() => {}}
onDowngrade={() => {}}
/>,
);
assert.equal(markup.includes('PRIVATE-CONTENT-MUST-NOT-RENDER'), false);
assert.equal(markup.includes('SECRET_SET'), true);
assert.match(
markup,
/data-testid="convergent-sync-icon"[^>]*class="[^"]*h-9[^"]*w-9[^"]*self-start/,
);
});
test('secret fields nested inside array candidates never reach rendered markup', () => {
const markup = renderToStaticMarkup(
<ConvergentSyncPanel
t={t}
resolvedLocale="en"
config={{ enabled: true, initialized: true }}
preview={null}
busy={false}
error={null}
conflicts={[{
address: { kind: 'setting', path: ['ai', 'providers'] },
candidates: [{
dot: { deviceId: 'device-a', counter: 1 },
hlc: { wallTime: 1, logical: 0 },
tombstone: false,
value: [{
id: 'provider-1',
name: 'Private provider',
credentials: { apiKey: 'NESTED-API-KEY-MUST-NOT-RENDER' },
}],
selected: true,
}],
}]}
onToggle={() => {}}
onConfirmMigration={() => {}}
onCancelMigration={() => {}}
onResolveConflict={() => {}}
onDowngrade={() => {}}
/>,
);
assert.equal(markup.includes('NESTED-API-KEY-MUST-NOT-RENDER'), false);
assert.equal(markup.includes('SECRET_SET'), true);
});

View File

@@ -0,0 +1,257 @@
import React from 'react';
import { AlertTriangle, FlaskConical, RotateCcw, ShieldCheck } from 'lucide-react';
import type {
ConvergentFieldConflict,
ConvergentMigrationPreview,
} from '../../domain/sync';
import {
convergentConflictAddressKey,
dotKey,
isConvergentConflictSecret,
} from '../../domain/convergentSync';
import { Button } from '../ui/button';
type Translate = (key: string, values?: Record<string, string | number>) => string;
const ConvergentToggle: React.FC<{
checked: boolean;
disabled: boolean;
label: string;
onChange: (checked: boolean) => void | Promise<void>;
}> = ({ checked, disabled, label, onChange }) => (
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 transition-colors ${
checked ? 'border-amber-500 bg-amber-500' : 'border-border bg-muted'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<span
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
);
function conflictLabel(conflict: ConvergentFieldConflict, t: Translate): string {
const { address } = conflict;
switch (address.kind) {
case 'entity-presence':
return `${address.collection}/${address.entityId} · ${t('cloudSync.convergent.field.presence')}`;
case 'entity-position':
return `${address.collection}/${address.entityId} · ${t('cloudSync.convergent.field.position')}`;
case 'entity-field':
return `${address.collection}/${address.entityId} · ${address.field}`;
case 'setting':
return `settings.${address.path.join('.')}`;
case 'setting-structure':
return address.paths.map((path) => `settings.${path.join('.')}`).join(' ↔ ');
case 'string-entry-presence':
return `${address.collection}/${address.value} · ${t('cloudSync.convergent.field.presence')}`;
case 'string-entry-position':
return `${address.collection}/${address.value} · ${t('cloudSync.convergent.field.position')}`;
}
}
function candidateValue(
conflict: ConvergentFieldConflict,
candidate: ConvergentFieldConflict['candidates'][number],
t: Translate,
): string {
if (candidate.tombstone) return t('cloudSync.convergent.conflict.empty');
if (isConvergentConflictSecret(conflict)) {
return candidate.value == null
? t('cloudSync.convergent.conflict.empty')
: t('cloudSync.convergent.conflict.secretSet');
}
const encoded = JSON.stringify(candidate.value);
if (!encoded) return t('cloudSync.convergent.conflict.empty');
return encoded.length > 180 ? `${encoded.slice(0, 177)}...` : encoded;
}
export interface ConvergentSyncPanelProps {
t: Translate;
resolvedLocale: string | null;
config: { enabled: boolean; initialized: boolean };
preview: ConvergentMigrationPreview | null;
busy: boolean;
error: string | null;
conflicts: ConvergentFieldConflict[];
onToggle: (enabled: boolean) => void | Promise<void>;
onConfirmMigration: () => void | Promise<void>;
onCancelMigration: () => void;
onResolveConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
onDowngrade: () => void | Promise<void>;
}
export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
t,
resolvedLocale,
config,
preview,
busy,
error,
conflicts,
onToggle,
onConfirmMigration,
onCancelMigration,
onResolveConflict,
onDowngrade,
}) => (
<div className="space-y-4 overflow-hidden rounded-xl border border-amber-500/25 bg-gradient-to-br from-amber-500/[0.07] via-card to-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div
data-testid="convergent-sync-icon"
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
<FlaskConical size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{t('cloudSync.convergent.title')}</span>
<span className="inline-flex rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
{t('cloudSync.convergent.experimental')}
</span>
</div>
<div className="mt-1 max-w-3xl text-xs leading-relaxed text-muted-foreground">
{t('cloudSync.convergent.desc')}
</div>
</div>
</div>
<ConvergentToggle
checked={config.enabled}
onChange={onToggle}
disabled={busy}
label={t('cloudSync.convergent.title')}
/>
</div>
{config.initialized && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck size={14} className="text-green-500" />
{config.enabled
? t('cloudSync.convergent.active')
: t('cloudSync.convergent.paused')}
</div>
)}
{preview && (
<div className="space-y-3 rounded-lg border border-border/70 bg-background/65 p-3.5 shadow-sm">
<div className="text-sm font-semibold">{t('cloudSync.convergent.preview.title')}</div>
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="text-lg font-semibold tabular-nums">
{Object.values(preview.entityCounts).reduce((sum, count) => sum + (count ?? 0), 0)}
</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.entities')}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="text-lg font-semibold tabular-nums">{preview.providers.length}</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.providers')}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="text-lg font-semibold tabular-nums">{preview.conflictCount}</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.conflicts')}</div>
</div>
</div>
<div className="rounded-md bg-muted/25 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
{t('cloudSync.convergent.preview.compatibility')}
</div>
{preview.providers.length > 0 && (
<div className="divide-y rounded border text-xs">
{preview.providers.map((provider) => (
<div key={provider.provider} className="flex items-start justify-between gap-3 px-2 py-1.5">
<span className="font-medium">{provider.provider}</span>
<span className="text-right text-muted-foreground">
{t(`cloudSync.convergent.preview.status.${provider.status}`)} · {t('cloudSync.convergent.preview.schema')} {provider.schemaVersion}
{provider.message ? ` · ${provider.message}` : ''}
</span>
</div>
))}
</div>
)}
{preview.blockedReasons.length > 0 && (
<div className="rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
{preview.blockedReasons.join(' · ')}
</div>
)}
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
<Button variant="ghost" size="sm" onClick={onCancelMigration} disabled={busy}>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={onConfirmMigration} disabled={busy || !preview.canInitialize}>
{t('cloudSync.convergent.preview.confirm')}
</Button>
</div>
</div>
)}
{error && (
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
{config.initialized && conflicts.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('cloudSync.convergent.conflicts.title', { count: conflicts.length })}
</div>
<div className="space-y-2">
{conflicts.map((conflict) => {
const addressKey = convergentConflictAddressKey(conflict.address);
return (
<div key={addressKey} className="space-y-2 rounded-lg border border-border/70 bg-background/65 p-3 shadow-sm">
<div className="break-all font-mono text-xs">{conflictLabel(conflict, t)}</div>
<div className="space-y-1.5">
{conflict.candidates.map((candidate) => (
<div
key={dotKey(candidate.dot)}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/15 p-2.5"
>
<div className="min-w-0">
<div className="truncate text-xs font-medium">
{candidateValue(conflict, candidate, t)}
</div>
<div className="text-[10px] text-muted-foreground">
{candidate.dot.deviceId} · {new Date(candidate.hlc.wallTime).toLocaleString(resolvedLocale || undefined)}
{candidate.selected ? ` · ${t('cloudSync.convergent.conflict.current')}` : ''}
</div>
</div>
<Button
size="sm"
variant={candidate.selected ? 'secondary' : 'outline'}
disabled={busy}
onClick={() => onResolveConflict(addressKey, dotKey(candidate.dot))}
>
{t('cloudSync.convergent.conflict.choose')}
</Button>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{config.initialized && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
<div className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">{t('cloudSync.convergent.downgrade.desc')}</div>
<Button variant="ghost" size="sm" className="text-destructive" onClick={onDowngrade} disabled={busy}>
<RotateCcw size={14} className="mr-1" />
{t('cloudSync.convergent.downgrade.button')}
</Button>
</div>
)}
</div>
);