@@ -48,17 +48,22 @@ function isIgnoredByPatterns(name: string, patterns: string[]): boolean {
4848 return false ;
4949}
5050
51- function listFilesWalk ( cwd : string , maxFiles = 10000 ) : string [ ] {
51+ function yieldToEventLoop ( ) : Promise < void > {
52+ return new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
53+ }
54+
55+ async function listFilesWalk ( cwd : string , maxFiles = 10000 ) : Promise < string [ ] > {
5256 const files : string [ ] = [ ] ;
5357 const queue : string [ ] = [ "" ] ;
58+ let visitedDirs = 0 ;
5459
5560 while ( queue . length > 0 && files . length < maxFiles ) {
5661 const rel = queue . shift ( ) ! ;
5762 const abs = rel ? path . join ( cwd , rel ) : cwd ;
5863
5964 let entries : fs . Dirent [ ] ;
6065 try {
61- entries = fs . readdirSync ( abs , { withFileTypes : true } ) ;
66+ entries = await fs . promises . readdir ( abs , { withFileTypes : true } ) ;
6267 } catch {
6368 continue ;
6469 }
@@ -78,6 +83,11 @@ function listFilesWalk(cwd: string, maxFiles = 10000): string[] {
7883 files . push ( entryRel ) ;
7984 }
8085 }
86+
87+ visitedDirs += 1 ;
88+ if ( visitedDirs % 25 === 0 ) {
89+ await yieldToEventLoop ( ) ;
90+ }
8191 }
8292
8393 return files . sort ( ) ;
@@ -88,119 +98,27 @@ async function listProjectFiles(cwd: string): Promise<string[]> {
8898 return await listFilesGit ( cwd ) ;
8999 } catch {
90100 log ( "FILES:LIST" , "Not a git repo, falling back to filesystem walk" ) ;
91- return listFilesWalk ( cwd ) ;
101+ return await listFilesWalk ( cwd ) ;
92102 }
93103}
94104
95105/** Dirs to skip in the full filesystem walk (VCS internals + massive dependency dirs). */
96106const EXPLORER_SKIP = new Set ( [ ".git" , ".hg" , ".svn" , "node_modules" ] ) ;
97107
108+ // ── Recursive file watcher ──
109+ // Uses a single fs.watch(cwd, { recursive: true }) per project root.
110+ // macOS (FSEvents) and Windows (ReadDirectoryChangesW) handle this natively
111+ // with one kernel-level watcher for the entire subtree — no directory walking,
112+ // no thousands of individual watchers, instant setup and teardown.
113+
98114interface ProjectWatchState {
99115 refCount : number ;
100- watchers : Map < string , fs . FSWatcher > ;
116+ watcher : fs . FSWatcher ;
101117 notifyTimer ?: ReturnType < typeof setTimeout > ;
102- syncTimer ?: ReturnType < typeof setTimeout > ;
103118}
104119
105120const projectWatchers = new Map < string , ProjectWatchState > ( ) ;
106121
107- function collectWatchDirs ( cwd : string , maxDirs = 5000 ) : string [ ] {
108- const dirs = [ cwd ] ;
109- const queue = [ cwd ] ;
110-
111- while ( queue . length > 0 && dirs . length < maxDirs ) {
112- const dir = queue . shift ( ) ! ;
113-
114- let entries : fs . Dirent [ ] ;
115- try {
116- entries = fs . readdirSync ( dir , { withFileTypes : true } ) ;
117- } catch {
118- continue ;
119- }
120-
121- for ( const entry of entries ) {
122- if ( ! entry . isDirectory ( ) || EXPLORER_SKIP . has ( entry . name ) ) continue ;
123- const childDir = path . join ( dir , entry . name ) ;
124- dirs . push ( childDir ) ;
125- queue . push ( childDir ) ;
126- if ( dirs . length >= maxDirs ) break ;
127- }
128- }
129-
130- return dirs ;
131- }
132-
133- function closeProjectWatchers ( state : ProjectWatchState ) : void {
134- if ( state . notifyTimer ) clearTimeout ( state . notifyTimer ) ;
135- if ( state . syncTimer ) clearTimeout ( state . syncTimer ) ;
136- for ( const watcher of state . watchers . values ( ) ) {
137- watcher . close ( ) ;
138- }
139- state . watchers . clear ( ) ;
140- }
141-
142- function scheduleWatcherNotify (
143- cwd : string ,
144- getMainWindow : ( ) => BrowserWindow | null ,
145- ) : void {
146- const state = projectWatchers . get ( cwd ) ;
147- if ( ! state || state . notifyTimer ) return ;
148-
149- state . notifyTimer = setTimeout ( ( ) => {
150- const current = projectWatchers . get ( cwd ) ;
151- if ( ! current ) return ;
152- current . notifyTimer = undefined ;
153- safeSend ( getMainWindow , "files:changed" , { cwd } ) ;
154- } , 150 ) ;
155- }
156-
157- function syncProjectWatchers (
158- cwd : string ,
159- getMainWindow : ( ) => BrowserWindow | null ,
160- ) : void {
161- const state = projectWatchers . get ( cwd ) ;
162- if ( ! state ) return ;
163-
164- const nextDirs = new Set ( collectWatchDirs ( cwd ) ) ;
165-
166- for ( const [ dir , watcher ] of state . watchers ) {
167- if ( nextDirs . has ( dir ) ) continue ;
168- watcher . close ( ) ;
169- state . watchers . delete ( dir ) ;
170- }
171-
172- for ( const dir of nextDirs ) {
173- if ( state . watchers . has ( dir ) ) continue ;
174- try {
175- const watcher = fs . watch ( dir , { persistent : false } , ( ) => {
176- scheduleWatcherNotify ( cwd , getMainWindow ) ;
177- scheduleWatcherSync ( cwd , getMainWindow ) ;
178- } ) ;
179- watcher . on ( "error" , ( ) => {
180- scheduleWatcherSync ( cwd , getMainWindow ) ;
181- } ) ;
182- state . watchers . set ( dir , watcher ) ;
183- } catch {
184- // Ignore transient watch failures; the next sync will retry.
185- }
186- }
187- }
188-
189- function scheduleWatcherSync (
190- cwd : string ,
191- getMainWindow : ( ) => BrowserWindow | null ,
192- ) : void {
193- const state = projectWatchers . get ( cwd ) ;
194- if ( ! state || state . syncTimer ) return ;
195-
196- state . syncTimer = setTimeout ( ( ) => {
197- const current = projectWatchers . get ( cwd ) ;
198- if ( ! current ) return ;
199- current . syncTimer = undefined ;
200- syncProjectWatchers ( cwd , getMainWindow ) ;
201- } , 250 ) ;
202- }
203-
204122function startProjectWatcher (
205123 cwd : string ,
206124 getMainWindow : ( ) => BrowserWindow | null ,
@@ -211,12 +129,31 @@ function startProjectWatcher(
211129 return ;
212130 }
213131
214- const state : ProjectWatchState = {
215- refCount : 1 ,
216- watchers : new Map ( ) ,
217- } ;
218- projectWatchers . set ( cwd , state ) ;
219- syncProjectWatchers ( cwd , getMainWindow ) ;
132+ const watcher = fs . watch ( cwd , { recursive : true , persistent : false } , ( _eventType , filename ) => {
133+ // Ignore changes in directories we don't care about (node_modules, .git, etc.)
134+ if ( filename ) {
135+ const firstSegment = filename . split ( path . sep ) [ 0 ] ;
136+ if ( ALWAYS_SKIP . has ( firstSegment ) || firstSegment . startsWith ( "." ) ) return ;
137+ }
138+
139+ const state = projectWatchers . get ( cwd ) ;
140+ if ( ! state || state . notifyTimer ) return ;
141+
142+ // Debounce: coalesce rapid changes into a single notification
143+ state . notifyTimer = setTimeout ( ( ) => {
144+ const current = projectWatchers . get ( cwd ) ;
145+ if ( ! current ) return ;
146+ current . notifyTimer = undefined ;
147+ safeSend ( getMainWindow , "files:changed" , { cwd } ) ;
148+ } , 200 ) ;
149+ } ) ;
150+
151+ watcher . on ( "error" , ( ) => {
152+ // Watcher died (directory deleted, permissions, etc.) — clean up silently
153+ stopProjectWatcher ( cwd ) ;
154+ } ) ;
155+
156+ projectWatchers . set ( cwd , { refCount : 1 , watcher } ) ;
220157}
221158
222159function stopProjectWatcher ( cwd : string ) : void {
@@ -226,7 +163,8 @@ function stopProjectWatcher(cwd: string): void {
226163 state . refCount = Math . max ( 0 , state . refCount - 1 ) ;
227164 if ( state . refCount > 0 ) return ;
228165
229- closeProjectWatchers ( state ) ;
166+ if ( state . notifyTimer ) clearTimeout ( state . notifyTimer ) ;
167+ state . watcher . close ( ) ;
230168 projectWatchers . delete ( cwd ) ;
231169}
232170
@@ -235,17 +173,18 @@ function stopProjectWatcher(cwd: string): void {
235173 * Only skips VCS internals and node_modules (too massive).
236174 * Used by the "Project Files" explorer panel.
237175 */
238- function listAllFiles ( cwd : string , maxFiles = 10000 ) : string [ ] {
176+ async function listAllFiles ( cwd : string , maxFiles = 10000 ) : Promise < string [ ] > {
239177 const files : string [ ] = [ ] ;
240178 const queue : string [ ] = [ "" ] ;
179+ let visitedDirs = 0 ;
241180
242181 while ( queue . length > 0 && files . length < maxFiles ) {
243182 const rel = queue . shift ( ) ! ;
244183 const abs = rel ? path . join ( cwd , rel ) : cwd ;
245184
246185 let entries : fs . Dirent [ ] ;
247186 try {
248- entries = fs . readdirSync ( abs , { withFileTypes : true } ) ;
187+ entries = await fs . promises . readdir ( abs , { withFileTypes : true } ) ;
249188 } catch {
250189 continue ;
251190 }
@@ -260,6 +199,11 @@ function listAllFiles(cwd: string, maxFiles = 10000): string[] {
260199 files . push ( entryRel ) ;
261200 }
262201 }
202+
203+ visitedDirs += 1 ;
204+ if ( visitedDirs % 25 === 0 ) {
205+ await yieldToEventLoop ( ) ;
206+ }
263207 }
264208
265209 return files . sort ( ) ;
@@ -348,7 +292,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
348292
349293 ipcMain . handle ( "files:list-all" , async ( _event , cwd : string ) => {
350294 try {
351- const files = listAllFiles ( cwd ) ;
295+ const files = await listAllFiles ( cwd ) ;
352296 const dirSet = new Set < string > ( ) ;
353297 for ( const file of files ) {
354298 const parts = file . split ( "/" ) ;
0 commit comments