add ranked search to pql sidebar with DSL toggle
test / unit + widget + golden + a11y (push) Failing after 33s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped

Search tab defaults to ranked text search via pql search — debounced
300ms, scored results with visual score bar.  DSL toggle switches to
raw PQL query mode for SQL-like queries.  Clicking a search result
opens it in the markdown context viewer.

Adds pql.search IPC command and PqlClient.search method.  Controller
gains SearchMode enum and search() method alongside existing runQuery.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-23 22:22:16 +02:00
co-authored by Claude
parent 2f7f797288
commit 226520cfce
5 changed files with 194 additions and 17 deletions
+5 -4
View File
@@ -86,10 +86,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- `files.read` IPC command for reading file content by path.
- pql sidebar restructured: Search tab (PQL DSL query) is the
default left tab; Markdown tab (filtered to `.md` files) on the
right. Clicking a markdown file opens it in the context panel
markdown viewer with bidirectional focus highlighting.
- pql sidebar restructured: Search tab is the default left tab with
ranked text search (debounced, scored results with score bar) and
a DSL toggle for raw PQL query mode; Markdown tab (filtered to
`.md` files) on the right. Clicking a result opens it in the
context panel markdown viewer with bidirectional focus highlighting.
- Graph view in context panel — lists files with inbound/outbound
link counts from `pql search --connections` (T-39).
+41 -2
View File
@@ -1,7 +1,7 @@
/// State model for the pql sidebar panel.
///
/// Manages schema cache, query execution, file listing, and
/// decision/ticket views. All data comes through pql.* IPC verbs.
/// Manages search, DSL query execution, and markdown file listing.
/// All data comes through pql.* IPC verbs.
library;
import 'dart:async';
@@ -11,6 +11,8 @@ import 'package:flutter/foundation.dart';
enum PqlView { query, markdown }
enum SearchMode { search, dsl }
class PqlController extends ChangeNotifier {
PqlController({required this.ipc});
@@ -19,6 +21,9 @@ class PqlController extends ChangeNotifier {
PqlView _view = PqlView.query;
PqlView get view => _view;
SearchMode _searchMode = SearchMode.search;
SearchMode get searchMode => _searchMode;
String? _error;
String? get error => _error;
@@ -45,6 +50,40 @@ class PqlController extends ChangeNotifier {
}
}
void toggleSearchMode() {
_searchMode = _searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search;
_results = const [];
_error = null;
notifyListeners();
}
Future<void> search(String terms) async {
if (terms.trim().isEmpty) {
_results = const [];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
final r = await ipc.request('pql.search', args: {
'terms': terms,
'limit': 50,
});
_loading = false;
if (!r.ok) {
_error = r.error?.message;
_results = const [];
notifyListeners();
return;
}
_results = _castList(r.data['results']);
notifyListeners();
}
Future<void> loadMarkdownFiles({String? glob}) async {
_loading = true;
notifyListeners();
+128 -11
View File
@@ -1,4 +1,4 @@
/// Sidebar panel for pql — DSL query input and markdown file listing.
/// Sidebar panel for pql — ranked search, DSL query, and markdown file listing.
library;
import 'dart:async';
@@ -63,13 +63,9 @@ class _PqlPanelViewState extends State<PqlPanelView> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ViewTabs(controller: c),
if (c.view == PqlView.query)
ClideFilterBox(
hint: 'PQL query…',
debounce: Duration.zero,
onChanged: (_) {},
onSubmitted: (v) => unawaited(c.runQuery(v)),
),
if (c.view == PqlView.query) ...[
_SearchInput(controller: c),
],
if (c.view == PqlView.markdown)
ClideFilterBox(
hint: 'Filter markdown…',
@@ -82,8 +78,8 @@ class _PqlPanelViewState extends State<PqlPanelView> {
),
if (c.loading && c.results.isEmpty)
const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.results.isEmpty && c.error == null)
const Padding(padding: EdgeInsets.all(12), child: ClideText('No results.', muted: true)),
if (!c.loading && c.results.isEmpty && c.error == null && c.view == PqlView.markdown)
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -98,7 +94,9 @@ class _PqlPanelViewState extends State<PqlPanelView> {
focused: (f['path'] as String?) == _focusedPath,
focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
),
if (c.view == PqlView.query)
if (c.view == PqlView.query && c.searchMode == SearchMode.search)
for (final r in c.results) _SearchResultRow(entry: r),
if (c.view == PqlView.query && c.searchMode == SearchMode.dsl)
for (final r in c.results) _QueryResultRow(entry: r),
],
),
@@ -151,6 +149,125 @@ class _ViewTabs extends StatelessWidget {
};
}
class _SearchInput extends StatelessWidget {
const _SearchInput({required this.controller});
final PqlController controller;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final isDsl = controller.searchMode == SearchMode.dsl;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ClideFilterBox(
hint: isDsl ? 'PQL query…' : 'Search vault…',
debounce: isDsl ? Duration.zero : const Duration(milliseconds: 300),
onChanged: isDsl ? (_) {} : (v) => unawaited(controller.search(v)),
onSubmitted: isDsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
GestureDetector(
onTap: controller.toggleSearchMode,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isDsl ? tokens.globalFocus.withAlpha(0x30) : null,
borderRadius: BorderRadius.circular(3),
border: Border.all(color: isDsl ? tokens.globalFocus : tokens.panelBorder),
),
child: ClideText('DSL', fontSize: clideFontBadge, color: isDsl ? tokens.globalFocus : tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
),
),
const SizedBox(width: 6),
ClideText(
isDsl ? 'SQL-like query mode' : 'ranked text search',
fontSize: clideFontBadge,
muted: true,
),
],
),
),
],
);
}
}
class _SearchResultRow extends StatelessWidget {
const _SearchResultRow({required this.entry});
final Map<String, Object?> entry;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final path = entry['path'] as String? ?? '';
final score = (entry['score'] as num?)?.toDouble() ?? 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
child: ClideTappable(
onTap: () {
if (path.endsWith('.md')) {
ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
}
},
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClideText(path, fontSize: clideFontCaption, color: tokens.sidebarForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 3),
_ScoreBar(score: score, tokens: tokens),
],
),
),
),
);
}
}
class _ScoreBar extends StatelessWidget {
const _ScoreBar({required this.score, required this.tokens});
final double score;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 60,
height: 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(1.5),
child: ColoredBox(
color: tokens.panelBorder,
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: score.clamp(0, 1),
child: ColoredBox(color: tokens.globalFocus),
),
),
),
),
const SizedBox(width: 6),
ClideText('${(score * 100).round()}%', fontSize: clideFontBadge, muted: true, fontFamily: clideMonoFamily),
],
);
}
}
class _FileRow extends StatelessWidget {
const _FileRow({required this.entry, this.focused = false, this.focusKey});
final Map<String, Object?> entry;
+14
View File
@@ -94,6 +94,20 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
}
});
d.register('pql.search', (req) async {
final terms = req.args['terms'] as String?;
if (terms == null || terms.isEmpty) {
return _userError(req.id, 'pql.search requires a terms string');
}
try {
final limit = (req.args['limit'] as num?)?.toInt();
final results = await pql.search(terms, limit: limit);
return IpcResponse.ok(id: req.id, data: {'results': results});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.doctor', (req) async {
try {
final report = await pql.doctor();
+6
View File
@@ -59,6 +59,12 @@ class PqlClient {
return _runList(args);
}
Future<List<Map<String, Object?>>> search(String terms, {int? limit}) async {
final args = ['search', terms];
if (limit != null) args.addAll(['--limit', '$limit']);
return _runList(args);
}
Future<Map<String, Object?>> doctor() async {
return _runObject(['doctor']);
}