Files
tolaria/src/components/StatusBar.tsx
lucaronin 1dccc0239c feat: reindex vault command, indexing status bar, sync-triggered reindex
- Add "Reindex Vault" command to command palette and menu bar
- Show "Indexed Xm ago" in status bar when idle, clickable to reindex
- After git pull with updates, auto-trigger incremental reindex
- Add lastIndexedTime state to useIndexing, populated from backend metadata
- Add triggerFullReindex to useIndexing (retryIndexing is now an alias)
- Add onSyncUpdated callback to useAutoSync
- Extract formatIndexedElapsed to utils/indexingHelpers.ts
- Tests: 20 new unit tests across 5 files, 2 Playwright smoke tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:14:12 +01:00

416 lines
19 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
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
onTriggerSync?: () => void
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
lastIndexedTime?: number | null
onRetryIndexing?: () => void
onReindexVault?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
if (isActive) return <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
return <span style={{ width: 12 }} />
}
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 (
<div
role="button"
onClick={unavailable ? undefined : onSelect}
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
{canRemove && onRemove && (
<span
role="button"
onClick={(e) => { 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' }}
>
<X size={10} />
</span>
)}
</div>
)
}
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<HTMLDivElement>(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 (
<div ref={menuRef} style={{ position: 'relative' }}>
<span role="button" onClick={() => 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">
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</span>
{open && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} onRemove={() => { onRemoveVault?.(v.path); setOpen(false) }} canRemove={!!onRemoveVault && vaults.length > 1} />)}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
{onOpenLocalFolder && (
<div
role="button"
onClick={() => { 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"
>
<FolderOpen size={12} />
Open local folder
</div>
)}
{onConnectGitHub && (
<div
role="button"
onClick={() => { 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"
>
<Github size={12} />
Connect GitHub repo
</div>
)}
</div>
)}
</div>
)
}
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<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
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 (
<span
role="button"
onClick={() => 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)' }}
>
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
return (
<span style={ICON_STYLE} data-testid="status-commit-hash">
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
const isSyncing = status === 'syncing'
const isConflict = status === 'conflict'
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
return (
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
)
}
function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)', cursor: onClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Resolve merge conflicts"
onMouseEnter={onClick ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid="status-conflict-count"
>
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
</span>
</>
)
}
const INDEXING_LABELS: Record<string, string> = {
installing: 'Installing search…',
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index failed — retry',
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
// When idle, show "Indexed Xm ago" if we have a timestamp
if (isIdle) {
if (!lastIndexedTime) return null
const elapsed = formatIndexedElapsed(lastIndexedTime)
if (!elapsed) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={onReindex ? 'button' : undefined}
onClick={onReindex}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={onReindex ? 'Click to reindex vault' : undefined}
data-testid="status-indexed-time"
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Search size={13} />{elapsed}
</span>
</>
)
}
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={isError && onRetry ? 'button' : undefined}
onClick={isError && onRetry ? onRetry : undefined}
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
title={isError ? 'Click to retry indexing' : undefined}
data-testid="status-indexing"
>
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
}
{displayText}
</span>
</>
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
</>
)
}
const MCP_TOOLTIPS: Record<string, string> = {
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 (
<>
<span style={SEP_STYLE}>|</span>
<span
role={clickable ? 'button' : undefined}
onClick={clickable ? onInstall : undefined}
style={{
...ICON_STYLE,
color: 'var(--accent-orange)',
cursor: clickable ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={tooltip}
data-testid="status-mcp"
onMouseEnter={clickable ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={clickable ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Cpu size={13} />MCP
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
return () => clearInterval(id)
}, [])
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onCheckForUpdates}
style={{ ...ICON_STYLE, cursor: onCheckForUpdates ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Check for updates"
data-testid="status-build-number"
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
{zoomLevel !== 100 && (
<span
role="button"
onClick={onZoomReset}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Reset zoom (⌘0)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-zoom"
>{zoomLevel}%</span>
)}
<span style={DISABLED_STYLE} title="Coming soon"><Bell size={14} /></span>
<span
role="button"
onClick={onOpenSettings}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Settings (⌘,)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Settings size={14} />
</span>
</div>
</footer>
)
}