import { useState, useRef, useEffect } from 'react' import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react' import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types' import type { McpStatus } from '../hooks/useMcpStatus' import { openExternalUrl } from '../utils/url' export interface VaultOption { label: string path: string available?: boolean } interface StatusBarProps { noteCount: number modifiedCount?: number vaultPath: string vaults: VaultOption[] onSwitchVault: (path: string) => void onOpenSettings?: () => void onOpenLocalFolder?: () => void onConnectGitHub?: () => void onClickPending?: () => void hasGitHub?: boolean syncStatus?: SyncStatus lastSyncTime?: number | null conflictCount?: number lastCommitInfo?: LastCommitInfo | null remoteStatus?: GitRemoteStatus | null onTriggerSync?: () => void onPullAndPush?: () => void onOpenConflictResolver?: () => void zoomLevel?: number onZoomReset?: () => void buildNumber?: string onCheckForUpdates?: () => void onRemoveVault?: (path: string) => void mcpStatus?: McpStatus onInstallMcp?: () => void } function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) { if (isActive) return if (unavailable) return return } function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties { return { display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: unavailable ? 'not-allowed' : 'pointer', background: isActive ? 'var(--hover)' : 'transparent', opacity: unavailable ? 0.45 : 1, color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12, } } function VaultMenuItem({ vault, isActive, onSelect, onRemove, canRemove }: { vault: VaultOption; isActive: boolean; onSelect: () => void; onRemove?: () => void; canRemove?: boolean }) { const unavailable = vault.available === false const canHover = !isActive && !unavailable return (
{ e.currentTarget.style.background = 'var(--hover)' } : undefined} onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} data-testid={`vault-menu-item-${vault.label}`} > {vault.label} {canRemove && onRemove && ( { e.stopPropagation(); onRemove() }} style={{ display: 'flex', alignItems: 'center', padding: 2, borderRadius: 3, cursor: 'pointer', opacity: 0.5 }} title="Remove from list" data-testid={`vault-menu-remove-${vault.label}`} onMouseEnter={e => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.background = 'var(--hover)' }} onMouseLeave={e => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.background = 'transparent' }} > )}
) } function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConnectGitHub, hasGitHub, onRemoveVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean; onRemoveVault?: (path: string) => void }) { const [open, setOpen] = useState(false) const menuRef = useRef(null) const activeVault = vaults.find((v) => v.path === vaultPath) useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [open]) return (
setOpen((v) => !v)} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: open ? 'var(--hover)' : 'transparent' }} title="Switch vault"> {activeVault?.label ?? 'Vault'} {open && (
{vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} onRemove={() => { onRemoveVault?.(v.path); setOpen(false) }} canRemove={!!onRemoveVault && vaults.length > 1} />)}
{onOpenLocalFolder && (
{ onOpenLocalFolder(); setOpen(false) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer', background: 'transparent', color: 'var(--muted-foreground)', fontSize: 12, }} onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} data-testid="vault-menu-open-local" > Open local folder
)} {onConnectGitHub && (
{ onConnectGitHub(); setOpen(false) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer', background: 'transparent', color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12, }} onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} data-testid="vault-menu-connect-github" > Connect GitHub repo
)}
)}
) } const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const const SEP_STYLE = { color: 'var(--border)' } as const const SYNC_ICON_MAP: Record = { syncing: Loader2, conflict: AlertTriangle, pull_required: ArrowDown } const SYNC_LABELS: Record = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' } const SYNC_COLORS: Record = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)', pull_required: 'var(--accent-orange)' } function formatElapsedSync(lastSyncTime: number | null): string { if (!lastSyncTime) return 'Not synced' const secs = Math.round((Date.now() - lastSyncTime) / 1000) return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago` } function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string { return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime) } function syncIconColor(status: SyncStatus): string { return SYNC_COLORS[status] ?? 'var(--accent-green)' } function CommitBadge({ info }: { info: LastCommitInfo }) { if (info.commitUrl) { return ( openExternalUrl(info.commitUrl!)} style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }} title={`Open commit ${info.shortHash} on GitHub`} data-testid="status-commit-link" onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }} onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }} > {info.shortHash} ) } return ( {info.shortHash} ) } function syncBadgeTitle(status: SyncStatus): string { if (status === 'conflict') return 'Click to resolve conflicts' if (status === 'syncing') return 'Syncing…' if (status === 'pull_required') return 'Click to pull from remote and push' return 'Click to sync now' } function SyncBadge({ status, lastSyncTime, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; remoteStatus?: GitRemoteStatus | null; onTriggerSync?: () => void; onPullAndPush?: () => void; onOpenConflictResolver?: () => void }) { const [showPopup, setShowPopup] = useState(false) const popupRef = useRef(null) const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw const isSyncing = status === 'syncing' const isConflict = status === 'conflict' const isPullRequired = status === 'pull_required' const handleClick = () => { if (isConflict) { onOpenConflictResolver?.(); return } if (isPullRequired) { onPullAndPush?.(); return } setShowPopup(v => !v) } useEffect(() => { if (!showPopup) return const handleOutside = (e: MouseEvent) => { if (popupRef.current && !popupRef.current.contains(e.target as Node)) setShowPopup(false) } document.addEventListener('mousedown', handleOutside) return () => document.removeEventListener('mousedown', handleOutside) }, [showPopup]) return (
{formatSyncLabel(status, lastSyncTime)} {showPopup && ( setShowPopup(false)} /> )}
) } function GitStatusPopup({ status, remoteStatus, onPull, onClose }: { status: SyncStatus; remoteStatus: GitRemoteStatus | null; onPull?: () => void; onClose: () => void }) { const branch = remoteStatus?.branch || '—' const ahead = remoteStatus?.ahead ?? 0 const behind = remoteStatus?.behind ?? 0 const hasRemote = remoteStatus?.hasRemote ?? false return (
{branch}
{hasRemote && (
{ahead > 0 && 1 ? 's' : ''} ahead of remote`}>↑ {ahead} ahead} {behind > 0 && 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}>↓ {behind} behind} {ahead === 0 && behind === 0 && In sync with remote}
)} {!hasRemote && (
No remote configured
)}
Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status}
{hasRemote && (
)}
) } function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) { if (count <= 0) return null return ( <> | { e.currentTarget.style.background = 'var(--hover)' } : undefined} onMouseLeave={onClick ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} data-testid="status-conflict-count" > {count} conflict{count > 1 ? 's' : ''} ) } function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) { if (count <= 0) return null return ( <> | { e.currentTarget.style.background = 'var(--hover)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} data-testid="status-modified-count" >{count} pending ) } const MCP_TOOLTIPS: Record = { not_installed: 'MCP server not installed — click to install', no_claude_cli: 'Claude CLI not found — install it first', } function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () => void }) { if (status === 'installed' || status === 'checking') return null const tooltip = MCP_TOOLTIPS[status] ?? 'MCP status unknown' const clickable = status === 'not_installed' && !!onInstall return ( <> | { e.currentTarget.style.background = 'var(--hover)' } : undefined} onMouseLeave={clickable ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} > MCP ) } export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) { const [, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 30_000) return () => clearInterval(id) }, []) return ( ) }