11import { useEffect , useRef } from "react" ;
2- import { watch , type UnwatchFn , type WatchEvent } from "@tauri-apps/plugin-fs" ;
2+ import { watchImmediate , type UnwatchFn , type WatchEvent } from "@tauri-apps/plugin-fs" ;
33import { isFilesystemRoot } from "@/lib/storage" ;
44
55const WATCH_DEBOUNCE_MS = 350 ;
@@ -20,46 +20,142 @@ export function isDirectoryChangeEvent(event: WatchEvent): boolean {
2020 return false ;
2121}
2222
23+ type FolderWatchFn = (
24+ path : string ,
25+ onEvent : ( event : WatchEvent ) => void ,
26+ ) => Promise < UnwatchFn > ;
27+
28+ type ScheduleFn = ( fn : ( ) => void , ms : number ) => unknown ;
29+ type CancelFn = ( id : unknown ) => void ;
30+
31+ export type FolderWatcherController = {
32+ /** Reconcile the watched set: watchers are created only for new paths and
33+ * removed only for dropped ones — unchanged paths keep their watcher. */
34+ setPaths ( paths : readonly string [ ] ) : void ;
35+ dispose ( ) : void ;
36+ } ;
37+
38+ export type FolderWatcherControllerOptions = {
39+ /** Watcher factory — injectable for tests. Defaults to recursive `watchImmediate`. */
40+ watch ?: FolderWatchFn ;
41+ debounceMs ?: number ;
42+ isRelevant ?: ( event : WatchEvent ) => boolean ;
43+ schedule ?: ScheduleFn ;
44+ cancel ?: CancelFn ;
45+ } ;
46+
47+ /**
48+ * Watcher registry with change coalescing, extracted from the hook so the
49+ * lifecycle logic is unit-testable (see tests/folder-watcher.test.ts).
50+ *
51+ * Perf-critical details:
52+ * - `watchImmediate` + JS-side coalescing, NOT the plugin's debounced
53+ * `watch`. With `delayMs` set, tauri-plugin-fs (2.5, notify-debouncer-full
54+ * 0.6) routes through a debouncer whose file-ID cache walks the entire tree
55+ * at watcher creation on macOS and Windows (`RecommendedCache = FileIdMap`;
56+ * Linux uses `NoCache`), inside a synchronous Tauri command — on large
57+ * roots that froze the app at startup / folder add.
58+ * - Fixed-window coalescing: the first qualifying event schedules a refresh
59+ * `debounceMs` later and further events ride the same window, so sustained
60+ * filesystem churn cannot starve refreshes.
61+ * - Incremental registry: adding a folder creates only the new watcher.
62+ * - A failed watcher registration is evicted so a later reconciliation can
63+ * retry it (guarded against remove/re-add races via promise identity).
64+ * - Tradeoff: raw (uncoalesced) events now cross IPC; the JS window bounds
65+ * the refresh work, not the message volume.
66+ */
67+ export function createFolderWatcherController (
68+ onChange : ( ) => void ,
69+ options : FolderWatcherControllerOptions = { } ,
70+ ) : FolderWatcherController {
71+ const watch : FolderWatchFn = options . watch
72+ ?? ( ( path , onEvent ) => watchImmediate ( path , onEvent , { recursive : true } ) ) ;
73+ const debounceMs = options . debounceMs ?? WATCH_DEBOUNCE_MS ;
74+ const isRelevant = options . isRelevant ?? isDirectoryChangeEvent ;
75+ const schedule : ScheduleFn = options . schedule ?? ( ( fn , ms ) => setTimeout ( fn , ms ) ) ;
76+ const cancel : CancelFn = options . cancel
77+ ?? ( ( id ) => clearTimeout ( id as ReturnType < typeof setTimeout > ) ) ;
78+
79+ // path → pending watcher registration; promise identity doubles as the
80+ // "is this watcher still current?" token for stale callbacks.
81+ const registry = new Map < string , Promise < UnwatchFn | null > > ( ) ;
82+ let timer : unknown = null ;
83+ let disposed = false ;
84+
85+ const fire = ( ) => {
86+ if ( timer !== null ) return ; // window already open — coalesce
87+ timer = schedule ( ( ) => {
88+ timer = null ;
89+ onChange ( ) ;
90+ } , debounceMs ) ;
91+ } ;
92+
93+ const add = ( path : string ) => {
94+ // `pending` is referenced inside its own initialiser — safe because the
95+ // whole const binding completes synchronously before any event callback
96+ // or rejection can run in a later task.
97+ const pending : Promise < UnwatchFn | null > = watch ( path , ( event ) => {
98+ if ( disposed || registry . get ( path ) !== pending ) return ; // stale watcher
99+ if ( isRelevant ( event ) ) fire ( ) ;
100+ } ) . catch ( ( error : unknown ) => {
101+ console . warn ( `marka.md: failed to watch folder ${ path } ` , error ) ;
102+ // evict so the next reconciliation can retry — unless the path was
103+ // removed or re-added (newer registration) in the meantime
104+ if ( registry . get ( path ) === pending ) registry . delete ( path ) ;
105+ return null ;
106+ } ) ;
107+ registry . set ( path , pending ) ;
108+ } ;
109+
110+ return {
111+ setPaths ( paths ) {
112+ if ( disposed ) return ;
113+ const wanted = new Set ( paths ) ;
114+ for ( const [ path , pending ] of registry ) {
115+ if ( wanted . has ( path ) ) continue ;
116+ registry . delete ( path ) ;
117+ void pending . then ( ( unwatch ) => unwatch ?.( ) ) ;
118+ }
119+ for ( const path of wanted ) {
120+ if ( ! registry . has ( path ) ) add ( path ) ;
121+ }
122+ } ,
123+ dispose ( ) {
124+ disposed = true ;
125+ for ( const pending of registry . values ( ) ) {
126+ void pending . then ( ( unwatch ) => unwatch ?.( ) ) ;
127+ }
128+ registry . clear ( ) ;
129+ if ( timer !== null ) {
130+ cancel ( timer ) ;
131+ timer = null ;
132+ }
133+ } ,
134+ } ;
135+ }
136+
23137/** Watches each opened root recursively and refreshes the visible tree on structure changes. */
24138export function useFolderWatcher ( paths : readonly string [ ] , onChange : ( ) => void ) : void {
25139 const onChangeRef = useRef ( onChange ) ;
26- const pathsKey = paths . join ( "\0" ) ;
140+ const controllerRef = useRef < FolderWatcherController | null > ( null ) ;
141+ const pathsKey = watchableFolderPaths ( paths ) . join ( "\0" ) ;
27142
28143 useEffect ( ( ) => {
29144 onChangeRef . current = onChange ;
30145 } , [ onChange ] ) ;
31146
147+ // controller per mount (not per render) — StrictMode's setup/cleanup/setup
148+ // gets a fresh controller each time, so dispose() can be terminal.
32149 useEffect ( ( ) => {
33- let disposed = false ;
34- const unwatchers = new Set < UnwatchFn > ( ) ;
35- const uniquePaths = watchableFolderPaths ( paths ) ;
36-
37- const start = async ( ) => {
38- for ( const path of uniquePaths ) {
39- try {
40- const unwatch = await watch (
41- path ,
42- ( event ) => {
43- if ( isDirectoryChangeEvent ( event ) ) onChangeRef . current ( ) ;
44- } ,
45- { recursive : true , delayMs : WATCH_DEBOUNCE_MS } ,
46- ) ;
47- if ( disposed ) {
48- unwatch ( ) ;
49- } else {
50- unwatchers . add ( unwatch ) ;
51- }
52- } catch ( error ) {
53- console . warn ( `marka.md: failed to watch folder ${ path } ` , error ) ;
54- }
55- }
56- } ;
57-
58- void start ( ) ;
150+ const controller = createFolderWatcherController ( ( ) => onChangeRef . current ( ) ) ;
151+ controllerRef . current = controller ;
59152 return ( ) => {
60- disposed = true ;
61- for ( const unwatch of unwatchers ) unwatch ( ) ;
62- unwatchers . clear ( ) ;
153+ controllerRef . current = null ;
154+ controller . dispose ( ) ;
63155 } ;
156+ } , [ ] ) ;
157+
158+ useEffect ( ( ) => {
159+ controllerRef . current ?. setPaths ( pathsKey ? pathsKey . split ( "\0" ) : [ ] ) ;
64160 } , [ pathsKey ] ) ;
65161}
0 commit comments