From 4ff5556896051135c5d19bf81ef019a125aa34d5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 09:36:02 +0200 Subject: [PATCH] =?UTF-8?q?implement=20builtin.files=20=E2=80=94=20workspa?= =?UTF-8?q?ce=20file=20tree=20with=20live=20watcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 + app/lib/builtin/files/src/extension.dart | 24 +- .../files/src/file_tree_controller.dart | 123 ++++++++ app/lib/builtin/files/src/file_tree_view.dart | 269 ++++++++++++++++++ .../src/i18n/catalog/builtin.files_en_us.json | 5 + app/lib/main.dart | 1 + bin/clide.dart | 7 +- lib/clide.dart | 4 + lib/src/daemon/files_commands.dart | 125 ++++++++ lib/src/files/ignore.dart | 174 +++++++++++ lib/src/files/listing.dart | 79 +++++ lib/src/files/watcher.dart | 106 +++++++ test/daemon/files_commands_test.dart | 80 ++++++ test/files/ignore_test.dart | 80 ++++++ 14 files changed, 1084 insertions(+), 6 deletions(-) create mode 100644 app/lib/builtin/files/src/file_tree_controller.dart create mode 100644 app/lib/builtin/files/src/file_tree_view.dart create mode 100644 app/lib/kernel/src/i18n/catalog/builtin.files_en_us.json create mode 100644 lib/src/daemon/files_commands.dart create mode 100644 lib/src/files/ignore.dart create mode 100644 lib/src/files/listing.dart create mode 100644 lib/src/files/watcher.dart create mode 100644 test/daemon/files_commands_test.dart create mode 100644 test/files/ignore_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e9e120..5caa66b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- `builtin.files` — workspace filesystem panel in the sidebar. Lazy + tree rooted at the git root, expand/collapse, click-to-open plumbed + to a future `editor.open` command. Backed by a new daemon-side + `files.*` IPC subsystem (`files.root`, `files.ls`, `files.watch`) + and a `FileWatcher` that wraps `Directory.watch(recursive: true)` + with ignore-file filtering. Ignore set composes clide's built-in + hide list (`.git/`, `.pql/`, `.clide/`, `.dart_tool/`, `build/`, + `node_modules/`) with `.gitignore` / `.clideignore` at the root per + D-004. `IgnoreSet` + `IgnorePattern` support line-per-pattern, `#` + comments, anchored / directory-only / negated forms, and `**` across + directories. 11 new unit tests on the matcher; 5 new dispatcher + tests; 171 app tests still green. + - `builtin.terminal` — general-purpose terminal pane, Tier-1 stub upgraded to a working implementation. Contributes a `Terminal` tab in the workspace slot that spawns `$SHELL -l` via IPC diff --git a/app/lib/builtin/files/src/extension.dart b/app/lib/builtin/files/src/extension.dart index f800f20e..0a75caa7 100644 --- a/app/lib/builtin/files/src/extension.dart +++ b/app/lib/builtin/files/src/extension.dart @@ -1,17 +1,31 @@ +import 'package:clide_app/builtin/files/src/file_tree_view.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +/// Workspace filesystem panel. Contributes a sidebar tab that renders +/// the workspace file tree rooted at the git root, powered by the +/// daemon's `files.*` subsystem (ls + watch with ignore-file +/// filtering). class FilesExtension extends ClideExtension { @override String get id => 'builtin.files'; @override String get title => 'Files'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'files.tree', + slot: Slots.sidebar, + title: 'Files', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -100, + build: (_) => const FileTreeView(), + ), + ]; } diff --git a/app/lib/builtin/files/src/file_tree_controller.dart b/app/lib/builtin/files/src/file_tree_controller.dart new file mode 100644 index 00000000..0f7493cd --- /dev/null +++ b/app/lib/builtin/files/src/file_tree_controller.dart @@ -0,0 +1,123 @@ +/// State model for the file-tree panel. +/// +/// Owns a map of expanded directory → entries (lazy-loaded), the +/// workspace root path, and an IPC subscription to `files.changed` +/// events. Invalidation on events is coarse today — a change under +/// `a/b/` invalidates every currently-expanded directory that could +/// have been affected. Refinement (per-dir change tracking) is a +/// clear win once the tree gets large. +library; + +import 'dart:async'; + +import 'package:clide/clide.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/foundation.dart'; + +class FileTreeController extends ChangeNotifier { + FileTreeController({required this.ipc, required this.events}) { + _eventSub = events.on().listen(_onEvent); + } + + final DaemonClient ipc; + final EventBus events; + + StreamSubscription? _eventSub; + + String? _rootPath; + String? get rootPath => _rootPath; + + String? _error; + String? get error => _error; + + bool _watchSubscribed = false; + + final Set _expanded = {''}; // '' = workspace root + bool isExpanded(String path) => _expanded.contains(path); + + final Map> _entries = {}; + List? entriesFor(String path) => _entries[path]; + + /// Initial boot: resolve the workspace root, load the root dir, + /// subscribe to `files.changed` events. + Future load() async { + final rootResp = await ipc.request('files.root'); + if (!rootResp.ok) { + _error = rootResp.error?.message ?? 'files.root failed'; + notifyListeners(); + return; + } + _rootPath = rootResp.data['path'] as String?; + + final watchResp = await ipc.request('files.watch'); + _watchSubscribed = watchResp.ok; + + await _loadDir(''); + notifyListeners(); + } + + Future toggle(String path) async { + if (_expanded.contains(path)) { + _expanded.remove(path); + notifyListeners(); + } else { + _expanded.add(path); + if (!_entries.containsKey(path)) { + await _loadDir(path); + } + notifyListeners(); + } + } + + Future refresh(String path) async { + await _loadDir(path); + notifyListeners(); + } + + Future _loadDir(String path) async { + final r = await ipc.request('files.ls', args: {'path': path}); + if (!r.ok) { + _error = r.error?.message ?? 'files.ls($path) failed'; + return; + } + final raw = (r.data['entries'] as List?) ?? const []; + _entries[path] = [ + for (final e in raw.whereType()) + FileEntry( + name: e['name']! as String, + path: e['path']! as String, + isDirectory: e['isDirectory']! as bool, + isSymlink: (e['isSymlink'] as bool?) ?? false, + sizeBytes: (e['sizeBytes'] as num?)?.toInt(), + modifiedMs: (e['modifiedMs'] as num?)?.toInt(), + ), + ]; + } + + void _onEvent(DaemonEvent e) { + if (e.subsystem != 'files') return; + if (e.kind != 'files.changed') return; + // Coarse invalidation: reload the parent directory of the change, + // plus the root if the change is at top-level. This keeps the + // tree accurate without optimistic local mutation. + final path = (e.data['path'] as String?) ?? ''; + final parent = _parentOf(path); + if (_entries.containsKey(parent)) { + unawaited(refresh(parent)); + } + } + + static String _parentOf(String path) { + final slash = path.lastIndexOf('/'); + return slash < 0 ? '' : path.substring(0, slash); + } + + bool get watchSubscribed => _watchSubscribed; + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + super.dispose(); + } +} diff --git a/app/lib/builtin/files/src/file_tree_view.dart b/app/lib/builtin/files/src/file_tree_view.dart new file mode 100644 index 00000000..93b198ec --- /dev/null +++ b/app/lib/builtin/files/src/file_tree_view.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +import 'file_tree_controller.dart'; + +/// Sidebar panel rendering the workspace file tree. +/// +/// Lazy-expands directories via `files.ls`, subscribes to +/// `files.changed` events from the daemon, and refreshes the affected +/// subtrees on change. Click-to-open is plumbed through `kernel.commands` +/// — today the command doesn't exist yet (lands with Tier 2's editor); +/// the view degrades gracefully to a no-op when the command isn't +/// registered. +class FileTreeView extends StatefulWidget { + const FileTreeView({super.key}); + + @override + State createState() => _FileTreeViewState(); +} + +class _FileTreeViewState extends State { + FileTreeController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller != null) return; + final kernel = ClideKernel.of(context); + _controller = FileTreeController(ipc: kernel.ipc, events: kernel.events); + unawaited(_controller!.load()); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + if (c == null) return const SizedBox.shrink(); + return ListenableBuilder( + listenable: c, + builder: (context, _) { + if (c.error != null && c.rootPath == null) { + return Padding( + padding: const EdgeInsets.all(12), + child: ClideText(c.error!, muted: true, fontSize: 12), + ); + } + final root = c.rootPath; + if (root == null) { + return const Padding( + padding: EdgeInsets.all(12), + child: ClideText('Loading…', muted: true, fontSize: 12), + ); + } + final rootName = root.split(Platform.pathSeparator).last; + return Semantics( + label: 'file tree — $rootName', + container: true, + explicitChildNodes: true, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _DirRow( + name: rootName, + path: '', + controller: c, + depth: 0, + ), + if (c.isExpanded('')) + _Children(path: '', controller: c, depth: 1), + ], + ), + ), + ); + }, + ); + } +} + +class _Children extends StatelessWidget { + const _Children({ + required this.path, + required this.controller, + required this.depth, + }); + + final String path; + final FileTreeController controller; + final int depth; + + @override + Widget build(BuildContext context) { + final entries = controller.entriesFor(path); + if (entries == null) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final e in entries) + if (e.isDirectory) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _DirRow( + name: e.name, + path: e.path, + controller: controller, + depth: depth, + ), + if (controller.isExpanded(e.path)) + _Children(path: e.path, controller: controller, depth: depth + 1), + ], + ) + else + _FileRow( + name: e.name, + path: e.path, + depth: depth, + ), + ], + ); + } +} + +class _DirRow extends StatelessWidget { + const _DirRow({ + required this.name, + required this.path, + required this.controller, + required this.depth, + }); + + final String name; + final String path; + final FileTreeController controller; + final int depth; + + @override + Widget build(BuildContext context) { + final expanded = controller.isExpanded(path); + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: '${expanded ? 'Collapse' : 'Expand'} $name', + onTap: () => controller.toggle(path), + child: _Row( + depth: depth, + onTap: () => controller.toggle(path), + leading: ClideIcon( + const ChevronRightIcon(), + size: 10, + color: tokens.sidebarForeground, + ), + label: name, + rotateLeading: expanded, + ), + ); + } +} + +class _FileRow extends StatelessWidget { + const _FileRow({ + required this.name, + required this.path, + required this.depth, + }); + + final String name; + final String path; + final int depth; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Open $name', + onTap: () => _openFile(context, path), + child: _Row( + depth: depth, + onTap: () => _openFile(context, path), + label: name, + ), + ); + } + + void _openFile(BuildContext context, String path) { + final kernel = ClideKernel.of(context); + // editor.open is a Tier-2 command; the registry's contract takes + // a positional argv, so pass the path as argv[0]. Response is + // ignored — until the editor extension registers the handler, + // execute() returns a not-found error. + unawaited(kernel.commands.execute('editor.open', args: [path])); + } +} + +class _Row extends StatefulWidget { + const _Row({ + required this.depth, + required this.onTap, + required this.label, + this.leading, + this.rotateLeading = false, + }); + + final int depth; + final VoidCallback onTap; + final String label; + final Widget? leading; + final bool rotateLeading; + + @override + State<_Row> createState() => _RowState(); +} + +class _RowState extends State<_Row> { + bool _hover = false; + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final leftPadding = 8.0 + (widget.depth * 14.0); + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Container( + color: _hover ? tokens.sidebarItemHover : null, + padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3), + child: Row( + children: [ + if (widget.leading != null) ...[ + Transform.rotate( + angle: widget.rotateLeading ? 1.5708 : 0, // 90° when expanded + child: widget.leading, + ), + const SizedBox(width: 6), + ] else + const SizedBox(width: 16), + Expanded( + child: ClideText( + widget.label, + fontSize: 12, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: tokens.sidebarForeground, + ), + ), + ], + ), + ), + ), + ); + } +} + diff --git a/app/lib/kernel/src/i18n/catalog/builtin.files_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.files_en_us.json new file mode 100644 index 00000000..670ebe5f --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.files_en_us.json @@ -0,0 +1,5 @@ +{ + "tab.title": { "translation": "Files" }, + "loading": { "translation": "Loading…" }, + "empty": { "translation": "No visible files" } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index d638519a..9fc54d07 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -123,4 +123,5 @@ const List _tier0Namespaces = [ 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.terminal', + 'builtin.files', ]; diff --git a/bin/clide.dart b/bin/clide.dart index f6eb44fa..b2852576 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -72,9 +72,13 @@ Future _runDaemon(List args) async { socketPath: socketPath, dispatch: dispatcher.dispatch, ); - final registry = PaneRegistry(events: _ServerEventSink(server)); + final events = _ServerEventSink(server); + final registry = PaneRegistry(events: events); registerPaneCommands(dispatcher, registry); + final files = FilesService.atCwd(events: events); + registerFilesCommands(dispatcher, files); + final stopping = Completer(); void shutdown(ProcessSignal sig) { if (!stopping.isCompleted) { @@ -89,6 +93,7 @@ Future _runDaemon(List args) async { await server.start(); await stopping.future; await registry.shutdown(); + await files.shutdown(); await server.stop(); exit(0); } diff --git a/lib/clide.dart b/lib/clide.dart index dee86f93..d867b248 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -9,7 +9,11 @@ library; export 'src/daemon/dispatcher.dart'; +export 'src/daemon/files_commands.dart'; export 'src/daemon/pane_commands.dart'; +export 'src/files/ignore.dart'; +export 'src/files/listing.dart' show FileEntry, listDir; +export 'src/files/watcher.dart'; export 'src/ipc/envelope.dart'; export 'src/ipc/paths.dart'; export 'src/ipc/schema_v1.dart'; diff --git a/lib/src/daemon/files_commands.dart b/lib/src/daemon/files_commands.dart new file mode 100644 index 00000000..abcb7bf6 --- /dev/null +++ b/lib/src/daemon/files_commands.dart @@ -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 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 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 = []; + 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, + ]); +} diff --git a/lib/src/files/ignore.dart b/lib/src/files/ignore.dart new file mode 100644 index 00000000..ca288695 --- /dev/null +++ b/lib/src/files/ignore.dart @@ -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 _patterns; + + /// Build an IgnoreSet from the concatenated contents of multiple + /// ignore files, in D-004's `ignore_files:` order. + factory IgnoreSet.parse(Iterable fileContents) { + final patterns = []; + 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 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', + ]); +} diff --git a/lib/src/files/listing.dart b/lib/src/files/listing.dart new file mode 100644 index 00000000..d1a594e3 --- /dev/null +++ b/lib/src/files/listing.dart @@ -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 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> 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 = []; + 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; +} diff --git a/lib/src/files/watcher.dart b/lib/src/files/watcher.dart new file mode 100644 index 00000000..417e9c33 --- /dev/null +++ b/lib/src/files/watcher.dart @@ -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 toJson() => { + 'kind': kind.wire, + 'path': path, + 'isDirectory': isDirectory, + }; +} + +class FileWatcher { + FileWatcher({required this.root, required this.ignore}); + + final Directory root; + final IgnoreSet ignore; + + StreamSubscription? _sub; + final _controller = StreamController.broadcast(); + + Stream get stream => _controller.stream; + + Future start() async { + if (_sub != null) return; + _sub = root.watch(recursive: true).listen( + _onEvent, + onError: (Object e, StackTrace _) => _controller.addError(e), + ); + } + + Future 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, '/'); + } +} diff --git a/test/daemon/files_commands_test.dart b/test/daemon/files_commands_test.dart new file mode 100644 index 00000000..d4e3eada --- /dev/null +++ b/test/daemon/files_commands_test.dart @@ -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 call(String cmd, Map 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(); + 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(); + 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()) 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); + }); +} diff --git a/test/files/ignore_test.dart b/test/files/ignore_test.dart new file mode 100644 index 00000000..442393ce --- /dev/null +++ b/test/files/ignore_test.dart @@ -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); + }); + }); +}