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:
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user