Files
tolaria/src/hooks/useVaultWatcher.ts
2026-05-11 18:21:12 +02:00

387 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { isTauri } from '../mock-tauri'
export const VAULT_CHANGED_EVENT = 'vault-changed'
export const VAULT_WATCHER_DEBOUNCE_MS = 350
export const INTERNAL_WRITE_SUPPRESSION_MS = 4000
type WatchPath = string
type TimestampProvider = () => number
interface VaultChangedPayload {
vaultPath: WatchPath
paths: WatchPath[]
}
interface UseVaultWatcherOptions {
vaultPath?: WatchPath
vaultPaths?: WatchPath[]
onVaultChanged: (paths: WatchPath[]) => Promise<void> | void
debounceMs?: number
filterChangedPaths?: (paths: WatchPath[]) => WatchPath[]
}
interface ChangedPathOptions {
path: WatchPath
vaultPath: WatchPath
}
interface PathContainmentOptions {
path: WatchPath
parent: WatchPath
}
function isAbsoluteWatchPath(path: WatchPath): boolean {
return path.startsWith('/') || /^[A-Za-z]:\//u.test(path)
}
function trimTrailingSlash(path: WatchPath): WatchPath {
return path.length > 1 ? path.replace(/\/+$/u, '') : path
}
export function normalizeWatchPath(path: WatchPath): WatchPath {
return trimTrailingSlash(path.replaceAll('\\', '/').replace(/^\/private\/tmp(?=\/|$)/u, '/tmp'))
}
export function resolveChangedPath({ path, vaultPath }: ChangedPathOptions): WatchPath {
const normalizedPath = normalizeWatchPath(path)
if (isAbsoluteWatchPath(normalizedPath)) return normalizedPath
return normalizeWatchPath(`${vaultPath}/${normalizedPath}`)
}
function isSamePathOrChild({ path, parent }: PathContainmentOptions): boolean {
const normalizedPath = normalizeWatchPath(path)
const normalizedParent = normalizeWatchPath(parent)
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`)
}
function uniqueWatchRoots(paths: WatchPath[]): WatchPath[] {
return [...new Set(paths.map(normalizeWatchPath).filter(Boolean))]
}
function watchRootsFromOptions(vaultPath?: WatchPath, vaultPaths?: WatchPath[]): WatchPath[] {
if (vaultPaths && vaultPaths.length > 0) return uniqueWatchRoots(vaultPaths)
return uniqueWatchRoots(vaultPath ? [vaultPath] : [])
}
function watchRootsKeyFor(watchRoots: readonly WatchPath[]): string {
return watchRoots.join('\u0000')
}
function useWatchRootsRef(watchRoots: WatchPath[]) {
const watchRootsRef = useRef(watchRoots)
const watchRootsKey = watchRootsKeyFor(watchRoots)
useEffect(() => {
watchRootsRef.current = watchRoots
}, [watchRoots, watchRootsKey])
return { watchRootsRef, watchRootsKey }
}
function rootForPath(path: WatchPath, roots: readonly WatchPath[]): WatchPath | null {
const normalizedPath = normalizeWatchPath(path)
return roots.find((root) => isSamePathOrChild({ path: normalizedPath, parent: root })) ?? null
}
function resolvePathForKnownRoots({
path,
fallbackRoot,
roots,
}: {
path: WatchPath
fallbackRoot: WatchPath
roots: readonly WatchPath[]
}): WatchPath | null {
const normalizedPath = normalizeWatchPath(path)
if (isAbsoluteWatchPath(normalizedPath)) return normalizedPath
const root = fallbackRoot || roots[0]
return root ? resolveChangedPath({ path: normalizedPath, vaultPath: root }) : null
}
function eventRootForPayload(vaultPath: WatchPath, roots: readonly WatchPath[]): WatchPath | null {
const eventRoot = normalizeWatchPath(vaultPath)
return roots.find((root) => root === eventRoot) ?? null
}
function pruneRecentWrites(writes: Map<string, number>, now: number) {
for (const [path, timestamp] of writes) {
if (now - timestamp > INTERNAL_WRITE_SUPPRESSION_MS) writes.delete(path)
}
}
function useLatestRef<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
function useVaultPathRef(vaultPath: WatchPath) {
const vaultPathRef = useRef(normalizeWatchPath(vaultPath))
useEffect(() => {
vaultPathRef.current = normalizeWatchPath(vaultPath)
}, [vaultPath])
return vaultPathRef
}
export function useRecentVaultWrites({
vaultPath = '',
vaultPaths,
now = Date.now,
}: {
vaultPath?: WatchPath
vaultPaths?: WatchPath[]
now?: TimestampProvider
}) {
const recentWritesRef = useRef<Map<string, number>>(new Map())
const watchRoots = useMemo(() => watchRootsFromOptions(vaultPath, vaultPaths), [vaultPath, vaultPaths])
const { watchRootsRef, watchRootsKey } = useWatchRootsRef(watchRoots)
const vaultPathRef = useVaultPathRef(vaultPath)
useEffect(() => {
recentWritesRef.current.clear()
}, [watchRootsKey])
const markInternalWrite = useCallback((path: WatchPath) => {
const resolvedPath = resolvePathForKnownRoots({
path,
fallbackRoot: vaultPathRef.current,
roots: watchRootsRef.current,
})
if (resolvedPath && rootForPath(resolvedPath, watchRootsRef.current)) {
recentWritesRef.current.set(resolvedPath, now())
}
}, [now, vaultPathRef, watchRootsRef])
const filterExternalPaths = useCallback((paths: WatchPath[]) => {
if (watchRootsRef.current.length === 0 || paths.length === 0) return paths
const currentTime = now()
pruneRecentWrites(recentWritesRef.current, currentTime)
return paths.filter((path) => {
const resolvedPath = resolvePathForKnownRoots({
path,
fallbackRoot: vaultPathRef.current,
roots: watchRootsRef.current,
})
return !resolvedPath || !recentWritesRef.current.has(resolvedPath)
})
}, [now, vaultPathRef, watchRootsRef])
return { markInternalWrite, filterExternalPaths }
}
function clearRefreshQueue({
debounceTimerRef,
queuedPathsRef,
fullRefreshPendingRef,
}: {
debounceTimerRef: RefObject<ReturnType<typeof setTimeout> | null>
queuedPathsRef: RefObject<Set<string>>
fullRefreshPendingRef: RefObject<boolean>
}) {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
queuedPathsRef.current.clear()
fullRefreshPendingRef.current = false
}
function addQueuedChangedPaths({
root,
paths,
queuedPaths,
fullRefreshPendingRef,
}: {
root: WatchPath
paths: WatchPath[]
queuedPaths: Set<string>
fullRefreshPendingRef: RefObject<boolean>
}) {
if (paths.length === 0) {
fullRefreshPendingRef.current = true
return
}
for (const path of paths) {
const resolvedPath = resolveChangedPath({ path, vaultPath: root })
if (isSamePathOrChild({ path: resolvedPath, parent: root })) queuedPaths.add(resolvedPath)
}
}
function hasRefreshWork({
queuedPathsRef,
fullRefreshPendingRef,
}: {
queuedPathsRef: RefObject<Set<string>>
fullRefreshPendingRef: RefObject<boolean>
}) {
return fullRefreshPendingRef.current || queuedPathsRef.current.size > 0
}
function pendingRefreshPaths({
queuedPathsRef,
fullRefreshPendingRef,
}: {
queuedPathsRef: RefObject<Set<string>>
fullRefreshPendingRef: RefObject<boolean>
}) {
const fullRefresh = fullRefreshPendingRef.current
return {
fullRefresh,
queuedPaths: fullRefresh ? [] : Array.from(queuedPathsRef.current),
}
}
function filteredRefreshPaths({
fullRefresh,
queuedPaths,
filterChangedPaths,
}: {
fullRefresh: boolean
queuedPaths: WatchPath[]
filterChangedPaths?: (paths: WatchPath[]) => WatchPath[]
}) {
return fullRefresh ? queuedPaths : filterChangedPaths?.(queuedPaths) ?? queuedPaths
}
function handleWatcherEvent({
event,
roots,
enqueueChangedPaths,
}: {
event: { payload: VaultChangedPayload }
roots: readonly WatchPath[]
enqueueChangedPaths: (root: WatchPath, paths: WatchPath[]) => void
}) {
const root = eventRootForPayload(event.payload.vaultPath, roots)
if (root) enqueueChangedPaths(root, event.payload.paths ?? [])
}
function cleanupNativeWatcherListener(unlisten: UnlistenFn): void {
void Promise.resolve()
.then(unlisten)
.catch(() => {})
}
function usePendingVaultRefresh({
onVaultChanged,
filterChangedPaths,
debounceMs,
}: {
onVaultChanged: UseVaultWatcherOptions['onVaultChanged']
filterChangedPaths: UseVaultWatcherOptions['filterChangedPaths']
debounceMs: number
}) {
const onVaultChangedRef = useLatestRef(onVaultChanged)
const filterChangedPathsRef = useLatestRef(filterChangedPaths)
const queuedPathsRef = useRef<Set<string>>(new Set())
const fullRefreshPendingRef = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearPendingRefresh = useCallback(() => clearRefreshQueue({
debounceTimerRef,
queuedPathsRef,
fullRefreshPendingRef,
}), [])
const flushQueuedRefresh = useCallback(() => {
const { fullRefresh, queuedPaths } = pendingRefreshPaths({ queuedPathsRef, fullRefreshPendingRef })
clearPendingRefresh()
const filteredPaths = filteredRefreshPaths({
fullRefresh,
queuedPaths,
filterChangedPaths: filterChangedPathsRef.current,
})
if (!fullRefresh && filteredPaths.length === 0) return
void Promise.resolve(onVaultChangedRef.current(filteredPaths)).catch((err) => {
console.warn('Vault watcher refresh failed:', err)
})
}, [clearPendingRefresh, filterChangedPathsRef, onVaultChangedRef])
const enqueueChangedPaths = useCallback((root: WatchPath, paths: WatchPath[]) => {
if (!root) return
addQueuedChangedPaths({
root,
paths,
queuedPaths: queuedPathsRef.current,
fullRefreshPendingRef,
})
if (!hasRefreshWork({ queuedPathsRef, fullRefreshPendingRef })) return
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(flushQueuedRefresh, debounceMs)
}, [debounceMs, flushQueuedRefresh])
return { clearPendingRefresh, enqueueChangedPaths }
}
function useNativeVaultWatcher({
watchRoots,
watchRootsKey,
enqueueChangedPaths,
clearPendingRefresh,
}: {
watchRoots: WatchPath[]
watchRootsKey: string
enqueueChangedPaths: (root: WatchPath, paths: WatchPath[]) => void
clearPendingRefresh: () => void
}) {
useEffect(() => {
if (watchRoots.length === 0 || !isTauri()) return
let cancelled = false
let unlisten: UnlistenFn | null = null
void listen<VaultChangedPayload>(VAULT_CHANGED_EVENT, (event) => {
handleWatcherEvent({ event, roots: watchRoots, enqueueChangedPaths })
}).then((nextUnlisten) => {
if (cancelled) {
cleanupNativeWatcherListener(nextUnlisten)
} else {
unlisten = nextUnlisten
}
}).catch((err) => {
console.warn('Failed to subscribe to vault watcher events:', err)
})
for (const root of watchRoots) {
void invoke('start_vault_watcher', { path: root }).catch((err) => {
console.warn('Failed to start vault watcher:', err)
})
}
return () => {
cancelled = true
clearPendingRefresh()
if (unlisten) cleanupNativeWatcherListener(unlisten)
void invoke('stop_vault_watcher').catch(() => {})
}
}, [watchRoots, watchRootsKey, enqueueChangedPaths, clearPendingRefresh])
}
export function useVaultWatcher({
vaultPath = '',
vaultPaths,
onVaultChanged,
debounceMs = VAULT_WATCHER_DEBOUNCE_MS,
filterChangedPaths,
}: UseVaultWatcherOptions) {
const watchRoots = useMemo(() => watchRootsFromOptions(vaultPath, vaultPaths), [vaultPath, vaultPaths])
const watchRootsKey = watchRootsKeyFor(watchRoots)
const pendingRefresh = usePendingVaultRefresh({
onVaultChanged,
filterChangedPaths,
debounceMs,
})
useNativeVaultWatcher({
watchRoots,
watchRootsKey,
enqueueChangedPaths: pendingRefresh.enqueueChangedPaths,
clearPendingRefresh: pendingRefresh.clearPendingRefresh,
})
}