Files
NetMesh/components/cloud-sync/CloudSyncDashboardTabs.tsx
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

692 lines
35 KiB
TypeScript

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>
);
};