11use std:: path:: PathBuf ;
22use std:: sync:: Arc ;
33use std:: collections:: HashMap ;
4- use std:: time:: { Instant , Duration } ;
5- use tokio:: sync:: Mutex ;
6- use tokio:: time:: sleep;
4+ use std:: time:: Duration ;
5+ use tokio:: sync:: { Mutex , Notify } ;
76use serde_json:: json;
87use tracing:: info;
98use anyhow:: Result ;
109
10+ const DEBOUNCE : Duration = Duration :: from_millis ( 100 ) ;
11+
1112use crate :: app_state:: SocketData ;
1213use crate :: code:: Code ;
1314use crate :: diff:: compute_text_edits;
@@ -20,7 +21,8 @@ enum FileState {
2021
2122pub struct FileWatchState {
2223 pub state : FileState ,
23- pub last_event_time : Instant ,
24+ pub notify : Arc < Notify > ,
25+ pub pending : bool ,
2426}
2527
2628async fn is_parent_dir_opened (
@@ -67,9 +69,18 @@ async fn handle_create_remove_event(
6769
6870 let relative_path = crate :: utils:: relative_path ( path_str) ;
6971
72+ // For create events, path.is_file() works because the file exists.
73+ // For remove events, path.is_file() always returns false (file is gone),
74+ // so we use a heuristic: if the path has a file extension, it's a file.
75+ let is_file = match event_kind {
76+ notify:: EventKind :: Create ( _) => path. is_file ( ) ,
77+ notify:: EventKind :: Remove ( _) => path. extension ( ) . is_some ( ) ,
78+ _ => false ,
79+ } ;
80+
7081 let _ = socket. emit ( event_name, & json ! ( {
7182 "path" : relative_path,
72- "isFile" : path . is_file( )
83+ "isFile" : is_file
7384 } ) ) . await ;
7485}
7586
@@ -98,47 +109,97 @@ pub async fn handle_watch_event(
98109 git_manager : & Arc < Mutex < crate :: git:: GitManager > > ,
99110) {
100111 let path_str = match path. to_str ( ) {
101- Some ( s) => s,
112+ Some ( s) => s. to_string ( ) ,
102113 None => return ,
103114 } ;
104115
105- // Debounce: ignore events if less than 100ms since the last one
106- {
116+ let should_spawn = {
117+ let mut states = file_states. lock ( ) . await ;
118+ let entry = states. entry ( path_str. clone ( ) ) . or_insert_with ( || FileWatchState {
119+ state : FileState :: DoesNotExist , // never seen = didn't exist for us
120+ notify : Arc :: new ( Notify :: new ( ) ) ,
121+ pending : false ,
122+ } ) ;
123+
124+ // Signal the existing task to reset its timer
125+ entry. notify . notify_one ( ) ;
126+
127+ if entry. pending {
128+ // A task is already waiting — it will pick up the new event
129+ false
130+ } else {
131+ entry. pending = true ;
132+ true
133+ }
134+ } ;
135+
136+ if !should_spawn {
137+ return ;
138+ }
139+
140+ // Spawn a single debounce task for this file
141+ let path = path. clone ( ) ;
142+ let socket = socket. clone ( ) ;
143+ let file2code = file2code. clone ( ) ;
144+ let socket2data = socket2data. clone ( ) ;
145+ let file_states = file_states. clone ( ) ;
146+ let git_manager = git_manager. clone ( ) ;
147+
148+ // Get the notify handle for this file
149+ let file_notify = {
107150 let states = file_states. lock ( ) . await ;
108- if let Some ( watch_state) = states. get ( path_str) {
109- if watch_state. last_event_time . elapsed ( ) < Duration :: from_millis ( 100 ) {
110- info ! ( "Debouncing event for path: {:?}" , path) ;
111- return ;
151+ states. get ( & path_str) . unwrap ( ) . notify . clone ( )
152+ } ;
153+
154+ tokio:: spawn ( async move {
155+ // Wait until events stop arriving (trailing-edge debounce)
156+ loop {
157+ match tokio:: time:: timeout ( DEBOUNCE , file_notify. notified ( ) ) . await {
158+ Ok ( _) => continue , // new event arrived — reset timer
159+ Err ( _) => break , // timeout — silence, time to process
112160 }
113161 }
114- }
115162
116- // Wait 100ms before checking the actual state
117- sleep ( Duration :: from_millis ( 100 ) ) . await ;
163+ process_watch_event (
164+ & path, & path_str, & socket, & file2code, & socket2data, & file_states, & git_manager,
165+ ) . await ;
166+
167+ // Mark as not pending so future events spawn a new task
168+ {
169+ let mut states = file_states. lock ( ) . await ;
170+ if let Some ( state) = states. get_mut ( & path_str) {
171+ state. pending = false ;
172+ }
173+ }
174+ } ) ;
175+ }
118176
177+ async fn process_watch_event (
178+ path : & PathBuf ,
179+ path_str : & str ,
180+ socket : & Arc < socketioxide:: SocketIo > ,
181+ file2code : & Arc < Mutex < HashMap < String , Code > > > ,
182+ socket2data : & Arc < Mutex < HashMap < String , SocketData > > > ,
183+ file_states : & Arc < Mutex < HashMap < String , FileWatchState > > > ,
184+ git_manager : & Arc < Mutex < crate :: git:: GitManager > > ,
185+ ) {
119186 let current_state = if path. exists ( ) {
120187 FileState :: Exists
121188 } else {
122189 FileState :: DoesNotExist
123190 } ;
124191
125192 let last_state = {
126- let mut states = file_states. lock ( ) . await ;
127- let watch_state = states. entry ( path_str. to_string ( ) ) . or_insert ( FileWatchState {
128- state : if path. exists ( ) { FileState :: Exists } else { FileState :: DoesNotExist } ,
129- last_event_time : Instant :: now ( ) ,
130- } ) ;
131- let last = watch_state. state . clone ( ) ;
132- watch_state. last_event_time = Instant :: now ( ) ;
133- last
193+ let states = file_states. lock ( ) . await ;
194+ states. get ( path_str)
195+ . map ( |s| s. state . clone ( ) )
196+ . unwrap_or ( FileState :: DoesNotExist ) // never seen = didn't exist for us
134197 } ;
135198
136199 info ! ( "File state transition: {:?} -> {:?} for path: {:?}" , last_state, current_state, path) ;
137200
138- // Only handle when the state changes
139201 match ( & last_state, & current_state) {
140202 ( & FileState :: DoesNotExist , & FileState :: Exists ) => {
141- // File created/restored
142203 handle_create_remove_event (
143204 path,
144205 path_str,
@@ -148,7 +209,6 @@ pub async fn handle_watch_event(
148209 ) . await ;
149210 } ,
150211 ( & FileState :: Exists , & FileState :: DoesNotExist ) => {
151- // File removed
152212 handle_create_remove_event (
153213 path,
154214 path_str,
@@ -158,24 +218,21 @@ pub async fn handle_watch_event(
158218 ) . await ;
159219 } ,
160220 ( & FileState :: Exists , & FileState :: Exists ) => {
161- // File exists - treat as modify
162221 handle_modify_event ( path, path_str, socket, file2code, socket2data) . await ;
163222 } ,
164223 _ => {
165224 info ! ( "Ignoring state transition: {:?} -> {:?}" , last_state, current_state) ;
166225 }
167226 }
168227
169- // Update state
228+ // Update state (or remove if file was deleted to prevent memory leak)
170229 {
171230 let mut states = file_states. lock ( ) . await ;
172- states. insert (
173- path_str. to_string ( ) ,
174- FileWatchState {
175- state : current_state,
176- last_event_time : Instant :: now ( ) ,
177- } ,
178- ) ;
231+ if current_state == FileState :: DoesNotExist {
232+ states. remove ( path_str) ;
233+ } else if let Some ( watch_state) = states. get_mut ( path_str) {
234+ watch_state. state = current_state;
235+ }
179236 }
180237
181238 // Check git status
@@ -191,7 +248,6 @@ pub async fn handle_watch_event(
191248 } ;
192249
193250 if let Some ( status) = new_status {
194- // Emit to all clients
195251 let _ = socket. emit ( "git:status-update" , & status. to_json ( ) ) . await ;
196252 }
197253 }
0 commit comments