"use client"; // Settings panel for the desktop shell's encrypted local search index. // // Deliberately small: the index's PRIMARY trigger is the live push connection // (see lib/mail-index-client.ts's indexOnStateChange, wired into // stores/email-store.ts's handleStateChange), so this panel is a status readout // plus a manual catch-up button - not the mechanism. // // Renders nothing at all outside the Electron shell, where the routes 404. import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { SettingsSection, SettingItem } from './settings-section'; import { isElectronShell } from '@/lib/electron-bridge'; import { useAccountStore } from '@/stores/account-store'; import { catchUpIndex, fetchIndexStats, getRetentionDays, setRetentionDays, type IndexStats, } from '@/lib/mail-index-client'; import { chainSync, fetchReplicaStatus, purgeReplica, updateRetentionPolicy, type ReplicaStatus, type RetentionPolicy, } from '@/lib/offline-replica-client'; const TYPE_LABELS: Record = { mail: 'Mail', calendar: 'Calendar', contact: 'Contacts', file: 'Files', }; export function LocalIndexSettings() { const slot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); const [stats, setStats] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); // `null` until the first probe resolves, so we don't flash a panel that then // vanishes on a non-desktop build. const [available, setAvailable] = useState(null); // `null` = keep everything. Read once on mount; the setter writes through. const [retentionDays, setRetentionDaysState] = useState(365); useEffect(() => { setRetentionDaysState(getRetentionDays()); }, []); const refreshStats = useCallback(async () => { const next = await fetchIndexStats(slot); setStats(next); setAvailable(next !== null); }, [slot]); useEffect(() => { if (!isElectronShell()) { setAvailable(false); return; } void refreshStats(); }, [refreshStats]); const handleRebuild = async () => { setBusy(true); setMessage(null); try { const result = await catchUpIndex(slot, retentionDays); if (result.unavailable) { setAvailable(false); setMessage(result.error ?? 'The encrypted index is unavailable on this system.'); return; } if (!result.ok) { setMessage(result.error ?? 'Indexing failed.'); return; } const written = Object.entries(result.written ?? {}) .map(([type, n]) => `${TYPE_LABELS[type] ?? type}: ${n}`) .join(', '); const failed = (result.errors ?? []).map((e) => `${e.contentType} (${e.message})`).join('; '); setMessage( [ written ? `Indexed ${written}.` : 'Nothing to index.', result.skipped?.length ? `Not supported: ${result.skipped.join(', ')}.` : '', failed ? `Problems: ${failed}` : '', ] .filter(Boolean) .join(' '), ); await refreshStats(); } finally { setBusy(false); } }; if (available === false || available === null) return null; const total = (stats ?? []).reduce((sum, s) => sum + s.count, 0); return ( 0 ? (stats ?? []) .map((s) => `${TYPE_LABELS[s.contentType] ?? s.contentType}: ${s.count}`) .join(' · ') : 'Nothing indexed yet.' } > {total} ); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const units = ['KB', 'MB', 'GB']; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; } const PHASE_LABELS: Record = { 'never-run': 'not started', scanning: 'downloading history', reconciling: 'rebuilding', complete: 'up to date', }; /** * Controls for the offline mail replica (lib/offline-replica/**). * * Lives inside the same panel as the search index because they share one * encrypted file, one key and one purge - presenting them as two unrelated * features would misrepresent what "delete" deletes. */ function OfflineMailSettings({ slot }: { slot: number | undefined }) { const [status, setStatus] = useState(null); const [busy, setBusy] = useState(null); const [message, setMessage] = useState(null); const refresh = useCallback(async () => { setStatus(await fetchReplicaStatus(slot)); }, [slot]); useEffect(() => { void refresh(); }, [refresh]); const savePolicy = async (patch: Partial) => { if (!status) return; const next: RetentionPolicy = { ...status.policy, ...patch }; setBusy('policy'); setMessage(null); try { const ok = await updateRetentionPolicy(next, slot); if (!ok) { setMessage('Could not save the retention setting.'); return; } // The change is applied by the next cycle - a widen re-scans, a narrow // evicts - so run one now rather than leaving the number looking wrong. await chainSync({ slot, max: 2 }); await refresh(); } finally { setBusy(null); } }; const handleSync = async () => { setBusy('sync'); setMessage(null); try { const report = await chainSync({ slot }); if (!report) { setMessage('Offline mail is unavailable on this system.'); return; } setMessage( report.ok ? `Synced ${report.envelopesWritten} messages and ${report.bodiesWritten} bodies.` + (report.unfinishedWork ? ' More will download in the background.' : '') + (report.warnings.length > 0 ? ` Notes: ${report.warnings.join('; ')}` : '') : `Sync failed: ${report.error ?? 'unknown error'}`, ); await refresh(); } finally { setBusy(null); } }; const handlePurge = async () => { setBusy('purge'); setMessage(null); try { const ok = await purgeReplica(slot); setMessage(ok ? 'Offline mail deleted from this device.' : 'Could not delete offline mail.'); await refresh(); } finally { setBusy(null); } }; if (!status) return null; const stats = status.stats; const total = stats ? stats.fileBytes : 0; return ( <> 0 ? ` · ${stats.wantedBodies} still downloading` : '') : 'Nothing stored yet. Mail downloads automatically as it arrives.' } > {formatBytes(total)}
); }