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,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user