[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
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:
691
components/cloud-sync/CloudSyncDashboardTabs.tsx
Normal file
691
components/cloud-sync/CloudSyncDashboardTabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user