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
+79
View File
@@ -0,0 +1,79 @@
/// Directory listing with ignore-file filtering.
///
/// A thin wrapper over `Directory.list()` that applies an [IgnoreSet]
/// to each candidate entry. Used by the `files.ls` IPC handler and
/// by the file-tree UI for non-watched one-shot reads.
library;
import 'dart:io';
import 'ignore.dart';
class FileEntry {
const FileEntry({
required this.name,
required this.path,
required this.isDirectory,
required this.isSymlink,
this.sizeBytes,
this.modifiedMs,
});
/// Display name (basename).
final String name;
/// Repo-relative path, forward-slashed.
final String path;
final bool isDirectory;
final bool isSymlink;
final int? sizeBytes;
final int? modifiedMs;
Map<String, Object?> toJson() => {
'name': name,
'path': path,
'isDirectory': isDirectory,
'isSymlink': isSymlink,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
if (modifiedMs != null) 'modifiedMs': modifiedMs,
};
}
/// List the immediate children of [dir] (repo-relative path) under
/// [root]. Filters against [ignore]. Returns entries sorted
/// directory-first, then by name.
Future<List<FileEntry>> listDir({
required Directory root,
required String dir,
required IgnoreSet ignore,
}) async {
final resolved = dir.isEmpty
? root
: Directory('${root.absolute.path}${Platform.pathSeparator}${dir.replaceAll('/', Platform.pathSeparator)}');
if (!await resolved.exists()) return const [];
final entries = <FileEntry>[];
await for (final e in resolved.list(followLinks: false)) {
final name = e.uri.pathSegments.isNotEmpty
? e.uri.pathSegments.where((s) => s.isNotEmpty).last
: '';
final rel = dir.isEmpty ? name : '$dir/$name';
final stat = await e.stat();
final isDir = stat.type == FileSystemEntityType.directory;
if (ignore.isIgnored(rel, isDirectory: isDir)) continue;
entries.add(FileEntry(
name: name,
path: rel,
isDirectory: isDir,
isSymlink: stat.type == FileSystemEntityType.link,
sizeBytes: isDir ? null : stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch,
));
}
entries.sort((a, b) {
if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
return entries;
}