Files
tolaria/src/components/StatusBar.tsx
Test ed549a160c fix: make commit button always visible in status bar
The commit button was inside ChangesBadge which returned null when
modifiedCount was 0, hiding the button entirely. Extract it into a
standalone CommitButton component that is always visible in the
status bar with a "Commit" text label for discoverability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:17:43 +02:00

496 lines
22 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/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
onClickPulse?: () => void
onCommitPush?: () => void
isGitVault?: boolean
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 <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, pull_required: ArrowDown }
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' }
const SYNC_COLORS: Record<string, string> = { 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 (
<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 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<HTMLDivElement>(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 (
<div ref={popupRef} style={{ position: 'relative' }}>
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={syncBadgeTitle(status)}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
{showPopup && (
<GitStatusPopup
status={status}
remoteStatus={remoteStatus ?? null}
onPull={onTriggerSync}
onClose={() => setShowPopup(false)}
/>
)}
</div>
)
}
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 (
<div
data-testid="git-status-popup"
style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: 'var(--sidebar)', border: '1px solid var(--border)',
borderRadius: 6, padding: 8, minWidth: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000, fontSize: 12, color: 'var(--foreground)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
<span style={{ fontWeight: 500 }}>{branch}</span>
</div>
{hasRemote && (
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
{ahead > 0 && <span title={`${ahead} commit${ahead > 1 ? 's' : ''} ahead of remote`}> {ahead} ahead</span>}
{behind > 0 && <span title={`${behind} commit${behind > 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}> {behind} behind</span>}
{ahead === 0 && behind === 0 && <span>In sync with remote</span>}
</div>
)}
{!hasRemote && (
<div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status}
</div>
{hasRemote && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
<button
onClick={() => { onPull?.(); onClose() }}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
background: 'transparent', border: '1px solid var(--border)', borderRadius: 4,
fontSize: 11, color: 'var(--foreground)', cursor: 'pointer',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="git-status-pull-btn"
>
<ArrowDown size={11} />Pull
</button>
</div>
)}
</div>
)
}
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>
</>
)
}
function ChangesBadge({ 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"
>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', color: '#fff', borderRadius: 9, padding: '0 5px', fontSize: 10, fontWeight: 600, minWidth: 16, lineHeight: '16px' }}>{count}</span>
Changes
</span>
</>
)
}
function CommitButton({ onClick }: { onClick?: () => void }) {
if (!onClick) 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="Commit & Push"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"
>
<GitCommitHorizontal size={13} />
Commit
</span>
</>
)
}
function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) {
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={disabled ? undefined : 'button'}
onClick={disabled ? undefined : onClick}
style={{
...ICON_STYLE,
cursor: disabled ? 'not-allowed' : 'pointer',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
opacity: disabled ? 0.4 : 1,
}}
title={disabled ? 'Pulse is only available for git-enabled vaults' : 'View pulse'}
onMouseEnter={disabled ? undefined : (e) => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={disabled ? undefined : (e) => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-pulse"
>
<Pulse size={13} />Pulse
</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, onClickPulse, onCommitPush, isGitVault = false, 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 (
<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)', position: 'relative', zIndex: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<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>
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<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>
)
}