diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 1a193903..cfceab0b 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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', '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-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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 09649461..21117dd8 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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-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-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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 92be3e52..1e43ac79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 - The sidebar icon rail no longer overflows when there are more tabs than fit: diff --git a/lib/builtin/pql/pql.dart b/lib/builtin/pql/pql.dart index fca71c8b..963807a8 100644 --- a/lib/builtin/pql/pql.dart +++ b/lib/builtin/pql/pql.dart @@ -2,4 +2,4 @@ export 'src/backlinks_controller.dart'; export 'src/backlinks_view.dart'; export 'src/extension.dart'; export 'src/pql_controller.dart'; -export 'src/pql_panel_view.dart'; +export 'src/pql_search_body.dart'; diff --git a/lib/builtin/pql/src/extension.dart b/lib/builtin/pql/src/extension.dart index fc070b6d..4b89b2fe 100644 --- a/lib/builtin/pql/src/extension.dart +++ b/lib/builtin/pql/src/extension.dart @@ -1,5 +1,4 @@ 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/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; @@ -14,18 +13,10 @@ class PqlExtension extends ClideExtension { @override List 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 List 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( id: 'pql.backlinks', slot: Slots.contextPanel, diff --git a/lib/builtin/pql/src/pql_controller.dart b/lib/builtin/pql/src/pql_controller.dart index 1a7bdaad..a5163bab 100644 --- a/lib/builtin/pql/src/pql_controller.dart +++ b/lib/builtin/pql/src/pql_controller.dart @@ -50,8 +50,11 @@ class PqlController extends ChangeNotifier { } } - void toggleSearchMode() { - _searchMode = _searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search; + void toggleSearchMode() => setSearchMode(_searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search); + + void setSearchMode(SearchMode mode) { + if (_searchMode == mode) return; + _searchMode = mode; _results = const []; _error = null; notifyListeners(); diff --git a/lib/builtin/pql/src/pql_panel_view.dart b/lib/builtin/pql/src/pql_search_body.dart similarity index 62% rename from lib/builtin/pql/src/pql_panel_view.dart rename to lib/builtin/pql/src/pql_search_body.dart index 118d423f..ee3dd117 100644 --- a/lib/builtin/pql/src/pql_panel_view.dart +++ b/lib/builtin/pql/src/pql_search_body.dart @@ -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; import 'dart:async'; +import 'package:clide/builtin/pql/src/pql_controller.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/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 { - const PqlPanelView({super.key}); +class PqlSearchBody extends StatefulWidget { + const PqlSearchBody({super.key, required this.controller, required this.mode}); + + final PqlController controller; + final PqlPaneMode mode; @override - State createState() => _PqlPanelViewState(); + State createState() => _PqlSearchBodyState(); } -class _PqlPanelViewState extends State { - PqlController? _controller; +class _PqlSearchBodyState extends State { String? _focusedPath; final _focusedKey = GlobalKey(); StreamSubscription? _focusSub; StreamSubscription? _fileSub; + @override + void initState() { + super.initState(); + _applyMode(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); - if (_controller != null) return; + if (_focusSub != null) return; final kernel = ClideKernel.of(context); - _controller = PqlController(ipc: kernel.ipc); _focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) { final path = msg.data['path'] as String?; if (path == null || path == _focusedPath) return; - setState(() { - _focusedPath = path; - if (_controller!.view != PqlView.markdown) { - _controller!.switchView(PqlView.markdown); - } - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _focusedKey.currentContext; - if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); - }); + setState(() => _focusedPath = path); + if (widget.controller.view == PqlView.markdown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _focusedKey.currentContext; + if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); + }); + } }); _fileSub = kernel.events .on() .where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md')) .listen((_) { - if (_controller?.view == PqlView.markdown) { - unawaited(_controller!.loadMarkdownFiles()); - } + if (widget.controller.view == PqlView.markdown) unawaited(widget.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 void dispose() { _focusSub?.cancel(); _fileSub?.cancel(); - _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final c = _controller; - if (c == null) return const SizedBox.shrink(); + final c = widget.controller; return ListenableBuilder( listenable: c, builder: (context, _) { @@ -72,22 +101,20 @@ class _PqlPanelViewState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _ViewTabs(controller: c), - if (c.view == PqlView.query) ...[ - _SearchInput(controller: c), - ], - if (c.view == PqlView.markdown) + if (widget.mode == PqlPaneMode.markdown) ClideFilterBox( hint: 'Filter markdown…', onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')), - ), + ) + else + _PqlSearchInput(controller: c, dsl: widget.mode == PqlPaneMode.query), if (c.error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), 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 && 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)), Expanded( child: SingleChildScrollView( @@ -96,16 +123,16 @@ class _PqlPanelViewState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (c.view == PqlView.markdown) + if (widget.mode == PqlPaneMode.markdown) for (final f in c.results) _FileRow( entry: f, focused: (f['path'] as String?) == _focusedPath, 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), - if (c.view == PqlView.query && c.searchMode == SearchMode.dsl) + if (widget.mode == PqlPaneMode.query) for (final r in c.results) _QueryResultRow(entry: r), ], ), @@ -118,92 +145,18 @@ class _PqlPanelViewState extends State { } } -class _ViewTabs extends StatelessWidget { - const _ViewTabs({required this.controller}); +class _PqlSearchInput extends StatelessWidget { + const _PqlSearchInput({required this.controller, required this.dsl}); final PqlController controller; + final bool dsl; @override Widget build(BuildContext context) { - final tokens = ClideTheme.of(context).surface; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: tokens.panelBorder)), - ), - 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, - ), - ], - ), - ), - ], + return ClideFilterBox( + hint: dsl ? 'PQL query…' : 'Search vault…', + debounce: dsl ? Duration.zero : const Duration(milliseconds: 300), + onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)), + onSubmitted: dsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)), ); } } diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart index 3ed46f4d..f7f89c12 100644 --- a/lib/builtin/search/src/search_panel_view.dart +++ b/lib/builtin/search/src/search_panel_view.dart @@ -1,8 +1,12 @@ -/// The find-in-files sidebar panel (T-52, per D-79). A search input -/// with regex/case toggles + include/exclude glob fields, and a results -/// list grouped by file. Clicking a match opens the editor at its line. +/// The unified Search sidebar tab (T-52 / T-201). A mode switch selects +/// among: Find (content grep, per D-79), Vault (pql ranked search), +/// 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; +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/kernel/kernel.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:flutter/widgets.dart'; +/// The Search tab's top-level mode. +enum SearchTabMode { find, vault, query, markdown } + class SearchPanelView extends StatefulWidget { const SearchPanelView({super.key}); @@ -19,6 +26,8 @@ class SearchPanelView extends StatefulWidget { class _SearchPanelViewState extends State { FindInFilesController? _controller; + PqlController? _pql; + SearchTabMode _mode = SearchTabMode.find; @override void didChangeDependencies() { @@ -26,11 +35,13 @@ class _SearchPanelViewState extends State { if (_controller != null) return; final kernel = ClideKernel.of(context); _controller = FindInFilesController(ipc: kernel.ipc, events: kernel.events); + _pql = PqlController(ipc: kernel.ipc); } @override void dispose() { _controller?.dispose(); + _pql?.dispose(); super.dispose(); } @@ -56,6 +67,30 @@ class _SearchPanelViewState extends State { @override 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 c = _controller!; return ListenableBuilder( @@ -146,6 +181,49 @@ class _SearchPanelViewState extends State { } } +class _ModeSwitcher extends StatelessWidget { + const _ModeSwitcher({required this.mode, required this.tokens, required this.onSelect}); + + final SearchTabMode mode; + final SurfaceTokens tokens; + final ValueChanged 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 { const _Toggle({ required this.label, diff --git a/test/builtin/pql/pql_controller_test.dart b/test/builtin/pql/pql_controller_test.dart new file mode 100644 index 00000000..57ccbc3f --- /dev/null +++ b/test/builtin/pql/pql_controller_test.dart @@ -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 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.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); + }); +} diff --git a/test/builtin/search/search_panel_view_test.dart b/test/builtin/search/search_panel_view_test.dart index dbaa8f5c..53ff4ca1 100644 --- a/test/builtin/search/search_panel_view_test.dart +++ b/test/builtin/search/search_panel_view_test.dart @@ -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? 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? 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? 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')); + }); }