drive workspace ignore from ignore_files:, add files.walk
Replace the hardcoded .gitignore + .clideignore read with the ordered ignore_files: chain from .pql/config.yaml (D-4) — the single ignore knob clide owns (D-3). readIgnoreFiles defaults to .gitignore (plus .clideignore when present) when the config is absent or malformed, and honours an explicit [] as "no file-based exclusions". Add walkFiles + the files.walk command: a recursive, ignore-pruned, capped flat file listing reused by quick-open (T-51) and the search engine (T-52). Closes the never-filed ignore-layering placeholder in files_commands.dart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = <String>[];
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -73,3 +73,54 @@ Future<List<FileEntry>> 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<FileEntry> 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<WalkResult> walkFiles({
|
||||
required Directory root,
|
||||
required IgnoreSet ignore,
|
||||
int maxFiles = 50000,
|
||||
}) async {
|
||||
final out = <FileEntry>[];
|
||||
// DFS over repo-relative directory paths; '' is the root itself.
|
||||
final stack = <String>[''];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<String> 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 = <String>['.gitignore'];
|
||||
if (File('${root.path}/.clideignore').existsSync()) {
|
||||
names.add('.clideignore');
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -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<String>();
|
||||
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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user