Add trackEvent() calls for all user actions in the tracking plan: - Vault: vault_opened (with has_git, note_count), vault_switched - Notes: note_created (with has_type, creation_path), note_deleted, note_trashed, note_archived, note_favorited, note_unfavorited - Git: commit_made, sync_triggered - Search: search_used - Editor: raw_mode_toggled, wikilink_inserted - Types & Views: type_created, view_created - Telemetry: telemetry_opted_in, telemetry_opted_out All events gated by PostHog initialization (only fires when opted in). No PII in any event properties — counts, booleans, and enums only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
7.8 KiB
TypeScript
230 lines
7.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } 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
|
|
|
|
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
|
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
|
}
|
|
|
|
interface UseAutoSyncOptions {
|
|
vaultPath: string
|
|
intervalMinutes: number | null
|
|
onVaultUpdated: () => void
|
|
onSyncUpdated?: () => void
|
|
onConflict: (files: string[]) => void
|
|
onToast: (msg: string) => void
|
|
}
|
|
|
|
export interface AutoSyncState {
|
|
syncStatus: SyncStatus
|
|
lastSyncTime: number | null
|
|
conflictFiles: string[]
|
|
lastCommitInfo: LastCommitInfo | null
|
|
remoteStatus: GitRemoteStatus | null
|
|
triggerSync: () => void
|
|
/** Pull from remote, then push if there are local commits ahead. */
|
|
pullAndPush: () => 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
|
|
}
|
|
|
|
export function useAutoSync({
|
|
vaultPath,
|
|
intervalMinutes,
|
|
onVaultUpdated,
|
|
onSyncUpdated,
|
|
onConflict,
|
|
onToast,
|
|
}: UseAutoSyncOptions): AutoSyncState {
|
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
|
|
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
|
|
const [conflictFiles, setConflictFiles] = useState<string[]>([])
|
|
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
|
|
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
|
|
const syncingRef = useRef(false)
|
|
const pauseRef = useRef(false)
|
|
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
|
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
|
|
|
const refreshRemoteStatus = useCallback(async () => {
|
|
try {
|
|
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
|
setRemoteStatus(status)
|
|
return status
|
|
} catch {
|
|
return null
|
|
}
|
|
}, [vaultPath])
|
|
|
|
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
|
|
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
|
|
try {
|
|
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
|
if (files.length > 0) {
|
|
setSyncStatus('conflict')
|
|
setConflictFiles(files)
|
|
callbacksRef.current.onConflict(files)
|
|
return true
|
|
}
|
|
} catch {
|
|
// If the command doesn't exist (e.g. browser mock), ignore
|
|
}
|
|
return false
|
|
}, [vaultPath])
|
|
|
|
const refreshCommitInfo = useCallback(() => {
|
|
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
|
.then(info => setLastCommitInfo(info))
|
|
.catch(() => {})
|
|
}, [vaultPath])
|
|
|
|
const performPull = useCallback(async () => {
|
|
if (syncingRef.current || pauseRef.current) return
|
|
syncingRef.current = true
|
|
setSyncStatus('syncing')
|
|
|
|
try {
|
|
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
|
setLastSyncTime(Date.now())
|
|
refreshCommitInfo()
|
|
|
|
if (result.status === 'updated') {
|
|
setSyncStatus('idle')
|
|
setConflictFiles([])
|
|
callbacksRef.current.onVaultUpdated()
|
|
callbacksRef.current.onSyncUpdated?.()
|
|
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
|
} else if (result.status === 'conflict') {
|
|
setSyncStatus('conflict')
|
|
setConflictFiles(result.conflictFiles)
|
|
callbacksRef.current.onConflict(result.conflictFiles)
|
|
} else if (result.status === 'error') {
|
|
// Pull failed — check if there are pre-existing conflicts that caused it
|
|
const hasConflicts = await checkExistingConflicts()
|
|
if (!hasConflicts) {
|
|
setSyncStatus('error')
|
|
}
|
|
} else {
|
|
// up_to_date or no_remote
|
|
setSyncStatus('idle')
|
|
setConflictFiles([])
|
|
}
|
|
|
|
// Refresh remote status after pull
|
|
refreshRemoteStatus()
|
|
} catch {
|
|
setSyncStatus('error')
|
|
setLastSyncTime(Date.now())
|
|
} finally {
|
|
syncingRef.current = false
|
|
}
|
|
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
|
|
|
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
|
|
const pullAndPush = useCallback(async () => {
|
|
if (syncingRef.current) return
|
|
syncingRef.current = true
|
|
setSyncStatus('syncing')
|
|
|
|
try {
|
|
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
|
setLastSyncTime(Date.now())
|
|
refreshCommitInfo()
|
|
|
|
if (pullResult.status === 'conflict') {
|
|
setSyncStatus('conflict')
|
|
setConflictFiles(pullResult.conflictFiles)
|
|
callbacksRef.current.onConflict(pullResult.conflictFiles)
|
|
return
|
|
}
|
|
|
|
if (pullResult.status === 'error') {
|
|
const hasConflicts = await checkExistingConflicts()
|
|
if (!hasConflicts) {
|
|
setSyncStatus('error')
|
|
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (pullResult.status === 'updated') {
|
|
callbacksRef.current.onVaultUpdated()
|
|
callbacksRef.current.onSyncUpdated?.()
|
|
}
|
|
|
|
// Now push
|
|
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
|
if (pushResult.status === 'ok') {
|
|
setSyncStatus('idle')
|
|
setConflictFiles([])
|
|
callbacksRef.current.onToast('Pulled and pushed successfully')
|
|
} else if (pushResult.status === 'rejected') {
|
|
// Still diverged — shouldn't happen after pull but handle gracefully
|
|
setSyncStatus('pull_required')
|
|
callbacksRef.current.onToast('Push still rejected after pull — try again')
|
|
} else {
|
|
setSyncStatus('error')
|
|
callbacksRef.current.onToast(pushResult.message)
|
|
}
|
|
|
|
refreshRemoteStatus()
|
|
} catch {
|
|
setSyncStatus('error')
|
|
setLastSyncTime(Date.now())
|
|
} finally {
|
|
syncingRef.current = false
|
|
}
|
|
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
|
|
|
const handlePushRejected = useCallback(() => {
|
|
setSyncStatus('pull_required')
|
|
}, [])
|
|
|
|
// Check for pre-existing conflicts on mount, then pull
|
|
useEffect(() => {
|
|
checkExistingConflicts().then(hasConflicts => {
|
|
if (!hasConflicts) performPull()
|
|
})
|
|
refreshRemoteStatus()
|
|
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
|
|
|
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
|
const lastPullTimeRef = useRef(0)
|
|
useEffect(() => {
|
|
const FOCUS_COOLDOWN_MS = 30_000
|
|
const handleFocus = () => {
|
|
const now = Date.now()
|
|
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
|
lastPullTimeRef.current = now
|
|
performPull()
|
|
}
|
|
window.addEventListener('focus', handleFocus)
|
|
return () => window.removeEventListener('focus', handleFocus)
|
|
}, [performPull])
|
|
|
|
// Periodic pull
|
|
useEffect(() => {
|
|
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
|
|
const id = setInterval(performPull, ms)
|
|
return () => clearInterval(id)
|
|
}, [performPull, intervalMinutes])
|
|
|
|
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
|
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
|
|
|
const triggerSync = useCallback(() => {
|
|
trackEvent('sync_triggered')
|
|
performPull()
|
|
}, [performPull])
|
|
|
|
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
|
}
|