From 733cdfadf7cd758b16a5ceade521d6516e2a076f Mon Sep 17 00:00:00 2001 From: David Morgan Date: Tue, 7 Oct 2025 09:56:54 +0200 Subject: [PATCH 1/3] Add package-private extension type Event on FileSystemEvent. --- .github/workflows/watcher.yaml | 2 +- .../lib/src/directory_watcher/linux.dart | 9 +- .../lib/src/directory_watcher/mac_os.dart | 87 +++++++++-------- .../lib/src/directory_watcher/windows.dart | 83 +++++++++-------- pkgs/watcher/lib/src/event.dart | 93 +++++++++++++++++++ pkgs/watcher/lib/src/file_watcher/native.dart | 10 +- pkgs/watcher/lib/src/utils.dart | 12 ++- pkgs/watcher/pubspec.yaml | 2 +- 8 files changed, 196 insertions(+), 102 deletions(-) create mode 100644 pkgs/watcher/lib/src/event.dart diff --git a/.github/workflows/watcher.yaml b/.github/workflows/watcher.yaml index 6a3b9ba70..0afb7079a 100644 --- a/.github/workflows/watcher.yaml +++ b/.github/workflows/watcher.yaml @@ -54,7 +54,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - sdk: [3.1, dev] + sdk: [3.3, dev] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart index 99e2cf50e..c09e1a536 100644 --- a/pkgs/watcher/lib/src/directory_watcher/linux.dart +++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:async/async.dart'; import '../directory_watcher.dart'; +import '../event.dart'; import '../path_set.dart'; import '../resubscribable.dart'; import '../utils.dart'; @@ -145,7 +146,7 @@ class _LinuxDirectoryWatcher } /// The callback that's run when a batch of changes comes in. - void _onBatch(List batch) { + void _onBatch(List batch) { var files = {}; var dirs = {}; var changed = {}; @@ -162,7 +163,7 @@ class _LinuxDirectoryWatcher changed.add(event.path); - if (event is FileSystemMoveEvent) { + if (event.isMove) { files.remove(event.path); dirs.remove(event.path); @@ -170,7 +171,7 @@ class _LinuxDirectoryWatcher if (destination == null) continue; changed.add(destination); - if (event.isDirectory) { + if (event.isDirectory!) { files.remove(destination); dirs.add(destination); } else { @@ -180,7 +181,7 @@ class _LinuxDirectoryWatcher } else if (event is FileSystemDeleteEvent) { files.remove(event.path); dirs.remove(event.path); - } else if (event.isDirectory) { + } else if (event.isDirectory!) { files.remove(event.path); dirs.add(event.path); } else { diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart index 509cf6fe6..cd9e47344 100644 --- a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart +++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../directory_watcher.dart'; +import '../event.dart'; import '../path_set.dart'; import '../resubscribable.dart'; import '../utils.dart'; @@ -63,7 +64,7 @@ class _MacOSDirectoryWatcher /// /// This is separate from [_listSubscriptions] because this stream /// occasionally needs to be resubscribed in order to work around issue 14849. - StreamSubscription>? _watchSubscription; + StreamSubscription>? _watchSubscription; /// The subscription to the [Directory.list] call for the initial listing of /// the directory to determine its initial state. @@ -109,7 +110,7 @@ class _MacOSDirectoryWatcher } /// The callback that's run when [Directory.watch] emits a batch of events. - void _onBatch(List batch) { + void _onBatch(List batch) { // If we get a batch of events before we're ready to begin emitting events, // it's probable that it's a batch of pre-watcher events (see issue 14373). // Ignore those events and re-list the directory. @@ -132,8 +133,8 @@ class _MacOSDirectoryWatcher : [canonicalEvent]; for (var event in events) { - if (event is FileSystemCreateEvent) { - if (!event.isDirectory) { + if (event.isCreate) { + if (!event.isDirectory!) { // If we already know about the file, treat it like a modification. // This can happen if a file is copied on top of an existing one. // We'll see an ADD event for the latter file when from the user's @@ -163,11 +164,11 @@ class _MacOSDirectoryWatcher }); subscription.onError(_emitError); _listSubscriptions.add(subscription); - } else if (event is FileSystemModifyEvent) { - assert(!event.isDirectory); + } else if (event.isModify) { + assert(!event.isDirectory!); _emitEvent(ChangeType.MODIFY, path); } else { - assert(event is FileSystemDeleteEvent); + assert(event.isDelete); for (var removedPath in _files.remove(path)) { _emitEvent(ChangeType.REMOVE, removedPath); } @@ -184,8 +185,8 @@ class _MacOSDirectoryWatcher /// /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it /// contain any events relating to [path]. - Map> _sortEvents(List batch) { - var eventsForPaths = >{}; + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; // FSEvents can report past events, including events on the root directory // such as it being created. We want to ignore these. If the directory is @@ -196,27 +197,21 @@ class _MacOSDirectoryWatcher // directory's full contents will be examined anyway, so we ignore such // events. Emitting them could cause useless or out-of-order events. var directories = unionAll(batch.map((event) { - if (!event.isDirectory) return {}; - if (event is FileSystemMoveEvent) { - var destination = event.destination; - if (destination != null) { - return {event.path, destination}; - } - } - return {event.path}; + if (event.isDelete || !event.isDirectory!) return {}; + return event.paths; })); bool isInModifiedDirectory(String path) => directories.any((dir) => path != dir && p.isWithin(dir, path)); - void addEvent(String path, FileSystemEvent event) { + void addEvent(String path, Event event) { if (isInModifiedDirectory(path)) return; - eventsForPaths.putIfAbsent(path, () => {}).add(event); + eventsForPaths.putIfAbsent(path, () => {}).add(event); } for (var event in batch) { // The Mac OS watcher doesn't emit move events. See issue 14806. - assert(event is! FileSystemMoveEvent); + assert(!event.isMove); addEvent(event.path, event); } @@ -233,39 +228,39 @@ class _MacOSDirectoryWatcher /// If [batch] does contain contradictory events, this returns `null` to /// indicate that the state of the path on the filesystem should be checked to /// determine what occurred. - FileSystemEvent? _canonicalEvent(Set batch) { + Event? _canonicalEvent(Set batch) { // An empty batch indicates that we've learned earlier that the batch is // contradictory (e.g. because of a move). if (batch.isEmpty) return null; var type = batch.first.type; - var isDir = batch.first.isDirectory; + var isDirectory = batch.first.isDirectory; var hadModifyEvent = false; for (var event in batch.skip(1)) { // If one event reports that the file is a directory and another event // doesn't, that's a contradiction. - if (isDir != event.isDirectory) return null; + if (isDirectory != event.isDirectory) return null; // Modify events don't contradict either CREATE or REMOVE events. We can // safely assume the file was modified after a CREATE or before the // REMOVE; otherwise there will also be a REMOVE or CREATE event // (respectively) that will be contradictory. - if (event is FileSystemModifyEvent) { + if (event.isModify) { hadModifyEvent = true; continue; } - assert(event is FileSystemCreateEvent || event is FileSystemDeleteEvent); + assert(event.isCreate || event.isDelete); // If we previously thought this was a MODIFY, we now consider it to be a // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == FileSystemEvent.modify) { + if (type == EventType.modify) { type = event.type; continue; } // A CREATE event contradicts a REMOVE event and vice versa. - assert(type == FileSystemEvent.create || type == FileSystemEvent.delete); + assert(type == EventType.create || type == EventType.delete); if (type != event.type) return null; } @@ -273,24 +268,26 @@ class _MacOSDirectoryWatcher // from FSEvents reporting an add that happened prior to the watch // beginning. If we also received a MODIFY event, we want to report that, // but not the CREATE. - if (type == FileSystemEvent.create && + if (type == EventType.create && hadModifyEvent && _files.contains(batch.first.path)) { - type = FileSystemEvent.modify; + type = EventType.modify; } switch (type) { - case FileSystemEvent.create: + case EventType.create: // Issue 16003 means that a CREATE event for a directory can indicate // that the directory was moved and then re-created. // [_eventsBasedOnFileSystem] will handle this correctly by producing a // DELETE event followed by a CREATE event if the directory exists. - if (isDir) return null; - return FileSystemCreateEvent(batch.first.path, false); - case FileSystemEvent.delete: - return FileSystemDeleteEvent(batch.first.path, isDir); - case FileSystemEvent.modify: - return FileSystemModifyEvent(batch.first.path, isDir, false); + if (isDirectory!) return null; + return Event.createFile(batch.first.path); + case EventType.delete: + return Event.delete(batch.first.path); + case EventType.modify: + return isDirectory! + ? Event.modifyDirectory(batch.first.path) + : Event.modifyFile(batch.first.path); default: throw StateError('unreachable'); } @@ -303,35 +300,35 @@ class _MacOSDirectoryWatcher /// to the user, unlike the batched events from [Directory.watch]. The /// returned list may be empty, indicating that no changes occurred to [path] /// (probably indicating that it was created and then immediately deleted). - List _eventsBasedOnFileSystem(String path) { + List _eventsBasedOnFileSystem(String path) { var fileExisted = _files.contains(path); var dirExisted = _files.containsDir(path); var fileExists = File(path).existsSync(); var dirExists = Directory(path).existsSync(); - var events = []; + var events = []; if (fileExisted) { if (fileExists) { - events.add(FileSystemModifyEvent(path, false, false)); + events.add(Event.modifyFile(path)); } else { - events.add(FileSystemDeleteEvent(path, false)); + events.add(Event.delete(path)); } } else if (dirExisted) { if (dirExists) { // If we got contradictory events for a directory that used to exist and // still exists, we need to rescan the whole thing in case it was // replaced with a different directory. - events.add(FileSystemDeleteEvent(path, true)); - events.add(FileSystemCreateEvent(path, true)); + events.add(Event.delete(path)); + events.add(Event.createDirectory(path)); } else { - events.add(FileSystemDeleteEvent(path, true)); + events.add(Event.delete(path)); } } if (!fileExisted && fileExists) { - events.add(FileSystemCreateEvent(path, false)); + events.add(Event.createFile(path)); } else if (!dirExisted && dirExists) { - events.add(FileSystemCreateEvent(path, true)); + events.add(Event.createDirectory(path)); } return events; diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart index 87eca0f2f..dec73e004 100644 --- a/pkgs/watcher/lib/src/directory_watcher/windows.dart +++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart @@ -10,6 +10,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../directory_watcher.dart'; +import '../event.dart'; import '../path_set.dart'; import '../resubscribable.dart'; import '../utils.dart'; @@ -26,11 +27,11 @@ class WindowsDirectoryWatcher extends ResubscribableWatcher class _EventBatcher { static const Duration _batchDelay = Duration(milliseconds: 100); - final List events = []; + final List events = []; Timer? timer; void addEvent(FileSystemEvent event, void Function() callback) { - events.add(event); + events.add(Event(event)); timer?.cancel(); timer = Timer(_batchDelay, callback); } @@ -173,7 +174,7 @@ class _WindowsDirectoryWatcher } /// The callback that's run when [Directory.watch] emits a batch of events. - void _onBatch(List batch) { + void _onBatch(List batch) { _sortEvents(batch).forEach((path, eventSet) { var canonicalEvent = _canonicalEvent(eventSet); var events = canonicalEvent == null @@ -182,7 +183,7 @@ class _WindowsDirectoryWatcher for (var event in events) { if (event is FileSystemCreateEvent) { - if (!event.isDirectory) { + if (!event.isDirectory!) { if (_files.contains(path)) continue; _emitEvent(ChangeType.ADD, path); @@ -216,7 +217,7 @@ class _WindowsDirectoryWatcher }); _listSubscriptions.add(subscription); } else if (event is FileSystemModifyEvent) { - if (!event.isDirectory) { + if (!event.isDirectory!) { _emitEvent(ChangeType.MODIFY, path); } } else { @@ -237,15 +238,15 @@ class _WindowsDirectoryWatcher /// /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it /// contain any events relating to [path]. - Map> _sortEvents(List batch) { - var eventsForPaths = >{}; + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; // Events within directories that already have events are superfluous; the // directory's full contents will be examined anyway, so we ignore such // events. Emitting them could cause useless or out-of-order events. var directories = unionAll( batch.map((event) { - if (!event.isDirectory) return {}; + if (event.isDelete || !event.isDirectory!) return {}; if (event is FileSystemMoveEvent) { var destination = event.destination; if (destination != null) { @@ -259,13 +260,13 @@ class _WindowsDirectoryWatcher bool isInModifiedDirectory(String path) => directories.any((dir) => path != dir && p.isWithin(dir, path)); - void addEvent(String path, FileSystemEvent event) { + void addEvent(String path, Event event) { if (isInModifiedDirectory(path)) return; - eventsForPaths.putIfAbsent(path, () => {}).add(event); + eventsForPaths.putIfAbsent(path, () => {}).add(event); } for (var event in batch) { - if (event is FileSystemMoveEvent) { + if (event.isMove) { var destination = event.destination; if (destination != null) { addEvent(destination, event); @@ -287,57 +288,57 @@ class _WindowsDirectoryWatcher /// If [batch] does contain contradictory events, this returns `null` to /// indicate that the state of the path on the filesystem should be checked to /// determine what occurred. - FileSystemEvent? _canonicalEvent(Set batch) { + Event? _canonicalEvent(Set batch) { // An empty batch indicates that we've learned earlier that the batch is // contradictory (e.g. because of a move). if (batch.isEmpty) return null; var type = batch.first.type; - var isDir = batch.first.isDirectory; + var isDirectory = batch.first.isDirectory; for (var event in batch.skip(1)) { // If one event reports that the file is a directory and another event // doesn't, that's a contradiction. - if (isDir != event.isDirectory) return null; + if (isDirectory != event.isDirectory) return null; // Modify events don't contradict either CREATE or REMOVE events. We can // safely assume the file was modified after a CREATE or before the // REMOVE; otherwise there will also be a REMOVE or CREATE event // (respectively) that will be contradictory. - if (event is FileSystemModifyEvent) continue; + if (event.isModify) continue; assert( - event is FileSystemCreateEvent || - event is FileSystemDeleteEvent || - event is FileSystemMoveEvent, + event.isCreate || event.isDelete || event.isMove, ); // If we previously thought this was a MODIFY, we now consider it to be a // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == FileSystemEvent.modify) { + if (type == EventType.modify) { type = event.type; continue; } // A CREATE event contradicts a REMOVE event and vice versa. assert( - type == FileSystemEvent.create || - type == FileSystemEvent.delete || - type == FileSystemEvent.move, + type == EventType.create || + type == EventType.delete || + type == EventType.move, ); if (type != event.type) return null; } switch (type) { - case FileSystemEvent.create: - return FileSystemCreateEvent(batch.first.path, isDir); - case FileSystemEvent.delete: - return FileSystemDeleteEvent(batch.first.path, isDir); - case FileSystemEvent.modify: - return FileSystemModifyEvent(batch.first.path, isDir, false); - case FileSystemEvent.move: + case EventType.create: + return isDirectory! + ? Event.createDirectory(batch.first.path) + : Event.createFile(batch.first.path); + case EventType.delete: + return Event.delete(batch.first.path); + case EventType.modify: + return isDirectory! + ? Event.modifyDirectory(batch.first.path) + : Event.modifyFile(batch.first.path); + case EventType.move: return null; - default: - throw StateError('unreachable'); } } @@ -348,7 +349,7 @@ class _WindowsDirectoryWatcher /// to the user, unlike the batched events from [Directory.watch]. The /// returned list may be empty, indicating that no changes occurred to [path] /// (probably indicating that it was created and then immediately deleted). - List _eventsBasedOnFileSystem(String path) { + List _eventsBasedOnFileSystem(String path) { var fileExisted = _files.contains(path); var dirExisted = _files.containsDir(path); @@ -358,32 +359,32 @@ class _WindowsDirectoryWatcher fileExists = File(path).existsSync(); dirExists = Directory(path).existsSync(); } on FileSystemException { - return const []; + return const []; } - var events = []; + var events = []; if (fileExisted) { if (fileExists) { - events.add(FileSystemModifyEvent(path, false, false)); + events.add(Event.modifyFile(path)); } else { - events.add(FileSystemDeleteEvent(path, false)); + events.add(Event.delete(path)); } } else if (dirExisted) { if (dirExists) { // If we got contradictory events for a directory that used to exist and // still exists, we need to rescan the whole thing in case it was // replaced with a different directory. - events.add(FileSystemDeleteEvent(path, true)); - events.add(FileSystemCreateEvent(path, true)); + events.add(Event.delete(path)); + events.add(Event.createDirectory(path)); } else { - events.add(FileSystemDeleteEvent(path, true)); + events.add(Event.delete(path)); } } if (!fileExisted && fileExists) { - events.add(FileSystemCreateEvent(path, false)); + events.add(Event.createFile(path)); } else if (!dirExisted && dirExists) { - events.add(FileSystemCreateEvent(path, true)); + events.add(Event.createDirectory(path)); } return events; diff --git a/pkgs/watcher/lib/src/event.dart b/pkgs/watcher/lib/src/event.dart new file mode 100644 index 000000000..5076babda --- /dev/null +++ b/pkgs/watcher/lib/src/event.dart @@ -0,0 +1,93 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +/// Extension type on [FileSystemEvent]. +/// +/// The [FileSystemDeleteEvent] subclass of [FileSystemEvent] does something +/// surprising for `isDirectory`: it always returns `false`. The constructor +/// accepts a boolean called `isDirectory` but discards it. +/// +/// This extension type provides an `isDelete` that returns `null` for delete +/// events, so it's clear that it's unspecified; static creation methods that +/// only take the values that are actually used. +extension type Event(FileSystemEvent event) { + /// A create event for a file at [path]. + static Event createFile(String path) => + Event(FileSystemCreateEvent(path, false)); + + /// A create event for a directory at [path]. + static Event createDirectory(String path) => + Event(FileSystemCreateEvent(path, true)); + + /// A delete event for [path]. + /// + /// Delete events do not specify whether they are for files or directories. + static Event delete(String path) => Event(FileSystemDeleteEvent( + path, + // `FileSystemDeleteEvent` just discards `isDirectory`. + false /* isDirectory */)); + + /// A modify event for the file at [path]. + static Event modifyFile(String path) => Event(FileSystemModifyEvent( + path, + false /* isDirectory */, + // Don't set it, even pass through from the OS, as it's never used. + false /* contentChanged */)); + + /// A modify event for the directory at [path]. + static Event modifyDirectory(String path) => Event(FileSystemModifyEvent( + path, + true /* isDirectory */, + // `contentChanged` is not used by `package:watcher`, don't set it. + false)); + + /// See [FileSystemEvent.path]. + String get path => event.path; + + bool get isDelete => event.type == FileSystemEvent.delete; + bool get isCreate => event.type == FileSystemEvent.create; + bool get isModify => event.type == FileSystemEvent.modify; + bool get isMove => event.type == FileSystemEvent.move; + + EventType get type { + switch (event.type) { + case FileSystemEvent.create: + return EventType.create; + case FileSystemEvent.delete: + return EventType.delete; + case FileSystemEvent.modify: + return EventType.modify; + case FileSystemEvent.move: + return EventType.move; + default: + throw StateError('Invalid event type ${event.type}.'); + } + } + + /// See [FileSystemMoveEvent.destination]. + /// + /// For other types of event, always `null`. + String? get destination => + isMove ? (event as FileSystemMoveEvent).destination : null; + + /// See [FileSystemEvent.isDirectory]. + /// + /// For delete events, always `null`. + bool? get isDirectory => isDelete ? null : event.isDirectory; + + /// All paths mentioned by the event. + /// + /// This is [path] plus, for move events, [destination] if it's not `null`. + Set get paths => {event.path, if (destination != null) destination!}; +} + +/// See [FileSystemEvent.type]. +enum EventType { + delete, + create, + modify, + move, +} diff --git a/pkgs/watcher/lib/src/file_watcher/native.dart b/pkgs/watcher/lib/src/file_watcher/native.dart index 28cf8a180..79a6f0587 100644 --- a/pkgs/watcher/lib/src/file_watcher/native.dart +++ b/pkgs/watcher/lib/src/file_watcher/native.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io'; +import '../event.dart'; import '../file_watcher.dart'; import '../resubscribable.dart'; import '../utils.dart'; @@ -33,7 +34,7 @@ class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher { Future get ready => _readyCompleter.future; final _readyCompleter = Completer(); - StreamSubscription>? _subscription; + StreamSubscription>? _subscription; /// On MacOS only, whether the file existed on startup. bool? _existedAtStartup; @@ -65,8 +66,8 @@ class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher { onError: _eventsController.addError, onDone: _onDone); } - void _onBatch(List batch) { - if (batch.any((event) => event.type == FileSystemEvent.delete)) { + void _onBatch(List batch) { + if (batch.any((event) => event.isDelete)) { // If the file is deleted, the underlying stream will close. We handle // emitting our own REMOVE event in [_onDone]. return; @@ -76,8 +77,7 @@ class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher { // On MacOS, a spurious `create` event can be received for a file that is // created just before the `watch`. If the file existed at startup then it // should be ignored. - if (_existedAtStartup! && - batch.every((event) => event.type == FileSystemEvent.create)) { + if (_existedAtStartup! && batch.every((event) => event.isCreate)) { return; } } diff --git a/pkgs/watcher/lib/src/utils.dart b/pkgs/watcher/lib/src/utils.dart index e5ef54c66..c4d608033 100644 --- a/pkgs/watcher/lib/src/utils.dart +++ b/pkgs/watcher/lib/src/utils.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; +import 'event.dart'; + /// Returns `true` if [error] is a [FileSystemException] for a missing /// directory. bool isDirectoryNotFoundException(Object error) { @@ -20,7 +22,7 @@ bool isDirectoryNotFoundException(Object error) { Set unionAll(Iterable> sets) => sets.fold({}, (union, set) => union.union(set)); -extension BatchEvents on Stream { +extension BatchEvents on Stream { /// Batches all events that are sent at the same time. /// /// When multiple events are synchronously added to a stream controller, the @@ -28,11 +30,11 @@ extension BatchEvents on Stream { /// asynchronous firing of each event. In order to recreate the synchronous /// batches, this collates all the events that are received in "nearby" /// microtasks. - Stream> batchEvents() { - var batch = Queue(); - return StreamTransformer>.fromHandlers( + Stream> batchEvents() { + var batch = Queue(); + return StreamTransformer>.fromHandlers( handleData: (event, sink) { - batch.add(event); + batch.add(Event(event)); // [Timer.run] schedules an event that runs after any microtasks that have // been scheduled. diff --git a/pkgs/watcher/pubspec.yaml b/pkgs/watcher/pubspec.yaml index c7119052d..fde8fe3dd 100644 --- a/pkgs/watcher/pubspec.yaml +++ b/pkgs/watcher/pubspec.yaml @@ -7,7 +7,7 @@ repository: https://github.com/dart-lang/tools/tree/main/pkgs/watcher issue_tracker: https://github.com/dart-lang/tools/labels/package%3Awatcher environment: - sdk: ^3.1.0 + sdk: ^3.3.0 dependencies: async: ^2.5.0 From 367551eff55b36e3998292fc2123b037f499885b Mon Sep 17 00:00:00 2001 From: David Morgan Date: Tue, 7 Oct 2025 12:10:41 +0200 Subject: [PATCH 2/3] Hacking. --- .../lib/src/directory_watcher/linux.dart | 47 +++--- .../lib/src/directory_watcher/mac_os.dart | 141 +++++++++-------- .../lib/src/directory_watcher/windows.dart | 149 +++++++++--------- pkgs/watcher/lib/src/event.dart | 19 ++- 4 files changed, 194 insertions(+), 162 deletions(-) diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart index c09e1a536..80497e0cb 100644 --- a/pkgs/watcher/lib/src/directory_watcher/linux.dart +++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart @@ -163,30 +163,35 @@ class _LinuxDirectoryWatcher changed.add(event.path); - if (event.isMove) { - files.remove(event.path); - dirs.remove(event.path); - - var destination = event.destination; - if (destination == null) continue; + switch (event.type) { + case EventType.moveFile: + files.remove(event.path); + var destination = event.destination; + if (destination == null) continue; + changed.add(destination); + files.add(destination); + dirs.remove(destination); - changed.add(destination); - if (event.isDirectory!) { + case EventType.moveDirectory: + dirs.remove(event.path); + var destination = event.destination; + if (destination == null) continue; files.remove(destination); dirs.add(destination); - } else { - files.add(destination); - dirs.remove(destination); - } - } else if (event is FileSystemDeleteEvent) { - files.remove(event.path); - dirs.remove(event.path); - } else if (event.isDirectory!) { - files.remove(event.path); - dirs.add(event.path); - } else { - files.add(event.path); - dirs.remove(event.path); + + case EventType.delete: + files.remove(event.path); + dirs.remove(event.path); + + case EventType.createFile: + case EventType.modifyFile: + files.add(event.path); + dirs.remove(event.path); + + case EventType.createDirectory: + case EventType.modifyDirectory: + files.remove(event.path); + dirs.add(event.path); } } diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart index cd9e47344..84d9e9460 100644 --- a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart +++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart @@ -133,8 +133,8 @@ class _MacOSDirectoryWatcher : [canonicalEvent]; for (var event in events) { - if (event.isCreate) { - if (!event.isDirectory!) { + switch (event.type) { + case EventType.createFile: // If we already know about the file, treat it like a modification. // This can happen if a file is copied on top of an existing one. // We'll see an ADD event for the latter file when from the user's @@ -144,34 +144,39 @@ class _MacOSDirectoryWatcher _emitEvent(type, path); _files.add(path); - continue; - } - - if (_files.containsDir(path)) continue; - - var stream = Directory(path) - .list(recursive: true) - .ignoring(); - var subscription = stream.listen((entity) { - if (entity is Directory) return; - if (_files.contains(path)) return; - - _emitEvent(ChangeType.ADD, entity.path); - _files.add(entity.path); - }, cancelOnError: true); - subscription.onDone(() { - _listSubscriptions.remove(subscription); - }); - subscription.onError(_emitError); - _listSubscriptions.add(subscription); - } else if (event.isModify) { - assert(!event.isDirectory!); - _emitEvent(ChangeType.MODIFY, path); - } else { - assert(event.isDelete); - for (var removedPath in _files.remove(path)) { - _emitEvent(ChangeType.REMOVE, removedPath); - } + + case EventType.createDirectory: + if (_files.containsDir(path)) continue; + + var stream = Directory(path) + .list(recursive: true) + .ignoring(); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError(_emitError); + _listSubscriptions.add(subscription); + + case EventType.modifyFile: + _emitEvent(ChangeType.MODIFY, path); + + case EventType.delete: + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + + // Guaranteed not present by `_sortEvents`. + case EventType.moveFile: + case EventType.moveDirectory: + case EventType.modifyDirectory: + throw StateError(event.type.name); } } }); @@ -179,12 +184,15 @@ class _MacOSDirectoryWatcher /// Sort all the events in a batch into sets based on their path. /// - /// A single input event may result in multiple events in the returned map; - /// for example, a MOVE event becomes a DELETE event for the source and a - /// CREATE event for the destination. + /// Events for `path` are discarded. + /// + /// Events under directories that are created or modified are discarded. /// - /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it - /// contain any events relating to [path]. + /// Three event types are not expected on MacOS, if encountered they will be + /// dropped with an assert fail to signal in tests. The types are: + /// [EventType.moveFile], [EventType.moveDirectory] and + /// [EventType.modifyDirectory]. See + /// https://github.com/dart-lang/sdk/issues/14806. Map> _sortEvents(List batch) { var eventsForPaths = >{}; @@ -193,25 +201,29 @@ class _MacOSDirectoryWatcher // really deleted, that's handled by [_onDone]. batch = batch.where((event) => event.path != path).toList(); - // Events within directories that already have events are superfluous; the - // directory's full contents will be examined anyway, so we ignore such - // events. Emitting them could cause useless or out-of-order events. - var directories = unionAll(batch.map((event) { - if (event.isDelete || !event.isDirectory!) return {}; - return event.paths; + // Events within directories that already have create events are not needed + // as the directory's full content will be listed. + var createdDirectories = unionAll(batch.map((event) { + return event.type == EventType.createDirectory + ? event.paths + : const {}; })); - bool isInModifiedDirectory(String path) => - directories.any((dir) => path != dir && p.isWithin(dir, path)); + bool isInCreatedDirectory(String path) => + createdDirectories.any((dir) => path != dir && p.isWithin(dir, path)); void addEvent(String path, Event event) { - if (isInModifiedDirectory(path)) return; + if (isInCreatedDirectory(path)) return; eventsForPaths.putIfAbsent(path, () => {}).add(event); } for (var event in batch) { - // The Mac OS watcher doesn't emit move events. See issue 14806. - assert(!event.isMove); + if (event.type == EventType.moveFile || + event.type == EventType.moveDirectory || + event.type == EventType.modifyDirectory) { + assert(false); + continue; + } addEvent(event.path, event); } @@ -233,6 +245,7 @@ class _MacOSDirectoryWatcher // contradictory (e.g. because of a move). if (batch.isEmpty) return null; + var path = batch.first.path; var type = batch.first.type; var isDirectory = batch.first.isDirectory; var hadModifyEvent = false; @@ -246,7 +259,7 @@ class _MacOSDirectoryWatcher // safely assume the file was modified after a CREATE or before the // REMOVE; otherwise there will also be a REMOVE or CREATE event // (respectively) that will be contradictory. - if (event.isModify) { + if (event.type == EventType.modifyFile) { hadModifyEvent = true; continue; } @@ -254,13 +267,15 @@ class _MacOSDirectoryWatcher // If we previously thought this was a MODIFY, we now consider it to be a // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == EventType.modify) { + if (type == EventType.modifyFile) { type = event.type; continue; } // A CREATE event contradicts a REMOVE event and vice versa. - assert(type == EventType.create || type == EventType.delete); + assert(type == EventType.createFile || + type == EventType.createDirectory || + type == EventType.delete); if (type != event.type) return null; } @@ -268,28 +283,30 @@ class _MacOSDirectoryWatcher // from FSEvents reporting an add that happened prior to the watch // beginning. If we also received a MODIFY event, we want to report that, // but not the CREATE. - if (type == EventType.create && + if (type == EventType.createFile && hadModifyEvent && - _files.contains(batch.first.path)) { - type = EventType.modify; + _files.contains(path)) { + type = EventType.modifyFile; } switch (type) { - case EventType.create: + case EventType.createDirectory: // Issue 16003 means that a CREATE event for a directory can indicate // that the directory was moved and then re-created. // [_eventsBasedOnFileSystem] will handle this correctly by producing a // DELETE event followed by a CREATE event if the directory exists. - if (isDirectory!) return null; - return Event.createFile(batch.first.path); + return null; + + case EventType.createFile: case EventType.delete: - return Event.delete(batch.first.path); - case EventType.modify: - return isDirectory! - ? Event.modifyDirectory(batch.first.path) - : Event.modifyFile(batch.first.path); - default: - throw StateError('unreachable'); + case EventType.modifyFile: + return batch.firstWhere((e) => e.type == type); + + // Guaranteed not present by `_sortEvents`. + case EventType.moveFile: + case EventType.moveDirectory: + case EventType.modifyDirectory: + throw StateError(type.name); } } diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart index dec73e004..ec6f4336c 100644 --- a/pkgs/watcher/lib/src/directory_watcher/windows.dart +++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart @@ -182,49 +182,56 @@ class _WindowsDirectoryWatcher : [canonicalEvent]; for (var event in events) { - if (event is FileSystemCreateEvent) { - if (!event.isDirectory!) { + switch (event.type) { + case EventType.createFile: if (_files.contains(path)) continue; - _emitEvent(ChangeType.ADD, path); _files.add(path); - continue; - } - if (_files.containsDir(path)) continue; - - // "Path not found" can be caused by creating then quickly removing - // a directory: continue without reporting an error. Nested files - // that get removed during the `list` are already ignored by `list` - // itself, so there are no other types of "path not found" that - // might need different handling here. - var stream = Directory(path) - .list(recursive: true) - .ignoring(); - var subscription = stream.listen((entity) { - if (entity is Directory) return; - if (_files.contains(entity.path)) return; - - _emitEvent(ChangeType.ADD, entity.path); - _files.add(entity.path); - }, cancelOnError: true); - subscription.onDone(() { - _listSubscriptions.remove(subscription); - }); - subscription.onError((Object e, StackTrace stackTrace) { - _listSubscriptions.remove(subscription); - _emitError(e, stackTrace); - }); - _listSubscriptions.add(subscription); - } else if (event is FileSystemModifyEvent) { - if (!event.isDirectory!) { + case EventType.createDirectory: + if (_files.containsDir(path)) continue; + + // "Path not found" can be caused by creating then quickly removing + // a directory: continue without reporting an error. Nested files + // that get removed during the `list` are already ignored by `list` + // itself, so there are no other types of "path not found" that + // might need different handling here. + var stream = Directory(path) + .list(recursive: true) + .ignoring(); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(entity.path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError((Object e, StackTrace stackTrace) { + _listSubscriptions.remove(subscription); + _emitError(e, stackTrace); + }); + _listSubscriptions.add(subscription); + + case EventType.modifyFile: _emitEvent(ChangeType.MODIFY, path); - } - } else { - assert(event is FileSystemDeleteEvent); - for (var removedPath in _files.remove(path)) { - _emitEvent(ChangeType.REMOVE, removedPath); - } + + case EventType.delete: + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + + // Move events are removed by `_canonicalEvent` and never returned by + // `_eventsBasedOnFileSystem`. + // + // [EventType.modifyDirectory] is guaranteed not present by + // `_sortEvents`. + case EventType.moveFile: + case EventType.moveDirectory: + case EventType.modifyDirectory: + throw StateError(event.type.name); } } }); @@ -236,24 +243,21 @@ class _WindowsDirectoryWatcher /// for example, a MOVE event becomes a DELETE event for the source and a /// CREATE event for the destination. /// - /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it - /// contain any events relating to [path]. + /// Events of type [EventType.modifyDirectory] are ignored and dropped because + /// they are always accompanied by either an [EventType.createDirectory] or an + /// [EventType.delete]. Map> _sortEvents(List batch) { var eventsForPaths = >{}; - // Events within directories that already have events are superfluous; the - // directory's full contents will be examined anyway, so we ignore such - // events. Emitting them could cause useless or out-of-order events. + // Events within created or moved directories are not needed as the + // directory's full contents will be listed. var directories = unionAll( batch.map((event) { - if (event.isDelete || !event.isDirectory!) return {}; - if (event is FileSystemMoveEvent) { - var destination = event.destination; - if (destination != null) { - return {event.path, destination}; - } + if (event.type == EventType.createDirectory || + event.type == EventType.moveDirectory) { + return event.paths; } - return {event.path}; + return const {}; }), ); @@ -266,13 +270,12 @@ class _WindowsDirectoryWatcher } for (var event in batch) { - if (event.isMove) { - var destination = event.destination; - if (destination != null) { - addEvent(destination, event); - } + if (event.type == EventType.modifyDirectory) { + continue; + } + for (final path in event.paths) { + addEvent(path, event); } - addEvent(event.path, event); } return eventsForPaths; @@ -312,33 +315,33 @@ class _WindowsDirectoryWatcher // If we previously thought this was a MODIFY, we now consider it to be a // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == EventType.modify) { + if (type == EventType.modifyFile) { type = event.type; continue; } // A CREATE event contradicts a REMOVE event and vice versa. - assert( - type == EventType.create || - type == EventType.delete || - type == EventType.move, - ); + assert(type == EventType.createFile || + type == EventType.createDirectory || + type == EventType.delete || + type == EventType.moveFile); if (type != event.type) return null; } switch (type) { - case EventType.create: - return isDirectory! - ? Event.createDirectory(batch.first.path) - : Event.createFile(batch.first.path); - case EventType.delete: - return Event.delete(batch.first.path); - case EventType.modify: - return isDirectory! - ? Event.modifyDirectory(batch.first.path) - : Event.modifyFile(batch.first.path); - case EventType.move: + // Move events are always resolved by checking the filesystem state. + case EventType.moveFile: + case EventType.moveDirectory: return null; + + case EventType.createFile: + case EventType.createDirectory: + case EventType.delete: + case EventType.modifyFile: + return batch.firstWhere((e) => e.type == type); + + case EventType.modifyDirectory: + throw StateError(EventType.modifyDirectory.name); } } diff --git a/pkgs/watcher/lib/src/event.dart b/pkgs/watcher/lib/src/event.dart index 5076babda..b383716a1 100644 --- a/pkgs/watcher/lib/src/event.dart +++ b/pkgs/watcher/lib/src/event.dart @@ -55,13 +55,17 @@ extension type Event(FileSystemEvent event) { EventType get type { switch (event.type) { case FileSystemEvent.create: - return EventType.create; + return event.isDirectory + ? EventType.createDirectory + : EventType.createFile; case FileSystemEvent.delete: return EventType.delete; case FileSystemEvent.modify: - return EventType.modify; + return event.isDirectory + ? EventType.modifyDirectory + : EventType.modifyFile; case FileSystemEvent.move: - return EventType.move; + return event.isDirectory ? EventType.moveDirectory : EventType.moveFile; default: throw StateError('Invalid event type ${event.type}.'); } @@ -87,7 +91,10 @@ extension type Event(FileSystemEvent event) { /// See [FileSystemEvent.type]. enum EventType { delete, - create, - modify, - move, + createFile, + createDirectory, + modifyFile, + modifyDirectory, + moveFile, + moveDirectory; } From 83a4c8a8dc55f08589625f8855dd027d931e0834 Mon Sep 17 00:00:00 2001 From: David Morgan Date: Tue, 7 Oct 2025 17:01:11 +0200 Subject: [PATCH 3/3] Hacking. --- .../lib/src/directory_watcher/mac_os.dart | 80 +++++-------------- .../lib/src/directory_watcher/windows.dart | 67 +++++----------- pkgs/watcher/lib/src/event.dart | 13 +-- 3 files changed, 44 insertions(+), 116 deletions(-) diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart index 84d9e9460..3efde9b4a 100644 --- a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart +++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart @@ -205,7 +205,7 @@ class _MacOSDirectoryWatcher // as the directory's full content will be listed. var createdDirectories = unionAll(batch.map((event) { return event.type == EventType.createDirectory - ? event.paths + ? {event.path} : const {}; })); @@ -241,73 +241,35 @@ class _MacOSDirectoryWatcher /// indicate that the state of the path on the filesystem should be checked to /// determine what occurred. Event? _canonicalEvent(Set batch) { - // An empty batch indicates that we've learned earlier that the batch is - // contradictory (e.g. because of a move). if (batch.isEmpty) return null; - var path = batch.first.path; - var type = batch.first.type; - var isDirectory = batch.first.isDirectory; - var hadModifyEvent = false; - - for (var event in batch.skip(1)) { - // If one event reports that the file is a directory and another event - // doesn't, that's a contradiction. - if (isDirectory != event.isDirectory) return null; - - // Modify events don't contradict either CREATE or REMOVE events. We can - // safely assume the file was modified after a CREATE or before the - // REMOVE; otherwise there will also be a REMOVE or CREATE event - // (respectively) that will be contradictory. - if (event.type == EventType.modifyFile) { - hadModifyEvent = true; - continue; - } - assert(event.isCreate || event.isDelete); + var types = batch.map((e) => e.type).toSet(); - // If we previously thought this was a MODIFY, we now consider it to be a - // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == EventType.modifyFile) { - type = event.type; - continue; + if (types.length == 2 && + types.contains(EventType.modifyFile) && + types.contains(EventType.createFile)) { + if (_files.contains(path)) { + types.remove(EventType.createFile); + } else { + types.remove(EventType.modifyFile); } - - // A CREATE event contradicts a REMOVE event and vice versa. - assert(type == EventType.createFile || - type == EventType.createDirectory || - type == EventType.delete); - if (type != event.type) return null; } - // If we got a CREATE event for a file we already knew about, that comes - // from FSEvents reporting an add that happened prior to the watch - // beginning. If we also received a MODIFY event, we want to report that, - // but not the CREATE. - if (type == EventType.createFile && - hadModifyEvent && - _files.contains(path)) { - type = EventType.modifyFile; + if (types.length != 1) { + return null; } - switch (type) { - case EventType.createDirectory: - // Issue 16003 means that a CREATE event for a directory can indicate - // that the directory was moved and then re-created. - // [_eventsBasedOnFileSystem] will handle this correctly by producing a - // DELETE event followed by a CREATE event if the directory exists. - return null; - - case EventType.createFile: - case EventType.delete: - case EventType.modifyFile: - return batch.firstWhere((e) => e.type == type); - - // Guaranteed not present by `_sortEvents`. - case EventType.moveFile: - case EventType.moveDirectory: - case EventType.modifyDirectory: - throw StateError(type.name); + final type = types.first; + + if (type == EventType.createDirectory) { + // Issue 16003 means that a CREATE event for a directory can indicate + // that the directory was moved and then re-created. + // [_eventsBasedOnFileSystem] will handle this correctly by producing a + // DELETE event followed by a CREATE event if the directory exists. + return null; } + + return batch.firstWhere((e) => e.type == type); } /// Returns one or more events that describe the change between the last known diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart index ec6f4336c..402babebd 100644 --- a/pkgs/watcher/lib/src/directory_watcher/windows.dart +++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart @@ -255,7 +255,8 @@ class _WindowsDirectoryWatcher batch.map((event) { if (event.type == EventType.createDirectory || event.type == EventType.moveDirectory) { - return event.paths; + final destination = event.destination; + return {event.path, if (destination != null) destination}; } return const {}; }), @@ -273,8 +274,10 @@ class _WindowsDirectoryWatcher if (event.type == EventType.modifyDirectory) { continue; } - for (final path in event.paths) { - addEvent(path, event); + addEvent(path, event); + final destination = event.destination; + if (destination != null) { + addEvent(destination, event); } } @@ -292,57 +295,27 @@ class _WindowsDirectoryWatcher /// indicate that the state of the path on the filesystem should be checked to /// determine what occurred. Event? _canonicalEvent(Set batch) { - // An empty batch indicates that we've learned earlier that the batch is - // contradictory (e.g. because of a move). if (batch.isEmpty) return null; + var types = batch.map((e) => e.type).toSet(); - var type = batch.first.type; - var isDirectory = batch.first.isDirectory; - - for (var event in batch.skip(1)) { - // If one event reports that the file is a directory and another event - // doesn't, that's a contradiction. - if (isDirectory != event.isDirectory) return null; - - // Modify events don't contradict either CREATE or REMOVE events. We can - // safely assume the file was modified after a CREATE or before the - // REMOVE; otherwise there will also be a REMOVE or CREATE event - // (respectively) that will be contradictory. - if (event.isModify) continue; - assert( - event.isCreate || event.isDelete || event.isMove, - ); - - // If we previously thought this was a MODIFY, we now consider it to be a - // CREATE or REMOVE event. This is safe for the same reason as above. - if (type == EventType.modifyFile) { - type = event.type; - continue; - } - - // A CREATE event contradicts a REMOVE event and vice versa. - assert(type == EventType.createFile || - type == EventType.createDirectory || - type == EventType.delete || - type == EventType.moveFile); - if (type != event.type) return null; + if (types.length == 2 && + types.contains(EventType.modifyFile) && + types.contains(EventType.createFile)) { + types.remove(EventType.modifyFile); } - switch (type) { - // Move events are always resolved by checking the filesystem state. - case EventType.moveFile: - case EventType.moveDirectory: - return null; + if (types.length != 1) { + return null; + } - case EventType.createFile: - case EventType.createDirectory: - case EventType.delete: - case EventType.modifyFile: - return batch.firstWhere((e) => e.type == type); + final type = types.first; - case EventType.modifyDirectory: - throw StateError(EventType.modifyDirectory.name); + // Move events are always resolved by checking the filesystem state. + if (type == EventType.moveDirectory || type == EventType.moveFile) { + return null; } + + return batch.firstWhere((e) => e.type == type); } /// Returns zero or more events that describe the change between the last diff --git a/pkgs/watcher/lib/src/event.dart b/pkgs/watcher/lib/src/event.dart index b383716a1..23f97ce79 100644 --- a/pkgs/watcher/lib/src/event.dart +++ b/pkgs/watcher/lib/src/event.dart @@ -76,19 +76,12 @@ extension type Event(FileSystemEvent event) { /// For other types of event, always `null`. String? get destination => isMove ? (event as FileSystemMoveEvent).destination : null; - - /// See [FileSystemEvent.isDirectory]. - /// - /// For delete events, always `null`. - bool? get isDirectory => isDelete ? null : event.isDirectory; - - /// All paths mentioned by the event. - /// - /// This is [path] plus, for move events, [destination] if it's not `null`. - Set get paths => {event.path, if (destination != null) destination!}; } /// See [FileSystemEvent.type]. +/// +/// This additionally encodes [FileSystemEvent.isDirectory], which is specified +/// for all event types except deletes. enum EventType { delete, createFile,