/// Recursive file watcher with ignore-file filtering. /// /// Wraps `Directory.watch(recursive: true)` on Linux (inotify) and /// macOS (FSEvents). Events emit with [IgnoreSet] filtering applied so /// the tree view doesn't flicker on changes inside `.dart_tool/` / /// `node_modules/` / etc. library; import 'dart:async'; import 'dart:io'; import 'ignore.dart'; /// Kind of filesystem change. Mirrors Dart's `FileSystemEvent` but in /// a form that survives serialisation over IPC. enum FileChangeKind { created, deleted, modified, renamed; String get wire => name; static FileChangeKind fromEvent(FileSystemEvent e) { switch (e.type) { case FileSystemEvent.create: return FileChangeKind.created; case FileSystemEvent.delete: return FileChangeKind.deleted; case FileSystemEvent.modify: return FileChangeKind.modified; case FileSystemEvent.move: return FileChangeKind.renamed; default: return FileChangeKind.modified; } } } class FileChange { const FileChange({required this.kind, required this.path, required this.isDirectory}); final FileChangeKind kind; /// Repo-relative path, forward-slashed. final String path; final bool isDirectory; Map toJson() => {'kind': kind.wire, 'path': path, 'isDirectory': isDirectory}; } class FileWatcher { FileWatcher({required this.root, required this.ignore}); final Directory root; final IgnoreSet ignore; StreamSubscription? _sub; final _controller = StreamController.broadcast(); Stream get stream => _controller.stream; Future start() async { if (_sub != null) return; _sub = root.watch(recursive: true).listen(_onEvent, onError: (Object e, StackTrace _) => _controller.addError(e)); } Future stop() async { await _sub?.cancel(); _sub = null; await _controller.close(); } void _onEvent(FileSystemEvent e) { final rel = _toRelative(e.path); if (rel == null) return; final isDir = e.isDirectory; if (_ignored(rel, isDir)) return; _controller.add(FileChange(kind: FileChangeKind.fromEvent(e), path: rel, isDirectory: isDir)); } /// True if `rel` is ignored, or sits inside an ignored directory. /// A `foo/` rule hides the directory *and everything under it*, but a /// recursive watch still delivers events for those descendants — notably /// macOS FSEvents, which reports nested creates that inotify often drops. /// So check each ancestor segment as a directory, not just the leaf. bool _ignored(String rel, bool isDir) { if (ignore.isIgnored(rel, isDirectory: isDir)) return true; for (var slash = rel.indexOf('/'); slash != -1; slash = rel.indexOf('/', slash + 1)) { if (ignore.isIgnored(rel.substring(0, slash), isDirectory: true)) return true; } return false; } String? _toRelative(String abs) { final rootPath = root.absolute.path; if (!abs.startsWith(rootPath)) return null; var rel = abs.substring(rootPath.length); if (rel.startsWith(Platform.pathSeparator)) { rel = rel.substring(1); } return rel.replaceAll(Platform.pathSeparator, '/'); } }