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:
@@ -6,3 +6,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
|||||||
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-199', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 11:38:40', '2026-06-01 11:38:40', '2026-06-01 11:38:40', NULL, '44bad6e0417988025dae8606879de2a4', 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-199', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 11:38:40', '2026-06-01 11:38:40', '2026-06-01 11:38:40', NULL, '44bad6e0417988025dae8606879de2a4', 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-199', 'status', 'in_progress', 'done', NULL, '2026-06-01 12:35:04', '2026-06-01 12:35:04', '2026-06-01 12:35:04', NULL, '56c14e217540c5789e70317aa8b6461f', 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-199', 'status', 'in_progress', 'done', NULL, '2026-06-01 12:35:04', '2026-06-01 12:35:04', '2026-06-01 12:35:04', NULL, '56c14e217540c5789e70317aa8b6461f', 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-200', 'status', 'backlog', 'done', NULL, '2026-06-01 14:04:24', '2026-06-01 14:04:24', '2026-06-01 14:04:24', NULL, 'e77f8d64e4f9723af700dd46f962dea6', 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-200', 'status', 'backlog', 'done', NULL, '2026-06-01 14:04:24', '2026-06-01 14:04:24', '2026-06-01 14:04:24', NULL, 'e77f8d64e4f9723af700dd46f962dea6', 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-201', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 14:27:48', '2026-06-01 14:27:48', '2026-06-01 14:27:48', NULL, '98ad7183de73e87fe2192e3bcc12680b', 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-201', 'status', 'in_progress', 'done', NULL, '2026-06-01 14:45:39', '2026-06-01 14:45:39', '2026-06-01 14:45:39', NULL, '3073ba674f5d929f9d226e5351811433', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
|||||||
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-198', 'task', 'T-186', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'd2392d2e56ffc552c8f4f8993350d939', 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-198', 'task', 'T-186', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'd2392d2e56ffc552c8f4f8993350d939', 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-199', 'task', 'T-186', 'Tickets detail: adopt retained ReaderNav (back/forward + pin) + static tab', 'Bring the tickets detail reader in line with markdown/decisions (T-196/D-81). Adopt the retained ReaderNav(''builtin.tickets'', dataKey ''id''): the nav records selections + emits ''load''; the controller loads on ''load'' (not ''selection''); the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar (pin toggle left; back/forward + jump-to-pin right; NO edit pencil since tickets are pql records, not files). Also drop the per-click uncontribute/contribute churn in TicketsExtension (the T-188 anti-pattern) — use the static tickets.detail tab + reveal on selection. TicketDetailController loses its panels dependency (reveal moves to the extension).', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '82fe1aefd26d3f53872cc6983d1ae52d', 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-199', 'task', 'T-186', 'Tickets detail: adopt retained ReaderNav (back/forward + pin) + static tab', 'Bring the tickets detail reader in line with markdown/decisions (T-196/D-81). Adopt the retained ReaderNav(''builtin.tickets'', dataKey ''id''): the nav records selections + emits ''load''; the controller loads on ''load'' (not ''selection''); the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar (pin toggle left; back/forward + jump-to-pin right; NO edit pencil since tickets are pql records, not files). Also drop the per-click uncontribute/contribute churn in TicketsExtension (the T-188 anti-pattern) — use the static tickets.detail tab + reveal on selection. TicketDetailController loses its panels dependency (reveal moves to the extension).', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '82fe1aefd26d3f53872cc6983d1ae52d', 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-200', 'bug', NULL, 'Sidebar icon rail overflows (RenderFlex) when tab count exceeds width', 'Adding the Search sidebar tab (T-52) pushed the bottom icon rail past its width: ''A RenderFlex overflowed by 54 pixels'' from clide_icon_rail.dart — a fixed Row(center, max) with one _RailButton per tab. Fix: ClideIconRail centers when the icons fit and scrolls horizontally when they don''t (LayoutBuilder + SingleChildScrollView + ConstrainedBox(minWidth: maxWidth)). Robust to any tab count. (Related: T-201 will reduce tab count by merging the pql + find-in-files search surfaces.)', 'done', 'high', NULL, NULL, NULL, '2026-06-01 14:04:00', '2026-06-01 14:04:24', NULL, '59e5ac635342f5994afcc76c0fb3cd1e', 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-200', 'bug', NULL, 'Sidebar icon rail overflows (RenderFlex) when tab count exceeds width', 'Adding the Search sidebar tab (T-52) pushed the bottom icon rail past its width: ''A RenderFlex overflowed by 54 pixels'' from clide_icon_rail.dart — a fixed Row(center, max) with one _RailButton per tab. Fix: ClideIconRail centers when the icons fit and scrolls horizontally when they don''t (LayoutBuilder + SingleChildScrollView + ConstrainedBox(minWidth: maxWidth)). Robust to any tab count. (Related: T-201 will reduce tab count by merging the pql + find-in-files search surfaces.)', 'done', 'high', NULL, NULL, NULL, '2026-06-01 14:04:00', '2026-06-01 14:04:24', NULL, '59e5ac635342f5994afcc76c0fb3cd1e', 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-201', 'task', 'T-186', 'Merge pql search into the find-in-files Search tab (one search surface)', 'The pql sidebar panel (ranked text search + PQL DSL query + synced markdown-file listing) and the find-in-files Search tab (content grep) are duplicate search surfaces. Consolidate into ONE Search tab with a mode switch (option B): Find (content grep, existing), Vault (pql ranked search), Query (PQL DSL), Markdown (synced md-file listing, keeps focus-highlight + live refresh). Remove the builtin.pql SIDEBAR tab; keep the pql extension''s Backlinks context-panel tab + PqlController. Implementation: SearchPanelView gains a 4-mode switcher and holds both FindInFilesController + PqlController; extract the pql body/result rows into a reusable widget (they''re private in pql_panel_view.dart today); move the md-listing focus-sync + files.changed refresh into the merged panel; delete PqlPanelView. Test/coverage note: pql_panel_view + pql_controller are currently untested (not in the coverage denominator); folding them into the tested Search panel pulls them in, so the merge must cover the Vault/Query/Markdown modes too (same dynamic that bit T-199). Also reduces sidebar tab count (helps the rail, T-200). No D-79 conflict: grep vs ranked-search remain distinct backends; this is UI consolidation only.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 14:25:59', '2026-06-01 14:45:39', NULL, '4101a253d4893d6ea9b994332645dfdb', 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);
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
(back, forward, jump-to-pin) and edit pencil. The ticket reader also joins the
|
(back, forward, jump-to-pin) and edit pencil. The ticket reader also joins the
|
||||||
shared retained nav. (T-189, T-190, T-191, T-198, T-199)
|
shared retained nav. (T-189, T-190, T-191, T-198, T-199)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The pql search panel merged into the Search tab, which now has modes: Find
|
||||||
|
(content grep), Vault (pql ranked search), Query (PQL DSL), and Markdown (the
|
||||||
|
synced file listing). The standalone pql sidebar tab is gone; Backlinks stays
|
||||||
|
in the context panel. (T-201)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- The sidebar icon rail no longer overflows when there are more tabs than fit:
|
- The sidebar icon rail no longer overflows when there are more tabs than fit:
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export 'src/backlinks_controller.dart';
|
|||||||
export 'src/backlinks_view.dart';
|
export 'src/backlinks_view.dart';
|
||||||
export 'src/extension.dart';
|
export 'src/extension.dart';
|
||||||
export 'src/pql_controller.dart';
|
export 'src/pql_controller.dart';
|
||||||
export 'src/pql_panel_view.dart';
|
export 'src/pql_search_body.dart';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:clide/builtin/pql/src/backlinks_view.dart';
|
import 'package:clide/builtin/pql/src/backlinks_view.dart';
|
||||||
import 'package:clide/builtin/pql/src/pql_panel_view.dart';
|
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
@@ -14,18 +13,10 @@ class PqlExtension extends ClideExtension {
|
|||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const [];
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
|
// The pql search/query/markdown surface moved into the unified Search
|
||||||
|
// tab (T-201); this extension keeps only the Backlinks context panel.
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => [
|
List<ContributionPoint> get contributions => [
|
||||||
TabContribution(
|
|
||||||
id: 'pql.panel',
|
|
||||||
slot: Slots.sidebar,
|
|
||||||
title: 'pql',
|
|
||||||
titleKey: 'tab.title',
|
|
||||||
i18nNamespace: id,
|
|
||||||
icon: PhosphorIcons.magnifyingGlass,
|
|
||||||
priority: -60,
|
|
||||||
build: (_) => const PqlPanelView(),
|
|
||||||
),
|
|
||||||
TabContribution(
|
TabContribution(
|
||||||
id: 'pql.backlinks',
|
id: 'pql.backlinks',
|
||||||
slot: Slots.contextPanel,
|
slot: Slots.contextPanel,
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ class PqlController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleSearchMode() {
|
void toggleSearchMode() => setSearchMode(_searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search);
|
||||||
_searchMode = _searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search;
|
|
||||||
|
void setSearchMode(SearchMode mode) {
|
||||||
|
if (_searchMode == mode) return;
|
||||||
|
_searchMode = mode;
|
||||||
_results = const [];
|
_results = const [];
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
+70
-117
@@ -1,70 +1,99 @@
|
|||||||
/// Sidebar panel for pql — ranked search, DSL query, and markdown file listing.
|
/// The pql search/query/markdown body, embedded inside the unified
|
||||||
|
/// Search tab for the Vault / Query / Markdown modes (T-201). Drives a
|
||||||
|
/// parent-owned [PqlController]; the parent picks the [PqlPaneMode].
|
||||||
|
///
|
||||||
|
/// Vault = ranked text search, Query = PQL DSL, Markdown = the synced
|
||||||
|
/// markdown-file listing (highlights the open doc, live-refreshes on
|
||||||
|
/// `.md` changes).
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clide/builtin/pql/src/pql_controller.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'pql_controller.dart';
|
/// Which pql sub-surface the Search tab is showing.
|
||||||
|
enum PqlPaneMode { vault, query, markdown }
|
||||||
|
|
||||||
class PqlPanelView extends StatefulWidget {
|
class PqlSearchBody extends StatefulWidget {
|
||||||
const PqlPanelView({super.key});
|
const PqlSearchBody({super.key, required this.controller, required this.mode});
|
||||||
|
|
||||||
|
final PqlController controller;
|
||||||
|
final PqlPaneMode mode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PqlPanelView> createState() => _PqlPanelViewState();
|
State<PqlSearchBody> createState() => _PqlSearchBodyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PqlPanelViewState extends State<PqlPanelView> {
|
class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||||
PqlController? _controller;
|
|
||||||
String? _focusedPath;
|
String? _focusedPath;
|
||||||
final _focusedKey = GlobalKey();
|
final _focusedKey = GlobalKey();
|
||||||
StreamSubscription<Message>? _focusSub;
|
StreamSubscription<Message>? _focusSub;
|
||||||
StreamSubscription<DaemonEvent>? _fileSub;
|
StreamSubscription<DaemonEvent>? _fileSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_applyMode();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_controller != null) return;
|
if (_focusSub != null) return;
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
_controller = PqlController(ipc: kernel.ipc);
|
|
||||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) {
|
_focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) {
|
||||||
final path = msg.data['path'] as String?;
|
final path = msg.data['path'] as String?;
|
||||||
if (path == null || path == _focusedPath) return;
|
if (path == null || path == _focusedPath) return;
|
||||||
setState(() {
|
setState(() => _focusedPath = path);
|
||||||
_focusedPath = path;
|
if (widget.controller.view == PqlView.markdown) {
|
||||||
if (_controller!.view != PqlView.markdown) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_controller!.switchView(PqlView.markdown);
|
final ctx = _focusedKey.currentContext;
|
||||||
}
|
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
}
|
||||||
final ctx = _focusedKey.currentContext;
|
|
||||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
_fileSub = kernel.events
|
_fileSub = kernel.events
|
||||||
.on<DaemonEvent>()
|
.on<DaemonEvent>()
|
||||||
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
|
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
|
||||||
.listen((_) {
|
.listen((_) {
|
||||||
if (_controller?.view == PqlView.markdown) {
|
if (widget.controller.view == PqlView.markdown) unawaited(widget.controller.loadMarkdownFiles());
|
||||||
unawaited(_controller!.loadMarkdownFiles());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PqlSearchBody old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.mode != widget.mode) _applyMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the flat pane mode onto the controller's (view, searchMode).
|
||||||
|
void _applyMode() {
|
||||||
|
final c = widget.controller;
|
||||||
|
switch (widget.mode) {
|
||||||
|
case PqlPaneMode.vault:
|
||||||
|
c.setSearchMode(SearchMode.search);
|
||||||
|
c.switchView(PqlView.query);
|
||||||
|
case PqlPaneMode.query:
|
||||||
|
c.setSearchMode(SearchMode.dsl);
|
||||||
|
c.switchView(PqlView.query);
|
||||||
|
case PqlPaneMode.markdown:
|
||||||
|
c.switchView(PqlView.markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_focusSub?.cancel();
|
_focusSub?.cancel();
|
||||||
_fileSub?.cancel();
|
_fileSub?.cancel();
|
||||||
_controller?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = _controller;
|
final c = widget.controller;
|
||||||
if (c == null) return const SizedBox.shrink();
|
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: c,
|
listenable: c,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -72,22 +101,20 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_ViewTabs(controller: c),
|
if (widget.mode == PqlPaneMode.markdown)
|
||||||
if (c.view == PqlView.query) ...[
|
|
||||||
_SearchInput(controller: c),
|
|
||||||
],
|
|
||||||
if (c.view == PqlView.markdown)
|
|
||||||
ClideFilterBox(
|
ClideFilterBox(
|
||||||
hint: 'Filter markdown…',
|
hint: 'Filter markdown…',
|
||||||
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
||||||
),
|
)
|
||||||
|
else
|
||||||
|
_PqlSearchInput(controller: c, dsl: widget.mode == PqlPaneMode.query),
|
||||||
if (c.error != null)
|
if (c.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
||||||
),
|
),
|
||||||
if (c.loading && c.results.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
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 && c.view == PqlView.markdown)
|
if (!c.loading && c.results.isEmpty && c.error == null && widget.mode == PqlPaneMode.markdown)
|
||||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
|
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -96,16 +123,16 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (c.view == PqlView.markdown)
|
if (widget.mode == PqlPaneMode.markdown)
|
||||||
for (final f in c.results)
|
for (final f in c.results)
|
||||||
_FileRow(
|
_FileRow(
|
||||||
entry: f,
|
entry: f,
|
||||||
focused: (f['path'] as String?) == _focusedPath,
|
focused: (f['path'] as String?) == _focusedPath,
|
||||||
focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
|
focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
|
||||||
),
|
),
|
||||||
if (c.view == PqlView.query && c.searchMode == SearchMode.search)
|
if (widget.mode == PqlPaneMode.vault)
|
||||||
for (final r in c.results) _SearchResultRow(entry: r),
|
for (final r in c.results) _SearchResultRow(entry: r),
|
||||||
if (c.view == PqlView.query && c.searchMode == SearchMode.dsl)
|
if (widget.mode == PqlPaneMode.query)
|
||||||
for (final r in c.results) _QueryResultRow(entry: r),
|
for (final r in c.results) _QueryResultRow(entry: r),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -118,92 +145,18 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ViewTabs extends StatelessWidget {
|
class _PqlSearchInput extends StatelessWidget {
|
||||||
const _ViewTabs({required this.controller});
|
const _PqlSearchInput({required this.controller, required this.dsl});
|
||||||
final PqlController controller;
|
final PqlController controller;
|
||||||
|
final bool dsl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
return ClideFilterBox(
|
||||||
return Container(
|
hint: dsl ? 'PQL query…' : 'Search vault…',
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
debounce: dsl ? Duration.zero : const Duration(milliseconds: 300),
|
||||||
decoration: BoxDecoration(
|
onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)),
|
||||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
onSubmitted: dsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
for (final v in PqlView.values)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => controller.switchView(v),
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: ClideText(
|
|
||||||
_tabLabel(v),
|
|
||||||
fontSize: clideFontCaption,
|
|
||||||
color: controller.view == v ? tokens.globalForeground : tokens.globalTextMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _tabLabel(PqlView v) => switch (v) {
|
|
||||||
PqlView.query => 'Search',
|
|
||||||
PqlView.markdown => 'Markdown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
/// The find-in-files sidebar panel (T-52, per D-79). A search input
|
/// The unified Search sidebar tab (T-52 / T-201). A mode switch selects
|
||||||
/// with regex/case toggles + include/exclude glob fields, and a results
|
/// among: Find (content grep, per D-79), Vault (pql ranked search),
|
||||||
/// list grouped by file. Clicking a match opens the editor at its line.
|
/// Query (PQL DSL), and Markdown (the synced markdown-file listing).
|
||||||
|
/// The find modes own a [FindInFilesController]; the pql modes a
|
||||||
|
/// [PqlController] rendered by [PqlSearchBody].
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/pql/src/pql_controller.dart';
|
||||||
|
import 'package:clide/builtin/pql/src/pql_search_body.dart';
|
||||||
import 'package:clide/builtin/search/src/find_in_files_controller.dart';
|
import 'package:clide/builtin/search/src/find_in_files_controller.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/src/search/match.dart';
|
import 'package:clide/src/search/match.dart';
|
||||||
@@ -10,6 +14,9 @@ import 'package:clide/src/search/replace_engine.dart';
|
|||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// The Search tab's top-level mode.
|
||||||
|
enum SearchTabMode { find, vault, query, markdown }
|
||||||
|
|
||||||
class SearchPanelView extends StatefulWidget {
|
class SearchPanelView extends StatefulWidget {
|
||||||
const SearchPanelView({super.key});
|
const SearchPanelView({super.key});
|
||||||
|
|
||||||
@@ -19,6 +26,8 @@ class SearchPanelView extends StatefulWidget {
|
|||||||
|
|
||||||
class _SearchPanelViewState extends State<SearchPanelView> {
|
class _SearchPanelViewState extends State<SearchPanelView> {
|
||||||
FindInFilesController? _controller;
|
FindInFilesController? _controller;
|
||||||
|
PqlController? _pql;
|
||||||
|
SearchTabMode _mode = SearchTabMode.find;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -26,11 +35,13 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
|||||||
if (_controller != null) return;
|
if (_controller != null) return;
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
_controller = FindInFilesController(ipc: kernel.ipc, events: kernel.events);
|
_controller = FindInFilesController(ipc: kernel.ipc, events: kernel.events);
|
||||||
|
_pql = PqlController(ipc: kernel.ipc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
|
_pql?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +67,30 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideTheme.of(context).surface;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_ModeSwitcher(mode: _mode, tokens: tokens, onSelect: (m) => setState(() => _mode = m)),
|
||||||
|
Expanded(child: _body()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body() {
|
||||||
|
switch (_mode) {
|
||||||
|
case SearchTabMode.find:
|
||||||
|
return _findBody();
|
||||||
|
case SearchTabMode.vault:
|
||||||
|
return PqlSearchBody(controller: _pql!, mode: PqlPaneMode.vault);
|
||||||
|
case SearchTabMode.query:
|
||||||
|
return PqlSearchBody(controller: _pql!, mode: PqlPaneMode.query);
|
||||||
|
case SearchTabMode.markdown:
|
||||||
|
return PqlSearchBody(controller: _pql!, mode: PqlPaneMode.markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _findBody() {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final c = _controller!;
|
final c = _controller!;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
@@ -146,6 +181,49 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ModeSwitcher extends StatelessWidget {
|
||||||
|
const _ModeSwitcher({required this.mode, required this.tokens, required this.onSelect});
|
||||||
|
|
||||||
|
final SearchTabMode mode;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
final ValueChanged<SearchTabMode> onSelect;
|
||||||
|
|
||||||
|
static const _labels = {
|
||||||
|
SearchTabMode.find: 'Find',
|
||||||
|
SearchTabMode.vault: 'Vault',
|
||||||
|
SearchTabMode.query: 'Query',
|
||||||
|
SearchTabMode.markdown: 'Markdown',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.panelBorder))),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final m in SearchTabMode.values) ...[
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: m == mode,
|
||||||
|
label: _labels[m]!,
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: () => onSelect(m),
|
||||||
|
builder: (ctx, hovered, _) => ClideText(
|
||||||
|
_labels[m]!,
|
||||||
|
fontSize: clideFontCaption,
|
||||||
|
color: m == mode ? tokens.globalForeground : (hovered ? tokens.sidebarForeground : tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _Toggle extends StatelessWidget {
|
class _Toggle extends StatelessWidget {
|
||||||
const _Toggle({
|
const _Toggle({
|
||||||
required this.label,
|
required this.label,
|
||||||
|
|||||||
@@ -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!['apply'], isTrue);
|
||||||
expect(applyArgs!['replacement'], 'bar');
|
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'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user