Files
clide/lib/src/files/watcher.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

105 lines
3.1 KiB
Dart

/// 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<String, Object?> toJson() => {'kind': kind.wire, 'path': path, 'isDirectory': isDirectory};
}
class FileWatcher {
FileWatcher({required this.root, required this.ignore});
final Directory root;
final IgnoreSet ignore;
StreamSubscription<FileSystemEvent>? _sub;
final _controller = StreamController<FileChange>.broadcast();
Stream<FileChange> get stream => _controller.stream;
Future<void> start() async {
if (_sub != null) return;
_sub = root.watch(recursive: true).listen(_onEvent, onError: (Object e, StackTrace _) => _controller.addError(e));
}
Future<void> 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, '/');
}
}