implement builtin.files — workspace file tree with live watcher

Flutter sidebar tab that lazy-loads the workspace tree via IPC
files.ls and refreshes subtrees on files.changed events. Click-to-
open routes through a future editor.open command; until Tier 2
registers it, the execute call no-ops gracefully.

Daemon side adds a new files subsystem:
  - files.root returns the resolved workspace root (git root if
    present, otherwise cwd)
  - files.ls lists a directory with ignore filtering applied
  - files.watch starts a recursive Directory.watch and fans
    FileSystemEvents out as files.changed IPC events
  - FilesService owns the watcher + ignore set lifecycle

IgnoreSet + IgnorePattern implement the common gitignore subset:
anchored (/foo), directory-only (foo/), negation (!foo), **
crossing dirs, ** at trailing position. Built-in layer hides clide-
owned dirs (.git, .pql, .clide, .dart_tool, build, node_modules);
.gitignore + .clideignore at the root layer on top per D-004. Full
multi-file ignore_files: layering from .pql/config.yaml is future
work.

11 new ignore-matcher tests + 5 files.* dispatcher tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:36:02 +02:00
co-authored by Claude
parent e1ea52c5e4
commit 4ff5556896
14 changed files with 1084 additions and 6 deletions
+106
View File
@@ -0,0 +1,106 @@
/// 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 (ignore.isIgnored(rel, isDirectory: isDir)) return;
_controller.add(FileChange(
kind: FileChangeKind.fromEvent(e),
path: rel,
isDirectory: isDir,
));
}
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, '/');
}
}