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:
@@ -18,6 +18,19 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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
|
- `builtin.terminal` — general-purpose terminal pane, Tier-1 stub
|
||||||
upgraded to a working implementation. Contributes a `Terminal` tab
|
upgraded to a working implementation. Contributes a `Terminal` tab
|
||||||
in the workspace slot that spawns `$SHELL -l` via IPC
|
in the workspace slot that spawns `$SHELL -l` via IPC
|
||||||
|
|||||||
@@ -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/extension/extension.dart';
|
||||||
|
import 'package:clide_app/kernel/kernel.dart';
|
||||||
|
|
||||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
||||||
/// is registered so the extensions-ui surface can list it as "installed,
|
/// the workspace file tree rooted at the git root, powered by the
|
||||||
/// not yet implemented" and its id is reserved.
|
/// daemon's `files.*` subsystem (ls + watch with ignore-file
|
||||||
|
/// filtering).
|
||||||
class FilesExtension extends ClideExtension {
|
class FilesExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
String get id => 'builtin.files';
|
String get id => 'builtin.files';
|
||||||
@override
|
@override
|
||||||
String get title => 'Files';
|
String get title => 'Files';
|
||||||
@override
|
@override
|
||||||
String get version => '0.0.0-stub';
|
String get version => '0.1.0';
|
||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const [];
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => const [];
|
List<ContributionPoint> get contributions => [
|
||||||
|
TabContribution(
|
||||||
|
id: 'files.tree',
|
||||||
|
slot: Slots.sidebar,
|
||||||
|
title: 'Files',
|
||||||
|
titleKey: 'tab.title',
|
||||||
|
i18nNamespace: id,
|
||||||
|
priority: -100,
|
||||||
|
build: (_) => const FileTreeView(),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DaemonEvent>().listen(_onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DaemonClient ipc;
|
||||||
|
final EventBus events;
|
||||||
|
|
||||||
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
|
String? _rootPath;
|
||||||
|
String? get rootPath => _rootPath;
|
||||||
|
|
||||||
|
String? _error;
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
bool _watchSubscribed = false;
|
||||||
|
|
||||||
|
final Set<String> _expanded = {''}; // '' = workspace root
|
||||||
|
bool isExpanded(String path) => _expanded.contains(path);
|
||||||
|
|
||||||
|
final Map<String, List<FileEntry>> _entries = {};
|
||||||
|
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||||
|
|
||||||
|
/// Initial boot: resolve the workspace root, load the root dir,
|
||||||
|
/// subscribe to `files.changed` events.
|
||||||
|
Future<void> 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<void> 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<void> refresh(String path) async {
|
||||||
|
await _loadDir(path);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<Map>())
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileTreeView> createState() => _FileTreeViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileTreeViewState extends State<FileTreeView> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"tab.title": { "translation": "Files" },
|
||||||
|
"loading": { "translation": "Loading…" },
|
||||||
|
"empty": { "translation": "No visible files" }
|
||||||
|
}
|
||||||
@@ -123,4 +123,5 @@ const List<String> _tier0Namespaces = [
|
|||||||
'builtin.ipc-status',
|
'builtin.ipc-status',
|
||||||
'builtin.theme-picker',
|
'builtin.theme-picker',
|
||||||
'builtin.terminal',
|
'builtin.terminal',
|
||||||
|
'builtin.files',
|
||||||
];
|
];
|
||||||
|
|||||||
+6
-1
@@ -72,9 +72,13 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
socketPath: socketPath,
|
socketPath: socketPath,
|
||||||
dispatch: dispatcher.dispatch,
|
dispatch: dispatcher.dispatch,
|
||||||
);
|
);
|
||||||
final registry = PaneRegistry(events: _ServerEventSink(server));
|
final events = _ServerEventSink(server);
|
||||||
|
final registry = PaneRegistry(events: events);
|
||||||
registerPaneCommands(dispatcher, registry);
|
registerPaneCommands(dispatcher, registry);
|
||||||
|
|
||||||
|
final files = FilesService.atCwd(events: events);
|
||||||
|
registerFilesCommands(dispatcher, files);
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
final stopping = Completer<void>();
|
||||||
void shutdown(ProcessSignal sig) {
|
void shutdown(ProcessSignal sig) {
|
||||||
if (!stopping.isCompleted) {
|
if (!stopping.isCompleted) {
|
||||||
@@ -89,6 +93,7 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
await server.start();
|
await server.start();
|
||||||
await stopping.future;
|
await stopping.future;
|
||||||
await registry.shutdown();
|
await registry.shutdown();
|
||||||
|
await files.shutdown();
|
||||||
await server.stop();
|
await server.stop();
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/daemon/dispatcher.dart';
|
export 'src/daemon/dispatcher.dart';
|
||||||
|
export 'src/daemon/files_commands.dart';
|
||||||
export 'src/daemon/pane_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/envelope.dart';
|
||||||
export 'src/ipc/paths.dart';
|
export 'src/ipc/paths.dart';
|
||||||
export 'src/ipc/schema_v1.dart';
|
export 'src/ipc/schema_v1.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<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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user