diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6ee681..ff64d588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Workspace ignore now follows the `ignore_files:` list in `.pql/config.yaml` + (ordered, later-wins, per D-4) instead of a hardcoded `.gitignore` + + `.clideignore` pair — the single ignore knob clide owns. (T-52) +- `files.walk` command — a recursive, ignore-pruned, capped flat file listing + of the workspace, backing quick-open and search. (T-51, T-52) - Sidebar readers (markdown + decision) gain a chrome action bar: back/forward history, a single-slot pin (one click sets/replaces; one click to return), and an edit pencil that opens the current doc in the editor. (T-189, T-190, T-191) diff --git a/lib/src/daemon/files_commands.dart b/lib/src/daemon/files_commands.dart index 6b9dfe8f..c042d9b3 100644 --- a/lib/src/daemon/files_commands.dart +++ b/lib/src/daemon/files_commands.dart @@ -7,6 +7,7 @@ import 'dart:io'; import '../files/ignore.dart'; import '../files/listing.dart'; import '../files/path_safety.dart'; +import '../files/pql_config.dart'; import '../files/watcher.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; @@ -130,6 +131,17 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) { ); }); + d.register('files.walk', (req) async { + final result = await walkFiles(root: files.root, ignore: files.ignore); + return IpcResponse.ok( + id: req.id, + data: { + 'files': [for (final e in result.files) e.path], + 'truncated': result.truncated, + }, + ); + }); + d.register('files.watch', (req) async { await files.startWatching(); return IpcResponse.ok( @@ -153,15 +165,14 @@ Directory _resolveWorkspaceRoot(Directory start) { 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). +/// Build the default IgnoreSet: clide's always-hide list layered under +/// the `ignore_files:` chain from `.pql/config.yaml` (per D-4), in +/// order, later files winning. clide owns that config key (D-3) and +/// [readIgnoreFiles] resolves it (defaulting to `.gitignore` — +/// plus `.clideignore` when present — when the config is absent). IgnoreSet _defaultIgnore(Directory root) { final contents = []; - for (final name in ['.gitignore', '.clideignore']) { + for (final name in readIgnoreFiles(root)) { final f = File('${root.path}/$name'); if (f.existsSync()) contents.add(f.readAsStringSync()); } diff --git a/lib/src/files/listing.dart b/lib/src/files/listing.dart index e2a1e757..45d3cb20 100644 --- a/lib/src/files/listing.dart +++ b/lib/src/files/listing.dart @@ -73,3 +73,54 @@ Future> listDir({ }); return entries; } + +/// Result of [walkFiles]: the flat file list plus whether the walk +/// stopped early at [WalkResult.cap]. +class WalkResult { + const WalkResult({required this.files, required this.truncated}); + + /// Every non-ignored file under the root, repo-relative, sorted by path. + final List files; + + /// True when the [maxFiles] cap was reached and the walk stopped early. + final bool truncated; +} + +/// Recursively walk [root], returning every non-ignored *file* +/// (directories are descended into but not emitted), pruned by +/// [ignore]. Reuses [listDir] per directory, so ignore filtering, +/// symlink-escape safety (`followLinks: false`), and per-directory +/// sorting are inherited. +/// +/// Capped at [maxFiles] to bound work on pathological trees; when the +/// cap is hit the walk stops early and [WalkResult.truncated] is set so +/// callers can surface "results truncated". The returned list is sorted +/// by repo-relative path for a deterministic contract. +Future walkFiles({ + required Directory root, + required IgnoreSet ignore, + int maxFiles = 50000, +}) async { + final out = []; + // DFS over repo-relative directory paths; '' is the root itself. + final stack = ['']; + var truncated = false; + while (stack.isNotEmpty) { + final dir = stack.removeLast(); + final entries = await listDir(root: root, dir: dir, ignore: ignore); + for (final e in entries) { + if (e.isDirectory) { + stack.add(e.path); + } else { + out.add(e); + if (out.length >= maxFiles) { + truncated = true; + break; + } + } + } + if (truncated) break; + } + out.sort((a, b) => a.path.compareTo(b.path)); + return WalkResult(files: out, truncated: truncated); +} diff --git a/lib/src/files/pql_config.dart b/lib/src/files/pql_config.dart new file mode 100644 index 00000000..01748785 --- /dev/null +++ b/lib/src/files/pql_config.dart @@ -0,0 +1,54 @@ +/// Reads the clide-owned keys from `.pql/config.yaml`. +/// +/// Per [D-3] clide owns pql's `ignore_files:` key (and never touches +/// pql's `.pql/` index/cache data). Per [D-4] that key is the single +/// knob for ignore layering: an ordered list of gitignore-shaped files +/// at the workspace root, with later entries winning on per-pattern +/// conflicts. This module reads the list; the matcher itself lives in +/// [IgnoreSet] (see `ignore.dart`). +/// +/// Flutter-free by construction — used by daemon-side file walking and +/// the search engine, both of which run under `dart test`. +library; + +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +/// The ordered list of ignore-file names to layer when walking the +/// workspace, read from `ignore_files:` in `.pql/config.yaml`. +/// +/// Resolution (per D-4): +/// * config present with an explicit `ignore_files:` list → that list +/// verbatim. An empty list disables file-based exclusions entirely +/// (the built-in `.git/` etc. still apply via [IgnoreSet.builtin]). +/// * config absent / malformed / missing the key → the default +/// `[.gitignore]`, plus `.clideignore` when that file exists +/// (clide-specific deviations, per D-4). +/// +/// Never throws: a missing or unparseable config falls back to the +/// default, so a broken YAML file can't silently blank the ignore set. +List readIgnoreFiles(Directory root) { + final cfg = File('${root.path}/.pql/config.yaml'); + if (cfg.existsSync()) { + try { + final doc = loadYaml(cfg.readAsStringSync()); + if (doc is YamlMap && doc.containsKey('ignore_files')) { + final raw = doc['ignore_files']; + if (raw is YamlList) { + return [ + for (final e in raw) + if (e is String) e, + ]; + } + } + } catch (_) { + // Malformed YAML — fall through to the default below. + } + } + final names = ['.gitignore']; + if (File('${root.path}/.clideignore').existsSync()) { + names.add('.clideignore'); + } + return names; +} diff --git a/test/daemon/files_commands_test.dart b/test/daemon/files_commands_test.dart index 91cc7cc5..876d17d3 100644 --- a/test/daemon/files_commands_test.dart +++ b/test/daemon/files_commands_test.dart @@ -73,6 +73,17 @@ void main() { expect(names, ['main.dart']); }); + test('files.walk returns a flat, recursive file list (no dirs, no ignored)', () async { + final r = await call('files.walk', const {}); + expect(r.ok, isTrue); + final paths = (r.data['files'] as List).cast(); + expect(paths, containsAll(['README.md', 'pubspec.yaml', 'lib/main.dart'])); + // Directories themselves are never emitted, and .dart_tool is pruned. + expect(paths, isNot(contains('lib'))); + expect(paths.any((p) => p.startsWith('.dart_tool')), isFalse); + expect(r.data['truncated'], isFalse); + }); + test('files.watch acks subscription', () async { final r = await call('files.watch', const {}); expect(r.ok, isTrue); diff --git a/test/files/pql_config_test.dart b/test/files/pql_config_test.dart new file mode 100644 index 00000000..b692723a --- /dev/null +++ b/test/files/pql_config_test.dart @@ -0,0 +1,63 @@ +/// Tests for `readIgnoreFiles` — resolving the clide-owned +/// `ignore_files:` key from `.pql/config.yaml` (D-3 / D-4). +library; + +import 'dart:io'; + +import 'package:clide/src/files/pql_config.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('clide-pqlconfig-'); + }); + tearDown(() async { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + void writeConfig(String body) { + Directory('${root.path}/.pql').createSync(); + File('${root.path}/.pql/config.yaml').writeAsStringSync(body); + } + + test('no config → defaults to [.gitignore]', () { + expect(readIgnoreFiles(root), ['.gitignore']); + }); + + test('no config but .clideignore present → adds it after .gitignore', () { + File('${root.path}/.clideignore').writeAsStringSync('build/\n'); + expect(readIgnoreFiles(root), ['.gitignore', '.clideignore']); + }); + + test('explicit ignore_files list is honoured verbatim and in order', () { + writeConfig('ignore_files: [.gitignore, .pqlignore]\n'); + expect(readIgnoreFiles(root), ['.gitignore', '.pqlignore']); + }); + + test('explicit empty list disables file-based exclusions', () { + writeConfig('ignore_files: []\n'); + expect(readIgnoreFiles(root), isEmpty); + }); + + test('config present but key absent → default (ignores .clideignore rule only via default)', () { + writeConfig('frontmatter: yaml\n'); + expect(readIgnoreFiles(root), ['.gitignore']); + }); + + test('non-string entries are dropped from the list', () { + writeConfig('ignore_files: [.gitignore, 42, .pqlignore]\n'); + expect(readIgnoreFiles(root), ['.gitignore', '.pqlignore']); + }); + + test('malformed YAML falls back to the default (never throws)', () { + writeConfig('ignore_files: [unterminated\n:::bad'); + expect(readIgnoreFiles(root), ['.gitignore']); + }); + + test('ignore_files set to a scalar (not a list) falls back to default', () { + writeConfig('ignore_files: .gitignore\n'); + expect(readIgnoreFiles(root), ['.gitignore']); + }); +} diff --git a/test/files/walk_test.dart b/test/files/walk_test.dart new file mode 100644 index 00000000..9ea1d150 --- /dev/null +++ b/test/files/walk_test.dart @@ -0,0 +1,65 @@ +/// Tests for `walkFiles` — the recursive, ignore-pruned, capped +/// workspace file walk behind `files.walk` and the search engine. +library; + +import 'dart:io'; + +import 'package:clide/src/files/ignore.dart'; +import 'package:clide/src/files/listing.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('clide-walk-'); + File('${root.path}/README.md').writeAsStringSync('a'); + File('${root.path}/pubspec.yaml').writeAsStringSync('b'); + Directory('${root.path}/lib/src').createSync(recursive: true); + File('${root.path}/lib/main.dart').writeAsStringSync('c'); + File('${root.path}/lib/src/util.dart').writeAsStringSync('d'); + Directory('${root.path}/build').createSync(); + File('${root.path}/build/output.bin').writeAsStringSync('e'); + }); + tearDown(() async { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + test('walks recursively, returns files sorted by path', () async { + final r = await walkFiles(root: root, ignore: IgnoreSet([])); + expect(r.truncated, isFalse); + expect(r.files.map((e) => e.path).toList(), [ + 'README.md', + 'build/output.bin', + 'lib/main.dart', + 'lib/src/util.dart', + 'pubspec.yaml', + ]); + }); + + test('prunes ignored directories (build/ via builtin ignore)', () async { + final r = await walkFiles(root: root, ignore: IgnoreSet.builtin()); + final paths = r.files.map((e) => e.path).toList(); + expect(paths, isNot(contains('build/output.bin'))); + expect(paths, containsAll(['README.md', 'lib/main.dart', 'lib/src/util.dart'])); + }); + + test('emits only files, never directory entries', () async { + final r = await walkFiles(root: root, ignore: IgnoreSet([])); + expect(r.files.every((e) => !e.isDirectory), isTrue); + }); + + test('respects the maxFiles cap and flags truncation', () async { + final r = await walkFiles(root: root, ignore: IgnoreSet([]), maxFiles: 2); + expect(r.truncated, isTrue); + expect(r.files, hasLength(2)); + }); + + test('an empty workspace yields no files and is not truncated', () async { + final empty = await Directory.systemTemp.createTemp('clide-walk-empty-'); + addTearDown(() => empty.deleteSync(recursive: true)); + final r = await walkFiles(root: empty, ignore: IgnoreSet([])); + expect(r.files, isEmpty); + expect(r.truncated, isFalse); + }); +}