Files
NetMesh/components/SyncStatusButton.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

429 lines
20 KiB
TypeScript

/**
* SyncStatusButton - Cloud Sync Status Indicator for Top Bar
*
* Shows current sync state with cloud icon and colored indicators:
* - Green dot: Local changes pending upload
* - Blue dot + spin: Syncing in progress
* - Red dot: Error
* - Gray dot: No providers connected
*
* Clicking opens a popover with sync status details and history.
*/
import React, { useState } from 'react';
import {
Cloud,
CloudOff,
Github,
Loader2,
Plug,
RefreshCw,
Settings,
X,
ArrowUp,
ArrowDown,
Database,
Server,
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from './ui/popover';
import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================
// Provider Icons
// ============================================================================
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>
);
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>
);
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
github: <Github size={16} />,
google: <GoogleDriveIcon className="w-4 h-4" />,
onedrive: <OneDriveIcon className="w-4 h-4" />,
webdav: <Server size={16} />,
s3: <Database size={16} />,
};
const providerNames: Partial<Record<CloudProvider, string>> = {
github: 'GitHub Gist',
google: 'Google Drive',
onedrive: 'OneDrive',
webdav: 'WebDAV',
s3: 'S3 Compatible',
};
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
providerIcons[provider] ?? <Plug size={16} />;
const resolveProviderName = (provider: CloudProvider): string =>
providerNames[provider] ?? provider;
// ============================================================================
// Status Dot Component
// ============================================================================
interface StatusIndicatorProps {
status: 'synced' | 'syncing' | 'error' | 'none';
size?: 'sm' | 'md';
className?: string;
}
const StatusIndicator: React.FC<StatusIndicatorProps> = ({ status, size = 'sm', className }) => {
const sizeClass = size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5';
const baseClasses = cn(
'rounded-full',
sizeClass,
status === 'syncing' && 'animate-pulse',
className
);
const colors = {
synced: 'bg-green-500',
syncing: 'bg-blue-500',
error: 'bg-red-500',
none: 'bg-muted-foreground/30',
};
return <span className={cn(baseClasses, colors[status])} />;
};
// ============================================================================
// Main SyncStatusButton Component
// ============================================================================
interface SyncStatusButtonProps {
onOpenSettings?: () => void;
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
className?: string;
style?: React.CSSProperties;
}
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
onOpenSettings,
onSyncNow,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [isSyncingManual, setIsSyncingManual] = useState(false);
const sync = useCloudSync();
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
// Get connected provider (built-ins first, then any namespaced plugin provider).
const getConnectedProvider = (): CloudProvider | null => {
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const id of preferred) {
const conn = sync.providers[id];
if (conn && isProviderReadyForSync(conn)) return id;
}
for (const [id, conn] of Object.entries(sync.providers)) {
if (preferred.includes(id as CloudProvider)) continue;
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
}
return null;
};
const connectedProvider = getConnectedProvider();
const providerConnection = connectedProvider ? sync.providers[connectedProvider] : null;
const hasVersionMismatch = sync.hasAnyConnectedProvider
&& sync.localVersion !== sync.remoteVersion;
const hasPendingSync = sync.pendingLocalSync || hasVersionMismatch;
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (
sync.overallSyncStatus === 'error' ||
sync.overallSyncStatus === 'conflict' ||
sync.overallSyncStatus === 'blocked'
) {
return 'error';
}
if (hasPendingSync) return 'synced';
return 'none';
};
const overallStatus = getOverallStatus();
// Get the button icon based on state
const getButtonIcon = () => {
if (sync.isSyncing) return <Loader2 size={16} className="animate-spin" />;
if (sync.hasAnyConnectedProvider) return <Cloud size={16} />;
return <CloudOff size={16} />;
};
const formatTime = (timestamp?: number): string => {
if (!timestamp) return t('time.never');
const diff = Date.now() - timestamp;
if (diff < 60000) return t('time.justNow');
if (diff < 3600000) return t('time.minutesAgo', { minutes: Math.floor(diff / 60000) });
return formatSyncDateTime(timestamp);
};
// Create a unique key based on sync state to force re-render
const syncStateKey = `${sync.localVersion}-${sync.remoteVersion}-${sync.syncHistory.length}`;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 relative app-no-drag top-tab-utility-btn",
className
)}
style={style}
>
{getButtonIcon()}
{overallStatus !== 'none' && (
<StatusIndicator
status={overallStatus}
size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background"
/>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
</Tooltip>
<PopoverContent
key={syncStateKey}
className="w-80 p-0"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="px-3 py-2.5 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{overallStatus === 'synced' && hasPendingSync && (
<Cloud size={16} className="text-green-500" />
)}
{overallStatus === 'syncing' && (
<Loader2 size={16} className="text-blue-500 animate-spin" />
)}
{overallStatus === 'error' && (
<Cloud size={16} className="text-red-500" />
)}
{overallStatus === 'none' && sync.hasAnyConnectedProvider && (
<Cloud size={16} className="text-muted-foreground" />
)}
{overallStatus === 'none' && !sync.hasAnyConnectedProvider && (
<CloudOff size={16} className="text-muted-foreground" />
)}
<span className="text-sm font-medium">
{overallStatus === 'synced' && hasPendingSync && t('sync.pending')}
{overallStatus === 'syncing' && t('sync.syncing')}
{overallStatus === 'error' && t('sync.error')}
{overallStatus === 'none' && sync.hasAnyConnectedProvider && t('sync.active')}
{overallStatus === 'none' && !sync.hasAnyConnectedProvider && t('sync.notConfigured')}
</span>
</div>
{onOpenSettings && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setIsOpen(false);
onOpenSettings();
}}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Settings size={14} className="text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>{t('sync.settings')}</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Content based on state */}
<div className="p-3">
{!sync.hasAnyConnectedProvider ? (
// No provider connected
<div className="text-center py-4">
<CloudOff size={32} className="mx-auto mb-2 text-muted-foreground" />
<p className="text-sm font-medium mb-1">{t('sync.notConfigured')}</p>
<p className="text-xs text-muted-foreground mb-3">
{t('sync.autoSync.noProvider')}
</p>
<Button
size="sm"
className="w-full"
onClick={() => {
setIsOpen(false);
onOpenSettings?.();
}}
>
{t('sync.settings')}
</Button>
</div>
) : (
// Provider connected - show details
<div className="space-y-3">
{/* Connected Provider Info */}
{connectedProvider && providerConnection && (
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{resolveProviderIcon(connectedProvider)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
<StatusIndicator status={overallStatus} />
</div>
<div className="flex items-center gap-2 mt-0.5">
{providerConnection.account?.avatarUrl
&& connectedProvider
&& !isPluginCloudProviderId(connectedProvider) && (
<img
src={providerConnection.account.avatarUrl}
alt=""
className="w-4 h-4 rounded-full"
referrerPolicy="no-referrer"
/>
)}
<span className="text-xs text-muted-foreground truncate">
{providerConnection.account?.name ||
providerConnection.account?.email ||
t('sync.connected')}
</span>
</div>
</div>
</div>
)}
{/* Version Info */}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase mb-0.5">
{t('cloudSync.conflict.local')}
</div>
<div className="text-sm font-medium">v{sync.localVersion}</div>
<div className="text-[10px] text-muted-foreground">
{formatTime(sync.localUpdatedAt)}
</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase mb-0.5">
{t('cloudSync.conflict.cloud')}
</div>
<div className="text-sm font-medium">v{sync.remoteVersion}</div>
<div className="text-[10px] text-muted-foreground">
{formatTime(sync.remoteUpdatedAt)}
</div>
</div>
</div>
{/* Recent Sync History */}
{sync.syncHistory.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1.5">
{t('sync.recentActivity')}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{sync.syncHistory.slice(0, 5).map((entry) => (
<div key={entry.id} className="flex items-center gap-2 text-xs py-1">
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
entry.success ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"
)}>
{entry.success ? (
entry.action === 'upload' ? <ArrowUp size={10} /> : <ArrowDown size={10} />
) : (
<X size={10} />
)}
</div>
<span className="text-muted-foreground flex-1 truncate">
{entry.action === 'upload'
? t('sync.history.uploaded')
: entry.action === 'download'
? t('sync.history.downloaded')
: t('sync.history.resolved')}{' '}
v{entry.localVersion}
</span>
<span className="text-muted-foreground/60 shrink-0">
{formatTime(entry.timestamp)}
</span>
</div>
))}
</div>
</div>
)}
{/* Sync Button */}
<Button
size="sm"
variant="outline"
className="w-full gap-1"
disabled={sync.isSyncing || isSyncingManual}
onClick={async () => {
if (onSyncNow) {
setIsSyncingManual(true);
try {
await onSyncNow();
toast.success(t('sync.toast.completedMessage'), t('sync.cloudSync'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('sync.failed'),
t('sync.toast.errorTitle'),
);
} finally {
setIsSyncingManual(false);
}
} else {
setIsOpen(false);
onOpenSettings?.();
}
}}
>
{(sync.isSyncing || isSyncingManual) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
{t('sync.syncNow')}
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};
export default SyncStatusButton;