merge the pql search panel into the unified Search tab

The pql sidebar panel and the find-in-files tab were duplicate search
surfaces. Consolidate into one Search tab with a mode switch: Find
(content grep), Vault (pql ranked search), Query (PQL DSL), Markdown
(the synced markdown-file listing, keeping focus-highlight + live
refresh). SearchPanelView holds both FindInFilesController and
PqlController; the pql body + result rows move into a reusable
PqlSearchBody. The standalone builtin.pql sidebar tab is removed (one
fewer tab — eases the rail); the pql extension keeps the Backlinks
context panel. No D-79 conflict — grep vs ranked search remain distinct
backends, this is UI consolidation.

Adds the pql builtin's first widget/controller tests (it was untested,
so folding it into the tested Search panel required covering the
Vault/Query/Markdown modes + PqlController).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:45:54 +02:00
co-authored by Claude Opus 4.8
parent eb90ba14bc
commit a02fc754a8
10 changed files with 430 additions and 134 deletions
+161
View File
@@ -0,0 +1,161 @@
/// Unit tests for [PqlController] — search, DSL query, markdown listing,
/// view/mode switching, and error handling. Previously untested; brought
/// under test when the pql search surface merged into the Search tab
/// (T-201).
library;
import 'package:clide/builtin/pql/src/pql_controller.dart';
import 'package:clide/clide.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
IpcResponse _err(String m) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m),
);
void main() {
late KernelFixture f;
late PqlController c;
setUp(() async {
f = await KernelFixture.create();
c = PqlController(ipc: f.ipc);
});
tearDown(() async {
c.dispose();
await f.dispose();
});
test('defaults to query view + search mode', () {
expect(c.view, PqlView.query);
expect(c.searchMode, SearchMode.search);
expect(c.results, isEmpty);
});
test('search with empty terms clears without an IPC call', () async {
var called = false;
f.ipc.stub('pql.search', (_) async {
called = true;
return _ok(const {'results': []});
});
await c.search(' ');
expect(called, isFalse);
expect(c.results, isEmpty);
});
test('search populates ranked results', () async {
f.ipc.stub(
'pql.search',
(args) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.8},
],
}));
await c.search('term');
expect(c.results.single['path'], 'a.md');
expect(c.error, isNull);
});
test('search surfaces an error', () async {
f.ipc.stub('pql.search', (_) async => _err('boom'));
await c.search('term');
expect(c.error, 'boom');
expect(c.results, isEmpty);
});
test('runQuery ignores empty input', () async {
var called = false;
f.ipc.stub('pql.query', (_) async {
called = true;
return _ok(const {'results': []});
});
await c.runQuery(' ');
expect(called, isFalse);
});
test('runQuery populates rows; error surfaces', () async {
f.ipc.stub(
'pql.query',
(args) async => _ok({
'results': [
{'name': 'T-1'},
],
}));
await c.runQuery("type = 'ticket'");
expect(c.results.single['name'], 'T-1');
f.ipc.stub('pql.query', (_) async => _err('bad dsl'));
await c.runQuery('nope');
expect(c.error, 'bad dsl');
expect(c.results, isEmpty);
});
test('loadMarkdownFiles populates + errors', () async {
f.ipc.stub(
'pql.files',
(args) async => _ok({
'files': [
{'path': 'docs/x.md'},
],
}));
await c.loadMarkdownFiles();
expect(c.results.single['path'], 'docs/x.md');
f.ipc.stub('pql.files', (_) async => _err('no fs'));
await c.loadMarkdownFiles();
expect(c.error, 'no fs');
});
test('switchView(markdown) changes view and auto-loads', () async {
var filesCalls = 0;
f.ipc.stub('pql.files', (_) async {
filesCalls++;
return _ok(const {'files': []});
});
c.switchView(PqlView.markdown);
expect(c.view, PqlView.markdown);
await Future<void>.delayed(Duration.zero);
expect(filesCalls, 1);
// Switching to the same view is a no-op.
c.switchView(PqlView.markdown);
expect(filesCalls, 1);
});
test('setSearchMode + toggleSearchMode flip the mode and clear results', () async {
f.ipc.stub(
'pql.search',
(_) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.5},
],
}));
await c.search('x');
expect(c.results, isNotEmpty);
c.setSearchMode(SearchMode.dsl);
expect(c.searchMode, SearchMode.dsl);
expect(c.results, isEmpty);
c.toggleSearchMode();
expect(c.searchMode, SearchMode.search);
c.setSearchMode(SearchMode.search); // no-op when unchanged
expect(c.searchMode, SearchMode.search);
});
test('clearError clears a set error', () async {
f.ipc.stub('pql.search', (_) async => _err('e'));
await c.search('x');
expect(c.error, 'e');
c.clearError();
expect(c.error, isNull);
c.clearError(); // no-op
expect(c.error, isNull);
});
test('loadPlanStatus stores the status payload', () async {
f.ipc.stub('pql.plan.status', (_) async => _ok(const {'tickets': 5}));
await c.loadPlanStatus();
expect(c.planStatus['tickets'], 5);
});
}
@@ -175,4 +175,104 @@ void main() {
expect(applyArgs!['apply'], isTrue);
expect(applyArgs!['replacement'], 'bar');
});
// -- Merged pql modes (T-201) ----------------------------------------------
testWidgets('Vault mode runs a ranked pql search and lists results', (tester) async {
Map<String, Object?>? grepArgs;
f.ipc.stub('pql.search', (args) async {
grepArgs = args;
return _ok({
'results': [
{'path': 'docs/vault-hit.md', 'score': 0.9},
],
});
});
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Vault'));
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText).first, 'concept');
await tester.pump(const Duration(milliseconds: 350)); // ranked-search debounce
await pumpAsync(tester);
expect(grepArgs?['terms'], 'concept');
expect(find.text('docs/vault-hit.md'), findsOneWidget);
});
testWidgets('Query mode runs a PQL DSL query on submit', (tester) async {
Map<String, Object?>? queryArgs;
f.ipc.stub('pql.query', (args) async {
queryArgs = args;
return _ok({
'results': [
{'name': 'T-1', 'status': 'backlog'},
],
});
});
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Query'));
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText).first, "type = 'ticket'");
await tester.testTextInput.receiveAction(TextInputAction.done); // onSubmitted
await pumpAsync(tester);
expect(queryArgs?['query'], "type = 'ticket'");
expect(find.text('T-1'), findsOneWidget);
});
testWidgets('Markdown mode lists markdown files on switch', (tester) async {
f.ipc.stub(
'pql.files',
(_) async => _ok({
'files': [
{'path': 'docs/initial-plan.md'},
],
}));
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Markdown'));
await pumpAsync(tester);
expect(find.text('docs/initial-plan.md'), findsOneWidget);
});
testWidgets('Vault mode surfaces a pql search error', (tester) async {
f.ipc.stub(
'pql.search',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'pql down'),
));
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Vault'));
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText).first, 'x');
await tester.pump(const Duration(milliseconds: 350));
await pumpAsync(tester);
expect(find.textContaining('pql down'), findsOneWidget);
});
testWidgets('Markdown mode shows the empty state and filters by glob', (tester) async {
Map<String, Object?>? filesArgs;
f.ipc.stub('pql.files', (args) async {
filesArgs = args;
return _ok(const {'files': []});
});
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Markdown'));
await pumpAsync(tester);
expect(find.text('No markdown files found.'), findsOneWidget);
await tester.enterText(find.byType(EditableText).first, 'plan');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
expect(filesArgs?['glob'], contains('plan'));
});
}