cover quick-open + find-in-files edge cases

Add tests for the quick-open overlay's keymap-intent handlers (nav,
accept, dismiss), the no-match / truncated / walk-failure hints, the
search panel's error + no-results states and toggle re-run, the
controller's failed-grep and exclude paths, and engine glob/regex-group
cases. Restores the coverage floor (95.20%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:21:25 +02:00
co-authored by Claude Opus 4.8
parent f96c565acd
commit d26a1f895c
6 changed files with 194 additions and 0 deletions
@@ -2375,3 +2375,5 @@ Ignore — same full ignore_files: layering as T-52 (D-4).
Files: lib/src/daemon/files_commands.dart (files.write); new replacement-preview controller/view under lib/builtin/search/; reuse path_safety + diff render primitives; DialogRouter confirm; IPC schema + CLI parity verbs.', NULL, '2026-05-31 18:01:14', '2026-05-31 18:01:14', '2026-05-31 18:01:14', NULL, '4d4ee79e82da8d3985dbf016ed6315ba', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-52', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 18:01:23', '2026-05-31 18:01:23', '2026-05-31 18:01:23', NULL, '1715b4493ba4beda216d701cf0c41121', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 18:01:23', '2026-05-31 18:01:23', '2026-05-31 18:01:23', NULL, '386ac9084ce3fe9c035a56bba3cd1c42', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-52', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'bac566c015a7d599cbb2fc9b3855400b', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'fafab627f731815e6f83bfaf310543a6', 1) ON CONFLICT(hash) DO NOTHING;
+30
View File
@@ -3013,3 +3013,33 @@ Open-at-line — editor.open takes only {path} today. Extend it with an optional
Sidebar panel follows the files/git/decisions TabContribution(slot: Slots.sidebar) pattern; custom widgets, no Material/Cupertino.
Files: new lib/builtin/search/{extension,search_controller,search_panel_view}.dart; new lib/src/daemon/search_commands.dart (search.grep + isolate pool); lib/src/files ignore layering; editor_commands.dart + registry.dart (line param); IPC schema + dispatcher registration; CLI `clide search grep` verb.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 18:01:23', NULL, '7a80f9a305e2e93f33820bea9cb68740', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'story', NULL, 'quick file open (Ctrl+P)', 'Fuzzy file finder overlay, separate from the command palette. Wired to pql files with debounced input. Shows file path with match highlighting. Enter opens in editor or markdown viewer depending on file type. Recent files shown when input is empty.
Refinement (2026-05-31):
File source no repo-wide listing exists today (files.ls is per-directory). Add a `files.walk` daemon IPC verb that recursively walks the workspace honoring IgnoreSet (per D-4) and returns a flat file list.
Open routing mirrors the files panel after T-187: a `.md` path publishes (''builtin.markdown'',''selection'',{path}) to open the markdown reader; any other path uses editor.open. (The ticket''s "editor or markdown viewer depending on file type" IS this split.)
Recent files none exists today (ProjectManager.recents tracks projects, not files). Add a small kernel RecentFilesService (bounded in-memory list of repo-relative paths) pushed from the shared open dispatch in file_tree_view.dart; empty-input state shows it.
Overlay mirrors the ClidePalette chrome and reuses the 200ms-debounced ClideFilterBox. New QuickOpenIntent bound to ctrl+p / meta+p, guarded so it does not fire while the command palette is open (when: !palette.open verify the keymap when-parser supports negation; otherwise pick a non-conflicting default). ctrl+p is currently palette.selectPrevious.
Files: new lib/builtin/files/src/quick_open_overlay.dart + extension registration; new lib/kernel/src/recent_files.dart; files_commands.dart (files.walk); keymap intents + default.yaml; app.dart Stack mount alongside ClidePalette.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, '24320514183313ddd9f775d8f63eed3e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-52', 'story', NULL, 'find in files (Ctrl+Shift+F)', 'Workspace-wide text search via ripgrep or grep fallback. Sidebar panel with search input, match-in-context results, clickable to open file at line. Supports regex toggle, case sensitivity toggle, include/exclude glob filters.
Refinement (2026-05-31) see D-79:
Backend = pure-Dart in-process grep engine. An isolate worker pool fans non-ignored files across cores; match with RegExp, with a literal indexOf fast-path when the regex toggle is off. Results STREAM over a new engine-agnostic IPC verb `search.grep` (args: terms, regex, ignoreCase, include/exclude globs), with cancellation when the query changes. NOT ripgrep/grep (unvendored external binary, not guaranteed cross-platform against prefer-zero-deps). NOT pql: pql search is a ranked document index (path/score/connections) with no line numbers, snippets, regex, case, or glob, so it structurally cannot satisfy find-in-files.
Future work (note only, do not build): optional "use rg when on PATH, else the built-in engine" accelerator behind the same search.grep verb no caller changes (the contract is engine-agnostic).
Tree-sitter is NOT used here it is a parser for structural/semantic search (find usages, go-to-def), a separate future feature, not text grep.
Ignore handling implement the FULL ignore_files: ordered layering from .pql/config.yaml (D-4) in-scope. This closes the never-filed "Q-024 (to be recorded)" placeholder comment at lib/src/daemon/files_commands.dart:161 remove that comment. (Current _defaultIgnore only reads .gitignore + .clideignore.)
Open-at-line editor.open takes only {path} today. Extend it with an optional 1-based `line` param (daemon converts line to byte offset and sets the initial Selection); backward compatible (line null = open at top). Enables CLI parity: clide editor open <path> --line N.
Sidebar panel follows the files/git/decisions TabContribution(slot: Slots.sidebar) pattern; custom widgets, no Material/Cupertino.
Files: new lib/builtin/search/{extension,search_controller,search_panel_view}.dart; new lib/src/daemon/search_commands.dart (search.grep + isolate pool); lib/src/files ignore layering; editor_commands.dart + registry.dart (line param); IPC schema + dispatcher registration; CLI `clide search grep` verb.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, '59534ff6067343e354f4946fe7931d2f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
@@ -148,4 +148,29 @@ void main() {
c.cancel();
expect(c.running, isFalse);
});
test('a failed search.grep surfaces the error', () async {
f.ipc.stub(
'search.grep',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: 'nope'),
));
final c = make();
await c.run('foo');
expect(c.error, 'nope');
expect(c.running, isFalse);
});
test('exclude setter feeds the next search', () async {
Map<String, Object?>? sent;
f.ipc.stub('search.grep', (args) async {
sent = args;
return _ok({'searchId': 's1'});
});
final c = make();
c.exclude = 'build/** , *.g.dart';
await c.run('foo');
expect(sent!['exclude'], ['build/**', '*.g.dart']);
});
}
@@ -69,4 +69,50 @@ void main() {
expect(opened!['path'], 'lib/a.dart');
expect(opened!['line'], 12);
});
testWidgets('no results shows the No results status', (tester) async {
await tester.pumpWidget(harness(f, const SearchPanelView()));
await tester.enterText(find.byType(EditableText).first, 'foo');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.done',
data: const {'searchId': 's1', 'cancelled': false},
ts: DateTime.now().toUtc(),
));
await pumpAsync(tester);
expect(find.text('No results'), findsOneWidget);
});
testWidgets('a search error is shown in the panel', (tester) async {
await tester.pumpWidget(harness(f, const SearchPanelView()));
await tester.enterText(find.byType(EditableText).first, '(bad');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.error',
data: const {'searchId': 's1', 'message': 'invalid regex: boom'},
ts: DateTime.now().toUtc(),
));
await pumpAsync(tester);
expect(find.textContaining('invalid regex'), findsOneWidget);
});
testWidgets('toggling regex re-runs the search', (tester) async {
var grepCalls = 0;
f.ipc.stub('search.grep', (_) async {
grepCalls++;
return _ok({'searchId': 's1'});
});
await tester.pumpWidget(harness(f, const SearchPanelView()));
await tester.enterText(find.byType(EditableText).first, 'foo');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
final before = grepCalls;
await tester.tap(find.text('.*')); // regex toggle
await pumpAsync(tester);
expect(grepCalls, greaterThan(before));
});
}
+18
View File
@@ -144,6 +144,24 @@ void main() {
// Spawns real worker isolates — runs in the --concurrency=1 serial
// pass to avoid competing with the parallel flutter pool (T-193).
test('? and ** glob metacharacters match as expected', () async {
File('${root.path}/a1.dart').writeAsStringSync('main\n');
// '?' matches a single char: a?.dart → a1.dart (and a.dart).
final q = await run(const SearchQuery(pattern: 'main', include: ['a?.dart']));
expect(q.any((m) => m.path == 'a1.dart'), isTrue);
expect(q.any((m) => m.path == 'sub/c.txt'), isFalse);
// '**' spans directories.
final r = await run(const SearchQuery(pattern: 'main', include: ['sub/**']));
expect(r.any((m) => m.path == 'sub/c.txt'), isTrue);
});
test('regex capture groups are available on the match line', () async {
// The engine surfaces spans; group expansion is the replacer's job
// (T-53), but the regex itself must match with groups.
final r = await run(const SearchQuery(pattern: r'final (\w+)', regex: true));
expect(r.any((m) => m.path == 'a.dart'), isTrue);
});
test('runs across isolates without error (smoke)', tags: ['serial'], () async {
final out = <SearchMatch>[];
await for (final batch in grepWorkspace(
+73
View File
@@ -90,4 +90,77 @@ void main() {
// Empty query → recents listed.
expect(find.text('app.dart'), findsOneWidget);
});
testWidgets('a non-matching query shows the no-match hint', (tester) async {
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), 'zzzzz-nope');
await pumpAsync(tester);
expect(find.text('No matching files'), findsOneWidget);
});
testWidgets('truncated walk surfaces the limited-results hint', (tester) async {
f.ipc.stub(
'files.walk',
(_) async => IpcResponse.ok(id: '1', data: const {
'files': ['lib/main.dart'],
'truncated': true,
}));
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), 'main');
await pumpAsync(tester);
expect(find.text('Results limited — large workspace'), findsOneWidget);
});
testWidgets('keymap intents drive nav, accept and dismiss', (tester) async {
String? opened;
f.ipc.stub('editor.open', (args) async {
opened = args['path'] as String?;
return IpcResponse.ok(id: '1', data: {'path': args['path']});
});
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), 'lib');
await pumpAsync(tester);
final ctx = tester.element(find.byType(EditableText));
Actions.invoke(ctx, const QuickOpenSelectNextIntent());
await pumpAsync(tester);
Actions.invoke(ctx, const QuickOpenSelectPreviousIntent());
await pumpAsync(tester);
// Accept opens the highlighted result in the editor.
Actions.invoke(ctx, const QuickOpenAcceptIntent());
await pumpAsync(tester);
expect(opened, isNotNull);
expect(f.services.quickOpen.isOpen, isFalse);
});
testWidgets('dismiss intent closes the overlay', (tester) async {
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
final ctx = tester.element(find.byType(EditableText));
Actions.invoke(ctx, const DismissIntent());
await pumpAsync(tester);
expect(f.services.quickOpen.isOpen, isFalse);
});
testWidgets('files.walk failure leaves the list empty (no crash)', (tester) async {
f.ipc.stub(
'files.walk',
(_) async => IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
));
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), 'main');
await pumpAsync(tester);
expect(find.text('No matching files'), findsOneWidget);
});
}