@kadi.build/file-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,611 @@
1
+ import chokidar from 'chokidar';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { EventEmitter } from 'events';
5
+
6
+ class WatchProvider extends EventEmitter {
7
+ constructor(config) {
8
+ super();
9
+ this.config = config || {};
10
+ this.localRoot = this.config.localRoot || process.cwd();
11
+ this.enabled = this.config.enabled !== false; // Default true
12
+ this.recursive = this.config.recursive !== false; // Default true
13
+ this.ignoreDotfiles = this.config.ignoreDotfiles !== false; // Default true
14
+ this.debounceMs = this.config.debounceMs || 100;
15
+ this.maxWatchers = this.config.maxWatchers || 50;
16
+
17
+ // Active watchers registry
18
+ this.watchers = new Map();
19
+ this.debounceTimers = new Map();
20
+ this.watchCount = 0;
21
+ }
22
+
23
+ // ============================================================================
24
+ // CONNECTION AND VALIDATION
25
+ // ============================================================================
26
+
27
+ async testConnection() {
28
+ try {
29
+ const stats = await fs.stat(this.localRoot);
30
+ if (!stats.isDirectory()) {
31
+ throw new Error(`Local root '${this.localRoot}' is not a directory`);
32
+ }
33
+
34
+ const testWatcher = chokidar.watch(this.localRoot, {
35
+ ignored: () => true,
36
+ persistent: false,
37
+ ignoreInitial: true
38
+ });
39
+
40
+ await new Promise((resolve, reject) => {
41
+ const timeout = setTimeout(() => {
42
+ reject(new Error('Chokidar initialization timeout'));
43
+ }, 5000);
44
+
45
+ testWatcher.on('ready', () => {
46
+ clearTimeout(timeout);
47
+ testWatcher.close();
48
+ resolve();
49
+ });
50
+
51
+ testWatcher.on('error', (error) => {
52
+ clearTimeout(timeout);
53
+ reject(error);
54
+ });
55
+ });
56
+
57
+ return {
58
+ provider: 'watch',
59
+ localRoot: this.localRoot,
60
+ enabled: this.enabled,
61
+ recursive: this.recursive,
62
+ activeWatchers: this.watchers.size,
63
+ maxWatchers: this.maxWatchers,
64
+ debounceMs: this.debounceMs
65
+ };
66
+ } catch (error) {
67
+ throw new Error(`Watch provider connection test failed: ${error.message}`);
68
+ }
69
+ }
70
+
71
+ validateConfig() {
72
+ const errors = [];
73
+ const warnings = [];
74
+
75
+ if (!this.localRoot) {
76
+ errors.push('Local root directory is required for watching');
77
+ }
78
+
79
+ if (this.debounceMs < 0) {
80
+ errors.push('Debounce time must be non-negative');
81
+ }
82
+
83
+ if (this.maxWatchers <= 0) {
84
+ errors.push('Max watchers must be positive');
85
+ }
86
+
87
+ if (this.debounceMs > 10000) {
88
+ warnings.push('Very high debounce time (>10s) may delay notifications significantly');
89
+ }
90
+
91
+ if (this.maxWatchers > 100) {
92
+ warnings.push('Very high max watchers (>100) may impact performance');
93
+ }
94
+
95
+ if (!this.enabled) {
96
+ warnings.push('File watching is disabled');
97
+ }
98
+
99
+ return {
100
+ isValid: errors.length === 0,
101
+ errors,
102
+ warnings
103
+ };
104
+ }
105
+
106
+ // ============================================================================
107
+ // PATH MANAGEMENT METHODS
108
+ // ============================================================================
109
+
110
+ normalizePath(inputPath) {
111
+ if (!inputPath || inputPath === '/') {
112
+ return this.localRoot;
113
+ }
114
+
115
+ if (path.isAbsolute(inputPath)) {
116
+ return path.normalize(inputPath);
117
+ }
118
+
119
+ const resolvedLocalRoot = path.resolve(this.localRoot);
120
+ return path.resolve(resolvedLocalRoot, inputPath);
121
+ }
122
+
123
+ validatePath(inputPath) {
124
+ if (!inputPath) {
125
+ throw new Error('Path cannot be empty');
126
+ }
127
+
128
+ if (/[<>:"|?*\x00-\x1f]/.test(inputPath)) {
129
+ throw new Error(`Path contains invalid characters: ${inputPath}`);
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ generateWatchId(watchPath, options = {}) {
136
+ const normalizedPath = this.normalizePath(watchPath);
137
+ const optionsHash = JSON.stringify(options);
138
+ return `${normalizedPath}:${optionsHash}`;
139
+ }
140
+
141
+ // ============================================================================
142
+ // CORE WATCHING METHODS
143
+ // ============================================================================
144
+
145
+ async startWatching(watchPath, options = {}) {
146
+ this.validatePath(watchPath);
147
+
148
+ if (!this.enabled) {
149
+ throw new Error('File watching is disabled in configuration');
150
+ }
151
+
152
+ const normalizedPath = this.normalizePath(watchPath);
153
+
154
+ // Check if path exists
155
+ try {
156
+ const stats = await fs.stat(normalizedPath);
157
+ if (!stats.isDirectory() && !stats.isFile()) {
158
+ throw new Error(`Path '${watchPath}' is neither a file nor directory`);
159
+ }
160
+ } catch (error) {
161
+ if (error.code === 'ENOENT') {
162
+ throw new Error(`Path not found: ${watchPath}`);
163
+ }
164
+ throw error;
165
+ }
166
+
167
+ const watchId = this.generateWatchId(watchPath, options);
168
+
169
+ // Check if already watching
170
+ if (this.watchers.has(watchId)) {
171
+ return {
172
+ watchId,
173
+ path: watchPath,
174
+ alreadyWatching: true
175
+ };
176
+ }
177
+
178
+ // Check watcher limit
179
+ if (this.watchCount >= this.maxWatchers) {
180
+ throw new Error(`Maximum number of watchers (${this.maxWatchers}) reached`);
181
+ }
182
+
183
+ const {
184
+ recursive = this.recursive,
185
+ ignoreDotfiles = this.ignoreDotfiles,
186
+ events = ['add', 'change', 'unlink', 'addDir', 'unlinkDir'],
187
+ callback = null,
188
+ persistent = true
189
+ } = options;
190
+
191
+ try {
192
+ const ignorePattern = this.buildIgnorePattern(ignoreDotfiles, recursive, normalizedPath);
193
+
194
+ const watcher = chokidar.watch(normalizedPath, {
195
+ ignored: ignorePattern,
196
+ persistent: persistent,
197
+ ignoreInitial: true,
198
+ followSymlinks: false,
199
+ disableGlobbing: true,
200
+ usePolling: false,
201
+ interval: 100,
202
+ binaryInterval: 300,
203
+ ignorePermissionErrors: true
204
+ });
205
+
206
+ // Set up event handlers
207
+ this.setupWatcherEvents(watcher, watchId, watchPath, events, callback);
208
+
209
+ // Wait for watcher to be ready
210
+ await new Promise((resolve, reject) => {
211
+ const timeout = setTimeout(() => {
212
+ reject(new Error('Watcher initialization timeout'));
213
+ }, 10000);
214
+
215
+ watcher.on('ready', () => {
216
+ clearTimeout(timeout);
217
+ resolve();
218
+ });
219
+
220
+ watcher.on('error', (error) => {
221
+ clearTimeout(timeout);
222
+ reject(error);
223
+ });
224
+ });
225
+
226
+ // Store watcher info
227
+ this.watchers.set(watchId, {
228
+ watcher,
229
+ path: watchPath,
230
+ normalizedPath,
231
+ options: {
232
+ recursive: recursive,
233
+ ignoreDotfiles: ignoreDotfiles,
234
+ events: events,
235
+ persistent: persistent
236
+ },
237
+ callback,
238
+ startedAt: new Date().toISOString(),
239
+ eventCount: 0
240
+ });
241
+
242
+ this.watchCount++;
243
+
244
+ return {
245
+ watchId,
246
+ path: watchPath,
247
+ recursive,
248
+ events,
249
+ startedAt: new Date().toISOString()
250
+ };
251
+
252
+ } catch (error) {
253
+ throw new Error(`Failed to start watching ${watchPath}: ${error.message}`);
254
+ }
255
+ }
256
+
257
+ async stopWatching(watchIdOrPath) {
258
+ let watchId;
259
+
260
+ if (this.watchers.has(watchIdOrPath)) {
261
+ watchId = watchIdOrPath;
262
+ } else {
263
+ watchId = this.findWatcherByPath(watchIdOrPath);
264
+ if (!watchId) {
265
+ throw new Error(`No active watcher found for: ${watchIdOrPath}`);
266
+ }
267
+ }
268
+
269
+ const watcherInfo = this.watchers.get(watchId);
270
+ if (!watcherInfo) {
271
+ throw new Error(`Watcher not found: ${watchId}`);
272
+ }
273
+
274
+ try {
275
+ await watcherInfo.watcher.close();
276
+
277
+ this.cleanupDebounceTimers(watchId);
278
+
279
+ this.watchers.delete(watchId);
280
+ this.watchCount--;
281
+
282
+ return {
283
+ watchId,
284
+ path: watcherInfo.path,
285
+ stopped: true,
286
+ eventCount: watcherInfo.eventCount,
287
+ duration: Date.now() - new Date(watcherInfo.startedAt).getTime()
288
+ };
289
+
290
+ } catch (error) {
291
+ throw new Error(`Failed to stop watching: ${error.message}`);
292
+ }
293
+ }
294
+
295
+ async stopAllWatching() {
296
+ const results = [];
297
+ const watchIds = Array.from(this.watchers.keys());
298
+
299
+ for (const watchId of watchIds) {
300
+ try {
301
+ const result = await this.stopWatching(watchId);
302
+ results.push(result);
303
+ } catch (error) {
304
+ results.push({
305
+ watchId,
306
+ error: error.message
307
+ });
308
+ }
309
+ }
310
+
311
+ this.debounceTimers.clear();
312
+
313
+ return {
314
+ stopped: results.filter(r => r.stopped).length,
315
+ failed: results.filter(r => r.error).length,
316
+ results
317
+ };
318
+ }
319
+
320
+ // ============================================================================
321
+ // WATCHER MANAGEMENT METHODS
322
+ // ============================================================================
323
+
324
+ setupWatcherEvents(watcher, watchId, watchPath, events, callback) {
325
+ const eventHandlers = {
326
+ add: (filePath) => this.handleFileEvent('add', filePath, watchId, watchPath, callback),
327
+ change: (filePath) => this.handleFileEvent('change', filePath, watchId, watchPath, callback),
328
+ unlink: (filePath) => this.handleFileEvent('unlink', filePath, watchId, watchPath, callback),
329
+ addDir: (dirPath) => this.handleFileEvent('addDir', dirPath, watchId, watchPath, callback),
330
+ unlinkDir: (dirPath) => this.handleFileEvent('unlinkDir', dirPath, watchId, watchPath, callback)
331
+ };
332
+
333
+ events.forEach(eventType => {
334
+ if (eventHandlers[eventType]) {
335
+ watcher.on(eventType, eventHandlers[eventType]);
336
+ }
337
+ });
338
+
339
+ watcher.on('error', (error) => {
340
+ this.emit('watcherError', {
341
+ watchId,
342
+ path: watchPath,
343
+ error: error.message
344
+ });
345
+ });
346
+ }
347
+
348
+ handleFileEvent(eventType, filePath, watchId, watchPath, callback) {
349
+ const watcherInfo = this.watchers.get(watchId);
350
+ if (!watcherInfo) {
351
+ return;
352
+ }
353
+
354
+ // Non-recursive safety filter
355
+ const isNonRecursive = watcherInfo.options.recursive === false;
356
+
357
+ if (isNonRecursive) {
358
+ const watchedPath = path.resolve(watcherInfo.normalizedPath);
359
+ const eventPath = path.resolve(filePath);
360
+ const eventParentDir = path.dirname(eventPath);
361
+
362
+ if (eventParentDir !== watchedPath) {
363
+ return; // Block nested events
364
+ }
365
+ }
366
+
367
+ // Increment event counter
368
+ watcherInfo.eventCount = (watcherInfo.eventCount || 0) + 1;
369
+
370
+ // Create event data
371
+ const eventData = {
372
+ type: eventType,
373
+ path: filePath,
374
+ relativePath: path.relative(watcherInfo.normalizedPath, filePath),
375
+ watchId,
376
+ watchPath,
377
+ timestamp: new Date().toISOString()
378
+ };
379
+
380
+ // Apply debouncing
381
+ if (this.debounceMs > 0) {
382
+ this.debounceEvent(eventData, callback);
383
+ } else {
384
+ this.processEvent(eventData, callback);
385
+ }
386
+ }
387
+
388
+ debounceEvent(eventData, callback) {
389
+ const debounceKey = `${eventData.watchId}:${eventData.path}:${eventData.type}`;
390
+
391
+ if (this.debounceTimers.has(debounceKey)) {
392
+ clearTimeout(this.debounceTimers.get(debounceKey));
393
+ }
394
+
395
+ const timer = setTimeout(() => {
396
+ this.debounceTimers.delete(debounceKey);
397
+ this.processEvent(eventData, callback);
398
+ }, this.debounceMs);
399
+
400
+ this.debounceTimers.set(debounceKey, timer);
401
+ }
402
+
403
+ processEvent(eventData, callback) {
404
+ this.emit('fileEvent', eventData);
405
+
406
+ if (callback && typeof callback === 'function') {
407
+ try {
408
+ callback(eventData);
409
+ } catch (error) {
410
+ this.emit('watcherError', {
411
+ watchId: eventData.watchId,
412
+ path: eventData.path,
413
+ error: `Callback error: ${error.message}`
414
+ });
415
+ }
416
+ }
417
+ }
418
+
419
+ // ============================================================================
420
+ // UTILITY AND QUERY METHODS
421
+ // ============================================================================
422
+
423
+ buildIgnorePattern(ignoreDotfiles, isRecursive = true, watchedPath = null) {
424
+ const patterns = [];
425
+
426
+ if (ignoreDotfiles) {
427
+ patterns.push(/(^|[\/\\])\../);
428
+ }
429
+
430
+ // Always ignore common system files
431
+ patterns.push(/node_modules/);
432
+ patterns.push(/\.git/);
433
+ patterns.push(/\.DS_Store/);
434
+ patterns.push(/Thumbs\.db/);
435
+
436
+ // For non-recursive, add a pattern that ignores anything in subdirectories
437
+ if (!isRecursive && watchedPath) {
438
+ const resolvedWatchedPath = path.resolve(watchedPath);
439
+ const nestedPattern = new RegExp(
440
+ resolvedWatchedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
441
+ '[\\\\/][^\\\\\/]+[\\\\/]'
442
+ );
443
+ patterns.push(nestedPattern);
444
+ }
445
+
446
+ return patterns.length > 0 ? patterns : null;
447
+ }
448
+
449
+ findWatcherByPath(searchPath) {
450
+ const normalizedSearch = this.normalizePath(searchPath);
451
+
452
+ for (const [watchId, watcherInfo] of this.watchers) {
453
+ if (watcherInfo.normalizedPath === normalizedSearch ||
454
+ watcherInfo.path === searchPath) {
455
+ return watchId;
456
+ }
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ cleanupDebounceTimers(watchId) {
463
+ const keysToDelete = [];
464
+
465
+ for (const [key, timer] of this.debounceTimers) {
466
+ if (key.startsWith(watchId + ':')) {
467
+ clearTimeout(timer);
468
+ keysToDelete.push(key);
469
+ }
470
+ }
471
+
472
+ keysToDelete.forEach(key => this.debounceTimers.delete(key));
473
+ }
474
+
475
+ getEventIcon(eventType) {
476
+ const icons = {
477
+ add: '📄',
478
+ change: '✏️',
479
+ unlink: '🗑️',
480
+ addDir: '📁',
481
+ unlinkDir: '🗂️'
482
+ };
483
+ return icons[eventType] || '📋';
484
+ }
485
+
486
+ // ============================================================================
487
+ // INFORMATION AND STATUS METHODS
488
+ // ============================================================================
489
+
490
+ listActiveWatchers() {
491
+ const watchers = [];
492
+
493
+ for (const [watchId, watcherInfo] of this.watchers) {
494
+ watchers.push({
495
+ watchId,
496
+ path: watcherInfo.path,
497
+ recursive: watcherInfo.options.recursive,
498
+ events: watcherInfo.options.events,
499
+ startedAt: watcherInfo.startedAt,
500
+ eventCount: watcherInfo.eventCount || 0,
501
+ duration: Date.now() - new Date(watcherInfo.startedAt).getTime()
502
+ });
503
+ }
504
+
505
+ return watchers;
506
+ }
507
+
508
+ getWatcherInfo(watchIdOrPath) {
509
+ let watchId;
510
+
511
+ if (this.watchers.has(watchIdOrPath)) {
512
+ watchId = watchIdOrPath;
513
+ } else {
514
+ watchId = this.findWatcherByPath(watchIdOrPath);
515
+ if (!watchId) {
516
+ throw new Error(`Watcher not found: ${watchIdOrPath}`);
517
+ }
518
+ }
519
+
520
+ const watcherInfo = this.watchers.get(watchId);
521
+ if (!watcherInfo) {
522
+ throw new Error(`Watcher not found: ${watchId}`);
523
+ }
524
+
525
+ return {
526
+ watchId,
527
+ path: watcherInfo.path,
528
+ normalizedPath: watcherInfo.normalizedPath,
529
+ options: watcherInfo.options,
530
+ startedAt: watcherInfo.startedAt,
531
+ eventCount: watcherInfo.eventCount || 0,
532
+ duration: Date.now() - new Date(watcherInfo.startedAt).getTime(),
533
+ hasCallback: !!watcherInfo.callback
534
+ };
535
+ }
536
+
537
+ getWatchingStatus() {
538
+ return {
539
+ enabled: this.enabled,
540
+ activeWatchers: this.watchers.size,
541
+ maxWatchers: this.maxWatchers,
542
+ totalEvents: Array.from(this.watchers.values()).reduce((sum, w) => sum + (w.eventCount || 0), 0),
543
+ pendingDebounces: this.debounceTimers.size,
544
+ config: {
545
+ recursive: this.recursive,
546
+ ignoreDotfiles: this.ignoreDotfiles,
547
+ debounceMs: this.debounceMs
548
+ }
549
+ };
550
+ }
551
+
552
+ // ============================================================================
553
+ // ERROR HANDLING HELPERS
554
+ // ============================================================================
555
+
556
+ isPathNotFoundError(error) {
557
+ return error.code === 'ENOENT' ||
558
+ error.message.includes('not found') ||
559
+ error.message.includes('no such file');
560
+ }
561
+
562
+ isPermissionError(error) {
563
+ return error.code === 'EACCES' ||
564
+ error.code === 'EPERM' ||
565
+ error.message.includes('permission denied');
566
+ }
567
+
568
+ isWatchingError(error) {
569
+ return error.message.includes('watch') ||
570
+ error.message.includes('EMFILE') ||
571
+ error.message.includes('ENOSPC');
572
+ }
573
+
574
+ // ============================================================================
575
+ // CLEANUP AND SHUTDOWN
576
+ // ============================================================================
577
+
578
+ async shutdown() {
579
+ try {
580
+ const result = await this.stopAllWatching();
581
+ this.removeAllListeners();
582
+ return result;
583
+ } catch (error) {
584
+ throw error;
585
+ }
586
+ }
587
+
588
+ formatBytes(bytes) {
589
+ if (bytes === 0) return '0 Bytes';
590
+ const k = 1024;
591
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
592
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
593
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
594
+ }
595
+
596
+ formatDuration(milliseconds) {
597
+ const seconds = Math.floor(milliseconds / 1000);
598
+ const minutes = Math.floor(seconds / 60);
599
+ const hours = Math.floor(minutes / 60);
600
+
601
+ if (hours > 0) {
602
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
603
+ } else if (minutes > 0) {
604
+ return `${minutes}m ${seconds % 60}s`;
605
+ } else {
606
+ return `${seconds}s`;
607
+ }
608
+ }
609
+ }
610
+
611
+ export { WatchProvider };