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
+80
View File
@@ -0,0 +1,80 @@
/// Tests for the `files.*` command handlers.
library;
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
late Directory sandbox;
late DaemonDispatcher dispatcher;
late FilesService files;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-files-test-');
// Two files + a subdir + an ignored dir.
File('${sandbox.path}/README.md').writeAsStringSync('hi');
File('${sandbox.path}/pubspec.yaml').writeAsStringSync('name: fake');
Directory('${sandbox.path}/lib').createSync();
File('${sandbox.path}/lib/main.dart').writeAsStringSync('void main(){}');
Directory('${sandbox.path}/.dart_tool').createSync();
File('${sandbox.path}/.dart_tool/hidden').writeAsStringSync('x');
final sink = RecordingEventSink();
files = FilesService(
root: sandbox,
events: sink,
// builtin ignore set hides .dart_tool/, which is what we want.
ignore: IgnoreSet.builtin(),
);
dispatcher = DaemonDispatcher();
registerFilesCommands(dispatcher, files);
});
tearDown(() async {
await files.shutdown();
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
Future<IpcResponse> call(String cmd, Map<String, Object?> args) {
return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args));
}
test('files.root returns the configured root path', () async {
final r = await call('files.root', const {});
expect(r.ok, isTrue);
expect(r.data['path'], sandbox.absolute.path);
expect(r.data['ignorePatterns'], greaterThan(0));
});
test('files.ls lists the top-level directory', () async {
final r = await call('files.ls', const {'path': ''});
expect(r.ok, isTrue);
final entries = (r.data['entries'] as List).cast<Map>();
final names = entries.map((e) => e['name']).toList();
expect(names, containsAll(['lib', 'README.md', 'pubspec.yaml']));
expect(names, isNot(contains('.dart_tool')));
});
test('files.ls sorts directories first', () async {
final r = await call('files.ls', const {'path': ''});
final entries = (r.data['entries'] as List).cast<Map>();
expect(entries.first['isDirectory'], isTrue);
});
test('files.ls into a subdirectory returns its contents', () async {
final r = await call('files.ls', const {'path': 'lib'});
expect(r.ok, isTrue);
final names = [
for (final e in (r.data['entries'] as List).cast<Map>()) e['name'],
];
expect(names, ['main.dart']);
});
test('files.watch acks subscription', () async {
final r = await call('files.watch', const {});
expect(r.ok, isTrue);
expect(r.data['subscribed'], isTrue);
});
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('IgnorePattern.parse', () {
test('blank + comment lines return null', () {
expect(IgnorePattern.parse(''), isNull);
expect(IgnorePattern.parse(' '), isNull);
expect(IgnorePattern.parse('# comment'), isNull);
});
test('directory-only detected', () {
final p = IgnorePattern.parse('build/');
expect(p, isNotNull);
expect(p!.directoryOnly, isTrue);
});
test('negation detected', () {
final p = IgnorePattern.parse('!keep.txt');
expect(p!.negated, isTrue);
});
test('anchored detected', () {
final p = IgnorePattern.parse('/root-only.txt');
expect(p!.anchored, isTrue);
});
});
group('IgnoreSet', () {
test('matches unanchored name at any depth', () {
final s = IgnoreSet.parse(const ['node_modules/\n*.log\n']);
expect(s.isIgnored('node_modules', isDirectory: true), isTrue);
expect(s.isIgnored('a/b/node_modules', isDirectory: true), isTrue);
expect(s.isIgnored('a.log', isDirectory: false), isTrue);
expect(s.isIgnored('deep/nested/foo.log', isDirectory: false), isTrue);
});
test('directory-only pattern does not match files', () {
final s = IgnoreSet.parse(const ['cache/\n']);
expect(s.isIgnored('cache', isDirectory: true), isTrue);
expect(s.isIgnored('cache', isDirectory: false), isFalse);
});
test('anchored pattern stays at the root', () {
final s = IgnoreSet.parse(const ['/config.yaml\n']);
expect(s.isIgnored('config.yaml', isDirectory: false), isTrue);
expect(s.isIgnored('app/config.yaml', isDirectory: false), isFalse);
});
test('later pattern wins — negation unignores', () {
final s = IgnoreSet.parse(const ['*.log\n!keep.log\n']);
expect(s.isIgnored('a.log', isDirectory: false), isTrue);
expect(s.isIgnored('keep.log', isDirectory: false), isFalse);
});
test('layered sets preserve order', () {
final s = IgnoreSet.parse(const ['*.tmp\n', '!important.tmp\n']);
expect(s.isIgnored('x.tmp', isDirectory: false), isTrue);
expect(s.isIgnored('important.tmp', isDirectory: false), isFalse);
});
test('built-in set hides clide-owned dirs', () {
final s = IgnoreSet.builtin();
for (final d in const ['.git', '.pql', '.clide', '.dart_tool', 'build', 'node_modules']) {
expect(s.isIgnored(d, isDirectory: true), isTrue, reason: d);
}
expect(s.isIgnored('lib', isDirectory: true), isFalse);
});
test('** crosses directory boundaries', () {
final s = IgnoreSet.parse(const ['docs/**/*.draft.md\n']);
expect(
s.isIgnored('docs/deep/nested/a.draft.md', isDirectory: false),
isTrue,
);
expect(s.isIgnored('docs/a.draft.md', isDirectory: false), isTrue);
expect(s.isIgnored('other/a.draft.md', isDirectory: false), isFalse);
});
});
}