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; type Translate = (key: string, values?: Record) => string; interface CloudSyncDashboardTabsProps { activeTab: 'providers' | 'status'; setActiveTab: Dispatch>; t: Translate; sync: SyncController; resolvedLocale: string | null; localBackupsRef: RefObject; isConnectDisabled: (provider: CloudProvider) => boolean; handleConnectGitHub: () => Promise; handleConnectGoogle: () => Promise; handleConnectOneDrive: () => Promise; openWebdavDialog: () => void; openS3Dialog: () => void; handleOpenHistory: () => Promise; handleSync: (provider: CloudProvider) => Promise; onApplyPayload: (payload: SyncPayload) => void | Promise; onApplyLocalPayload?: (payload: SyncPayload) => void | Promise; setShowClearLocalDialog: Dispatch>; convergentConfig: { enabled: boolean; initialized: boolean }; convergentPreview: ConvergentMigrationPreview | null; convergentBusy: boolean; convergentError: string | null; convergentConflicts: ConvergentFieldConflict[]; onToggleConvergent: (enabled: boolean) => void | Promise; onConfirmConvergentMigration: () => void | Promise; onCancelConvergentMigration: () => void; onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise; onDowngradeConvergent: () => void | Promise; /** Disconnect other legacy providers before connecting a plugin backend. */ disconnectOtherProviders?: (current: CloudProvider) => Promise; } export const CloudSyncDashboardTabs: React.FC = ({ 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>([]); const [pluginConnectBusy, setPluginConnectBusy] = useState(null); const [pluginConfigDialog, setPluginConfigDialog] = useState<{ providerId: string; title: string; configurationSchema?: unknown; } | null>(null); const [pluginConfigText, setPluginConfigText] = useState('{}'); const [pluginConfigError, setPluginConfigError] = useState(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 => { 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([ ...pluginSyncProviders.map((entry) => entry.id), ...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)), ]); return ( setActiveTab(v as 'providers' | 'status')} className="space-y-4"> {t('cloudSync.providers.title')} {t('cloudSync.status.title')} } 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) ? ( ) : undefined } /> } 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')} /> } 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')} /> } 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')} /> } 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 ( } 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)} /> ); })} { if (!open && !pluginConfigSaving) setPluginConfigDialog(null); }} > {t('cloudSync.pluginConfig.title', { name: pluginConfigDialog?.title ?? '', })} {t('cloudSync.pluginConfig.desc')}