From d26a1f895c5d9a91850e0a24d713c625382d6013 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 21:21:25 +0200 Subject: [PATCH] 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) --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 30 ++++++++ .../search/find_in_files_controller_test.dart | 25 +++++++ .../search/search_panel_view_test.dart | 46 ++++++++++++ test/search/grep_engine_test.dart | 18 +++++ test/widgets/quick_open_overlay_test.dart | 73 +++++++++++++++++++ 6 files changed, 194 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 4cbdf6c7..76c45ad2 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 48fcff49..681b4bf9 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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 --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); diff --git a/test/builtin/search/find_in_files_controller_test.dart b/test/builtin/search/find_in_files_controller_test.dart index 6c393fed..80e73c8a 100644 --- a/test/builtin/search/find_in_files_controller_test.dart +++ b/test/builtin/search/find_in_files_controller_test.dart @@ -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? 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']); + }); } diff --git a/test/builtin/search/search_panel_view_test.dart b/test/builtin/search/search_panel_view_test.dart index 349383f1..4e39254b 100644 --- a/test/builtin/search/search_panel_view_test.dart +++ b/test/builtin/search/search_panel_view_test.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)); + }); } diff --git a/test/search/grep_engine_test.dart b/test/search/grep_engine_test.dart index ca95b679..ab8dc7be 100644 --- a/test/search/grep_engine_test.dart +++ b/test/search/grep_engine_test.dart @@ -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 = []; await for (final batch in grepWorkspace( diff --git a/test/widgets/quick_open_overlay_test.dart b/test/widgets/quick_open_overlay_test.dart index 3884b90c..a6a841d5 100644 --- a/test/widgets/quick_open_overlay_test.dart +++ b/test/widgets/quick_open_overlay_test.dart @@ -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); + }); }