import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types' import { trackEvent } from '../lib/telemetry' const DEFAULT_INTERVAL_MS = 5 * 60_000 const FOCUS_COOLDOWN_MS = 30_000 type MaybePromise = void | Promise type SyncCallbacks = Pick function tauriCall(cmd: string, args: Record): Promise { return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) } interface UseAutoSyncOptions { enabled?: boolean vaultPath: string vaultPaths?: string[] intervalMinutes: number | null onVaultUpdated: (updatedFiles: string[], vaultPath: string) => MaybePromise onSyncUpdated?: () => MaybePromise onConflict: (files: string[]) => void onToast: (msg: string) => void } export interface AutoSyncState { syncStatus: SyncStatus lastSyncTime: number | null conflictFiles: string[] conflictVaultPath: string | null lastCommitInfo: LastCommitInfo | null remoteStatus: GitRemoteStatus | null triggerSync: (vaultPath?: string) => void /** Pull from remote, then push if there are local commits ahead. */ pullAndPush: (vaultPath?: string) => void /** Pause auto-pull (e.g. while conflict resolver modal is open). */ pausePull: () => void /** Resume auto-pull after pausing. */ resumePull: () => void /** Notify that a push was rejected so the status updates to pull_required. */ handlePushRejected: () => void } type SyncSetState = Dispatch> interface PullErrorResolution { checkExistingConflicts: () => Promise notifyError?: string callbacksRef: MutableRefObject setSyncStatus: SyncSetState } interface SyncTaskOptions { blockWhenPaused: boolean pauseRef: MutableRefObject syncingRef: MutableRefObject setLastSyncTime: SyncSetState setSyncStatus: SyncSetState task: () => Promise } interface PullOutcome { result: GitPullResult vaultPath: string } interface PushOutcome { result: GitPushResult vaultPath: string } interface UpdatedVaultRefreshOptions { outcomes: PullOutcome[] callbacksRef: MutableRefObject setConflictFiles: SyncSetState setConflictVaultPath: SyncSetState setSyncStatus: SyncSetState } interface ConflictStateOptions { callbacksRef: MutableRefObject files: string[] setConflictFiles: SyncSetState setConflictVaultPath: SyncSetState setSyncStatus: SyncSetState vaultPath: string } function uniqueVaultPaths(paths: string[]): string[] { const seen = new Set() return paths.filter((path) => { const trimmed = path.trim() if (!trimmed || seen.has(trimmed)) return false seen.add(trimmed) return true }) } function aggregateRemoteStatuses(statuses: GitRemoteStatus[]): GitRemoteStatus | null { if (statuses.length === 0) return null return { branch: statuses.length === 1 ? statuses[0].branch : '', ahead: statuses.reduce((sum, status) => sum + (status.ahead ?? 0), 0), behind: statuses.reduce((sum, status) => sum + (status.behind ?? 0), 0), hasRemote: statuses.some((status) => status.hasRemote === true), } } function updatedPullOutcomes(outcomes: PullOutcome[]): PullOutcome[] { return outcomes.filter((outcome) => outcome.result.status === 'updated') } function firstConflictOutcome(outcomes: PullOutcome[]): PullOutcome | null { return outcomes.find((outcome) => outcome.result.status === 'conflict') ?? null } function hasPullError(outcomes: PullOutcome[]): boolean { return outcomes.some((outcome) => outcome.result.status === 'error') } function firstPullError(outcomes: PullOutcome[]): PullOutcome | null { return outcomes.find((outcome) => outcome.result.status === 'error') ?? null } function pulledUpdateToast(outcomes: PullOutcome[]): string { const updateCount = outcomes.reduce((sum, outcome) => sum + outcome.result.updatedFiles.length, 0) return `Pulled ${updateCount} update(s) from remote` } function pushSuccessToast(): string { return 'Pulled and pushed successfully' } function rejectedPushCount(outcomes: PushOutcome[]): number { return outcomes.filter((outcome) => outcome.result.status === 'rejected').length } function firstFailedPush(outcomes: PushOutcome[]): PushOutcome | null { return outcomes.find((outcome) => outcome.result.status !== 'ok' && outcome.result.status !== 'rejected') ?? null } function clearConflictState( setSyncStatus: SyncSetState, setConflictFiles: SyncSetState, setConflictVaultPath: SyncSetState, ): void { setSyncStatus('idle') setConflictFiles([]) setConflictVaultPath(null) } function setConflictState({ callbacksRef, files, setConflictFiles, setConflictVaultPath, setSyncStatus, vaultPath, }: ConflictStateOptions): void { setSyncStatus('conflict') setConflictFiles(files) setConflictVaultPath(vaultPath) void callbacksRef.current.onConflict(files) } function markPullTimestamp( setLastSyncTime: SyncSetState, refreshCommitInfo: (vaultPath?: string) => void, vaultPath?: string, ): void { setLastSyncTime(Date.now()) refreshCommitInfo(vaultPath) } function useRemoteStatusRefresher(setRemoteStatus: SyncSetState) { return useCallback(async (targetVaultPaths: string[]) => { const statuses = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => { try { return await tauriCall('git_remote_status', { vaultPath: targetVaultPath }) } catch { return null } })) const aggregate = aggregateRemoteStatuses(statuses.filter((status): status is GitRemoteStatus => status !== null)) setRemoteStatus(aggregate) return aggregate }, [setRemoteStatus]) } function useConflictChecker( setSyncStatus: SyncSetState, setConflictFiles: SyncSetState, setConflictVaultPath: SyncSetState, callbacksRef: MutableRefObject, ) { return useCallback(async (targetVaultPaths: string[]): Promise => { const conflictChecks = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => { try { return { files: await tauriCall('get_conflict_files', { vaultPath: targetVaultPath }), vaultPath: targetVaultPath, } } catch { return { files: [], vaultPath: targetVaultPath } } })) const conflict = conflictChecks.find(({ files }) => Array.isArray(files) && files.length > 0) if (!conflict) return false setConflictState({ callbacksRef, files: conflict.files, setSyncStatus, setConflictFiles, setConflictVaultPath, vaultPath: conflict.vaultPath, }) return true }, [setSyncStatus, setConflictFiles, setConflictVaultPath, callbacksRef]) } function useCommitInfoRefresher( vaultPath: string, setLastCommitInfo: SyncSetState, ) { return useCallback((targetVaultPath = vaultPath) => { tauriCall('get_last_commit_info', { vaultPath: targetVaultPath }) .then(info => setLastCommitInfo(info)) .catch((err) => console.warn('[sync] Failed to refresh last commit info:', err)) }, [vaultPath, setLastCommitInfo]) } async function refreshUpdatedVaults(options: UpdatedVaultRefreshOptions): Promise { const { outcomes, callbacksRef, setConflictFiles, setConflictVaultPath, setSyncStatus, } = options clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) await Promise.all(outcomes.map((outcome) => ( callbacksRef.current.onVaultUpdated(outcome.result.updatedFiles, outcome.vaultPath) ))) await callbacksRef.current.onSyncUpdated?.() } async function handleUpdatedPull(options: UpdatedVaultRefreshOptions): Promise { const { outcomes, callbacksRef } = options await refreshUpdatedVaults(options) await callbacksRef.current.onToast(pulledUpdateToast(outcomes)) } async function resolvePullError(options: PullErrorResolution): Promise { const { checkExistingConflicts, notifyError, callbacksRef, setSyncStatus, } = options const hasConflicts = await checkExistingConflicts() if (hasConflicts) return setSyncStatus('error') if (notifyError) await callbacksRef.current.onToast(notifyError) } function handlePushResult(options: { outcomes: PushOutcome[] callbacksRef: MutableRefObject setConflictFiles: SyncSetState setConflictVaultPath: SyncSetState setSyncStatus: SyncSetState }): void { const { outcomes, callbacksRef, setConflictFiles, setConflictVaultPath, setSyncStatus, } = options const rejectedCount = rejectedPushCount(outcomes) if (rejectedCount > 0) { setSyncStatus('pull_required') void callbacksRef.current.onToast('Push still rejected after pull — try again') return } const failedPush = firstFailedPush(outcomes) if (failedPush) { setSyncStatus('error') void callbacksRef.current.onToast(failedPush.result.message) return } clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) void callbacksRef.current.onToast(pushSuccessToast()) } async function runSyncTask(options: SyncTaskOptions): Promise { const { blockWhenPaused, pauseRef, syncingRef, setLastSyncTime, setSyncStatus, task, } = options if (syncingRef.current || (blockWhenPaused && pauseRef.current)) return syncingRef.current = true setSyncStatus('syncing') try { await task() } catch { setSyncStatus('error') setLastSyncTime(Date.now()) } finally { syncingRef.current = false } } function useAutoSyncLifecycle(options: { enabled: boolean checkExistingConflicts: () => Promise intervalMinutes: number | null performPull: () => Promise refreshRemoteStatus: () => Promise }) { const { enabled, checkExistingConflicts, intervalMinutes, performPull, refreshRemoteStatus, } = options useEffect(() => { if (!enabled) return void checkExistingConflicts().then(hasConflicts => { if (!hasConflicts) void performPull() }) void refreshRemoteStatus() }, [checkExistingConflicts, enabled, performPull, refreshRemoteStatus]) const lastPullTimeRef = useRef(0) useEffect(() => { if (!enabled) return const handleFocus = () => { const now = Date.now() if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return lastPullTimeRef.current = now void performPull() } window.addEventListener('focus', handleFocus) return () => window.removeEventListener('focus', handleFocus) }, [enabled, performPull]) useEffect(() => { if (!enabled) return const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS const id = setInterval(() => { void performPull() }, ms) return () => clearInterval(id) }, [enabled, performPull, intervalMinutes]) } export function useAutoSync({ enabled = true, vaultPath, vaultPaths, intervalMinutes, onVaultUpdated, onSyncUpdated, onConflict, onToast, }: UseAutoSyncOptions): AutoSyncState { const [syncStatus, setSyncStatus] = useState('idle') const [lastSyncTime, setLastSyncTime] = useState(null) const [conflictFiles, setConflictFiles] = useState([]) const [conflictVaultPath, setConflictVaultPath] = useState(null) const [lastCommitInfo, setLastCommitInfo] = useState(null) const [remoteStatus, setRemoteStatus] = useState(null) const syncingRef = useRef(false) const pauseRef = useRef(false) const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast }) useEffect(() => { callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast } }, [onVaultUpdated, onSyncUpdated, onConflict, onToast]) const targetVaultPaths = useMemo(() => { const configuredPaths = vaultPaths && vaultPaths.length > 0 ? vaultPaths : [vaultPath] return uniqueVaultPaths(configuredPaths) }, [vaultPath, vaultPaths]) const resolveTargetVaultPaths = useCallback((targetVaultPath?: string) => ( targetVaultPath ? uniqueVaultPaths([targetVaultPath]) : targetVaultPaths ), [targetVaultPaths]) const refreshRemoteStatus = useRemoteStatusRefresher(setRemoteStatus) const checkExistingConflicts = useConflictChecker(setSyncStatus, setConflictFiles, setConflictVaultPath, callbacksRef) const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo) const checkActiveConflicts = useCallback( () => checkExistingConflicts(targetVaultPaths), [checkExistingConflicts, targetVaultPaths], ) const refreshActiveRemoteStatus = useCallback( () => refreshRemoteStatus(targetVaultPaths), [refreshRemoteStatus, targetVaultPaths], ) const performPull = useCallback(async (targetVaultPath?: string) => { if (!enabled) return const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) if (pullVaultPaths.length === 0) return await runSyncTask({ blockWhenPaused: true, pauseRef, syncingRef, setLastSyncTime, setSyncStatus, task: async () => { const outcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ result: await tauriCall('git_pull', { vaultPath: path }), vaultPath: path, }))) markPullTimestamp(setLastSyncTime, refreshCommitInfo, pullVaultPaths[0]) const conflictOutcome = firstConflictOutcome(outcomes) if (conflictOutcome) { setConflictState({ callbacksRef, files: conflictOutcome.result.conflictFiles, setSyncStatus, setConflictFiles, setConflictVaultPath, vaultPath: conflictOutcome.vaultPath, }) } else if (hasPullError(outcomes)) { await resolvePullError({ checkExistingConflicts: () => checkExistingConflicts(pullVaultPaths), callbacksRef, setSyncStatus, }) } else { const updatedOutcomes = updatedPullOutcomes(outcomes) if (updatedOutcomes.length > 0) { await handleUpdatedPull({ outcomes: updatedOutcomes, callbacksRef, setConflictFiles, setConflictVaultPath, setSyncStatus, }) } else { clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) } } void refreshRemoteStatus(pullVaultPaths) }, }) }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) /** Pull from remote, then auto-push if successful. Used for divergence recovery. */ const pullAndPush = useCallback(async (targetVaultPath?: string) => { if (!enabled) return const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) if (pullVaultPaths.length === 0) return await runSyncTask({ blockWhenPaused: false, pauseRef, syncingRef, setLastSyncTime, setSyncStatus, task: async () => { const pullOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ result: await tauriCall('git_pull', { vaultPath: path }), vaultPath: path, }))) markPullTimestamp(setLastSyncTime, refreshCommitInfo, pullVaultPaths[0]) const conflictOutcome = firstConflictOutcome(pullOutcomes) if (conflictOutcome) { setConflictState({ callbacksRef, files: conflictOutcome.result.conflictFiles, setSyncStatus, setConflictFiles, setConflictVaultPath, vaultPath: conflictOutcome.vaultPath, }) return } const pullError = firstPullError(pullOutcomes) if (pullError) { await resolvePullError({ checkExistingConflicts: () => checkExistingConflicts(pullVaultPaths), notifyError: `Pull failed: ${pullError.result.message}`, callbacksRef, setSyncStatus, }) return } const updatedOutcomes = updatedPullOutcomes(pullOutcomes) if (updatedOutcomes.length > 0) { await refreshUpdatedVaults({ outcomes: updatedOutcomes, callbacksRef, setConflictFiles, setConflictVaultPath, setSyncStatus, }) } const pushOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ result: await tauriCall('git_push', { vaultPath: path }), vaultPath: path, }))) handlePushResult({ outcomes: pushOutcomes, callbacksRef, setConflictFiles, setConflictVaultPath, setSyncStatus, }) void refreshRemoteStatus(pullVaultPaths) }, }) }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) const handlePushRejected = useCallback(() => { setSyncStatus('pull_required') }, []) useAutoSyncLifecycle({ enabled, checkExistingConflicts: checkActiveConflicts, intervalMinutes, performPull, refreshRemoteStatus: refreshActiveRemoteStatus, }) const pausePull = useCallback(() => { pauseRef.current = true }, []) const resumePull = useCallback(() => { pauseRef.current = false }, []) const triggerSync = useCallback((targetVaultPath?: string) => { if (!enabled) return trackEvent('sync_triggered') void performPull(targetVaultPath) }, [enabled, performPull]) return { syncStatus, lastSyncTime, conflictFiles, conflictVaultPath, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected } }