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,125 @@
|
||||
/// Registers `files.*` command handlers + wires a [FileWatcher]
|
||||
/// into the event bus.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import '../files/watcher.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Daemon-side state for the `files` subsystem. Holds one
|
||||
/// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet].
|
||||
class FilesService {
|
||||
FilesService({
|
||||
required this.root,
|
||||
required this.events,
|
||||
IgnoreSet? ignore,
|
||||
}) : ignore = ignore ?? _defaultIgnore(root);
|
||||
|
||||
/// Build from the current working directory, walking up to the git
|
||||
/// root if present. Falls back to CWD otherwise.
|
||||
factory FilesService.atCwd({required DaemonEventSink events}) {
|
||||
final root = _resolveWorkspaceRoot(Directory.current);
|
||||
return FilesService(root: root, events: events);
|
||||
}
|
||||
|
||||
final Directory root;
|
||||
final IgnoreSet ignore;
|
||||
final DaemonEventSink events;
|
||||
|
||||
FileWatcher? _watcher;
|
||||
|
||||
Future<void> startWatching() async {
|
||||
if (_watcher != null) return;
|
||||
final w = FileWatcher(root: root, ignore: ignore);
|
||||
_watcher = w;
|
||||
await w.start();
|
||||
w.stream.listen((change) {
|
||||
events.emit(IpcEvent(
|
||||
subsystem: 'files',
|
||||
kind: 'files.changed',
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
data: change.toJson(),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> shutdown() async {
|
||||
await _watcher?.stop();
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
d.register('files.root', (req) async => IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {
|
||||
'path': files.root.absolute.path,
|
||||
'ignorePatterns': files.ignore.length,
|
||||
},
|
||||
));
|
||||
|
||||
d.register('files.ls', (req) async {
|
||||
final dir = (req.args['path'] as String?) ?? '';
|
||||
final entries = await listDir(
|
||||
root: files.root,
|
||||
dir: dir,
|
||||
ignore: files.ignore,
|
||||
);
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {
|
||||
'path': dir,
|
||||
'entries': [for (final e in entries) e.toJson()],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
d.register('files.watch', (req) async {
|
||||
await files.startWatching();
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: const {'subscribed': true},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Directory _resolveWorkspaceRoot(Directory start) {
|
||||
Directory cur = start.absolute;
|
||||
for (var i = 0; i < 64; i++) {
|
||||
final g = Directory('${cur.path}/.git');
|
||||
if (g.existsSync()) return cur;
|
||||
final parent = cur.parent;
|
||||
if (parent.path == cur.path) break;
|
||||
cur = parent;
|
||||
}
|
||||
return start.absolute;
|
||||
}
|
||||
|
||||
/// Build the default IgnoreSet: clide's always-hide list + any
|
||||
/// `.gitignore` + `.clideignore` at the workspace root.
|
||||
///
|
||||
/// D-004 compliance note: this covers the single-file-at-root case.
|
||||
/// Full layering across arbitrary paths from `.pql/config.yaml`'s
|
||||
/// `ignore_files:` is future work — see Q-024 (to be recorded).
|
||||
IgnoreSet _defaultIgnore(Directory root) {
|
||||
final contents = <String>[];
|
||||
for (final name in ['.gitignore', '.clideignore']) {
|
||||
final f = File('${root.path}/$name');
|
||||
if (f.existsSync()) contents.add(f.readAsStringSync());
|
||||
}
|
||||
final user = IgnoreSet.parse(contents);
|
||||
// Merge: built-in patterns first, user patterns last. "Last match
|
||||
// wins" semantics give the user the ability to un-ignore via `!`
|
||||
// in a future extension of the matcher.
|
||||
return IgnoreSet([
|
||||
...IgnoreSet.builtin().patterns,
|
||||
...user.patterns,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/// Minimal gitignore-style matcher.
|
||||
///
|
||||
/// Implements the common subset: line-per-pattern, `#` comments,
|
||||
/// anchored-to-root (`/foo`), directory-only (`foo/`), `*` (any except
|
||||
/// `/`), `**` (any including `/`). Negation (`!foo`) is parsed but
|
||||
/// applied in list order — the last matching pattern wins.
|
||||
///
|
||||
/// Not implemented (deliberate simplification — Tier 1 scope):
|
||||
/// - Case-insensitive systems (Windows / macOS HFS+).
|
||||
/// - Nested .gitignore files inside subdirectories. A single ignore
|
||||
/// file at the root covers the whole tree.
|
||||
/// - Pattern comments at line ends (`foo # bar`).
|
||||
///
|
||||
/// Full D-004 layering (multiple ignore files from `.pql/config.yaml`'s
|
||||
/// `ignore_files:`, in order) is built on top of this primitive via
|
||||
/// [IgnoreSet].
|
||||
library;
|
||||
|
||||
/// A single parsed gitignore pattern.
|
||||
class IgnorePattern {
|
||||
IgnorePattern._({
|
||||
required this.source,
|
||||
required this.negated,
|
||||
required this.directoryOnly,
|
||||
required this.anchored,
|
||||
required this.regex,
|
||||
});
|
||||
|
||||
/// The raw line from the file (for diagnostics).
|
||||
final String source;
|
||||
final bool negated;
|
||||
final bool directoryOnly;
|
||||
final bool anchored;
|
||||
|
||||
/// Compiled pattern — runs against a repo-relative path (no leading
|
||||
/// slash, forward slashes only).
|
||||
final RegExp regex;
|
||||
|
||||
/// Parse a single line. Returns `null` on comment / blank.
|
||||
static IgnorePattern? parse(String line) {
|
||||
var s = line.trimRight();
|
||||
if (s.isEmpty || s.startsWith('#')) return null;
|
||||
|
||||
var negated = false;
|
||||
if (s.startsWith('!')) {
|
||||
negated = true;
|
||||
s = s.substring(1);
|
||||
}
|
||||
|
||||
var anchored = false;
|
||||
if (s.startsWith('/')) {
|
||||
anchored = true;
|
||||
s = s.substring(1);
|
||||
}
|
||||
|
||||
var directoryOnly = false;
|
||||
if (s.endsWith('/')) {
|
||||
directoryOnly = true;
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
|
||||
return IgnorePattern._(
|
||||
source: line,
|
||||
negated: negated,
|
||||
directoryOnly: directoryOnly,
|
||||
anchored: anchored,
|
||||
regex: _compile(s, anchored: anchored),
|
||||
);
|
||||
}
|
||||
|
||||
static RegExp _compile(String glob, {required bool anchored}) {
|
||||
final b = StringBuffer('^');
|
||||
if (!anchored) {
|
||||
// Unanchored: match anywhere in the path (either at root or
|
||||
// inside a subdirectory).
|
||||
b.write(r'(?:.*/)?');
|
||||
}
|
||||
var i = 0;
|
||||
while (i < glob.length) {
|
||||
// Handle the special triples first: `/**/` collapses to either
|
||||
// a single `/` (zero dirs between) or `/…/` (any depth).
|
||||
if (i + 3 <= glob.length && glob.substring(i, i + 3) == '/**') {
|
||||
if (i + 4 <= glob.length && glob[i + 3] == '/') {
|
||||
// `/**/` — zero or more directories
|
||||
b.write(r'/(?:.*/)?');
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
if (i + 3 == glob.length) {
|
||||
// Trailing `/**` — everything underneath
|
||||
b.write(r'(?:/.*)?');
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
final c = glob[i];
|
||||
if (c == '*') {
|
||||
if (i + 1 < glob.length && glob[i + 1] == '*') {
|
||||
// Bare `**` (not next to `/`): treat as `.*`
|
||||
b.write('.*');
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
// `*` — any except `/`
|
||||
b.write('[^/]*');
|
||||
}
|
||||
} else if (c == '?') {
|
||||
b.write('[^/]');
|
||||
} else if ('.^\$+(){}[]|\\'.contains(c)) {
|
||||
b.write('\\$c');
|
||||
} else {
|
||||
b.write(c);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
b.write(r'(?:/.*)?$'); // match subtree rooted at the glob
|
||||
return RegExp(b.toString());
|
||||
}
|
||||
|
||||
bool matches(String path, {required bool isDirectory}) {
|
||||
if (directoryOnly && !isDirectory) return false;
|
||||
return regex.hasMatch(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layered set of ignore files. Applied in order — later matches
|
||||
/// win (per D-004). A path is ignored when the last pattern that
|
||||
/// matches it is non-negated.
|
||||
class IgnoreSet {
|
||||
IgnoreSet(this._patterns);
|
||||
|
||||
final List<IgnorePattern> _patterns;
|
||||
|
||||
/// Build an IgnoreSet from the concatenated contents of multiple
|
||||
/// ignore files, in D-004's `ignore_files:` order.
|
||||
factory IgnoreSet.parse(Iterable<String> fileContents) {
|
||||
final patterns = <IgnorePattern>[];
|
||||
for (final content in fileContents) {
|
||||
for (final line in content.split('\n')) {
|
||||
final p = IgnorePattern.parse(line);
|
||||
if (p != null) patterns.add(p);
|
||||
}
|
||||
}
|
||||
return IgnoreSet(patterns);
|
||||
}
|
||||
|
||||
/// Apply the layered set to `path`.
|
||||
///
|
||||
/// `path` is repo-relative, forward-slashed, no leading slash.
|
||||
/// `isDirectory` is load-bearing for patterns that end in `/`.
|
||||
bool isIgnored(String path, {required bool isDirectory}) {
|
||||
bool ignored = false;
|
||||
for (final p in _patterns) {
|
||||
if (p.matches(path, isDirectory: isDirectory)) {
|
||||
ignored = !p.negated;
|
||||
}
|
||||
}
|
||||
return ignored;
|
||||
}
|
||||
|
||||
int get length => _patterns.length;
|
||||
|
||||
/// Exposed for composition: merging multiple IgnoreSets while
|
||||
/// preserving the deliberate "later wins" evaluation order.
|
||||
List<IgnorePattern> get patterns => List.unmodifiable(_patterns);
|
||||
|
||||
/// Clide-owned dirs that are always hidden regardless of the user's
|
||||
/// ignore files. Matches D-004's "walker magic: none except
|
||||
/// `.git/`" — but the tree-view UI benefits from hiding `.pql/` and
|
||||
/// `.dart_tool/` too since users never edit those by hand.
|
||||
static IgnoreSet builtin() => IgnoreSet.parse(const [
|
||||
'.git/\n.pql/\n.clide/\n.dart_tool/\nbuild/\nnode_modules/\n',
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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