From 826395481cb5641017c7aac7cd8ceed66fdc8388 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 7 Jun 2026 12:44:40 +0200 Subject: [PATCH] make sidebar filter boxes CLI-addressable via the MessageBus (T-270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar/dock filter fields (the shared ClideFilterBox) had no CLI peer — a one-way, UI-only affordance that broke D-6 parity. Add the drive+observe verb `clide ui filter
[]`, routed entirely through the kernel MessageBus pub/sub so a box reacts to a published message identically whether the trigger was a UI keystroke or the CLI — keeping extensions first-class (no dispatcher→widget wiring). - ClideFilterBox gains an `address`; when set it listens on `filter.set` for its address and republishes its value on `filter.state`. Null address keeps the box a kernel-free UI widget. - FilterStateCache (new kernel service) caches the latest `filter.state` per address — the bus has no retention, so this backs the observe-half. - ui.filter: with text → publishes `filter.set` (drive); without → reads the cache (observe). Honest toolError when there is no live UI. - Address every box: decisions/tickets/files/git/output/problems panes, the four search boxes, and the pql search/query/markdown inputs. Addresses are the ids from `clide pane list` (e.g. decisions.panel). settings.json: allow the `clide` CLI + relevant skills. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/settings.json | 6 + .pql/changelog/ticket_deps/2026-06.sql | 2 + .pql/changelog/ticket_history/2026-06.sql | 14 ++ .pql/changelog/tickets/2026-06.sql | 16 +++ CHANGELOG.md | 7 + lib/builtin/decisions/src/decisions_view.dart | 2 +- lib/builtin/files/src/file_tree_view.dart | 2 +- lib/builtin/git/src/git_panel_view.dart | 2 +- lib/builtin/output/src/output_view.dart | 2 +- lib/builtin/pql/src/pql_search_body.dart | 11 +- lib/builtin/problems/src/problems_view.dart | 2 +- lib/builtin/search/src/search_panel_view.dart | 16 ++- lib/builtin/tickets/src/tickets_view.dart | 2 +- lib/kernel/kernel.dart | 1 + lib/kernel/src/events/filter_state.dart | 36 +++++ lib/kernel/src/facade.dart | 9 ++ lib/main.dart | 10 +- lib/src/daemon/ui_command.dart | 70 +++++++++- lib/widgets/src/clide_filter_box.dart | 52 +++++++- test/daemon/ui_command_test.dart | 78 ++++++++++- test/kernel/filter_state_test.dart | 67 ++++++++++ test/widgets/src/clide_filter_box_test.dart | 124 ++++++++++++++++++ 22 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 lib/kernel/src/events/filter_state.dart create mode 100644 test/kernel/filter_state_test.dart create mode 100644 test/widgets/src/clide_filter_box_test.dart diff --git a/.claude/settings.json b/.claude/settings.json index 4d88485b..1dd8e6c1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,10 @@ { "permissions": { "allow": [ + "Skill(pql)", + "Skill(clide)", + "Skill(whats-next)", + "Skill(git-commit)", "Bash(git add *)", "Bash(git commit *)", "Bash(git status *)", @@ -30,6 +34,8 @@ "Bash(make *)", "Bash(pql)", "Bash(pql *)", + "Bash(clide)", + "Bash(clide *)", "Bash(awk *)" ], "deny": [ diff --git a/.pql/changelog/ticket_deps/2026-06.sql b/.pql/changelog/ticket_deps/2026-06.sql index fcddf84e..bc126527 100644 --- a/.pql/changelog/ticket_deps/2026-06.sql +++ b/.pql/changelog/ticket_deps/2026-06.sql @@ -7,3 +7,5 @@ INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-220', 'T-221', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, 'f054e1277bdd77ecfdac7b78902aa543', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-224', 'T-214', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, 'fb0b5ec4b479363fd4e4a4de1d9e5d82', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-266', 'T-264', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '192fb02408f15e992ad6df469cae696e', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'T-271', '2026-06-07 10:42:46', '2026-06-07 10:42:46', NULL, 'de1cc2484bc5f4bab84aef45de148eb2', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'T-272', '2026-06-07 10:42:50', '2026-06-07 10:42:50', NULL, 'f2401654c1b6a99a98b601216d9d6091', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 459cfda8..a1ca038e 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -766,3 +766,17 @@ 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-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 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-269', 'status', 'backlog', 'done', NULL, '2026-06-07 09:51:32', '2026-06-07 09:51:32', '2026-06-07 09:51:32', NULL, '3fec60e8de272979ad791c313eac76f8', 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-48', 'status', 'backlog', 'ready', NULL, '2026-06-07 10:03:36', '2026-06-07 10:03:36', '2026-06-07 10:03:36', NULL, '643716825d71326f6066fb04cbb67077', 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-270', 'status', 'backlog', 'done', NULL, '2026-06-07 10:43:18', '2026-06-07 10:43:18', '2026-06-07 10:43:18', NULL, '5bae170fdf2314837253e366ae06812e', 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-48', 'description', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links. + +## Refinement (decisions, 2026-06-07) + +**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing. +- View submenu: panel toggles, zoom, focus mode. +- Help: About modal (version, license, links). + +**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry. + +**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant. + +**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', NULL, '2026-06-07 10:43:22', '2026-06-07 10:43:22', '2026-06-07 10:43:22', NULL, '24d0f8f4ae04e75128f681498c24374f', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2d4ea7a5..afa1171b 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1621,3 +1621,19 @@ Fix (T-269): - Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '9b92ed68f5c352d5461c01d7e98c0b50', 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-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:03:36', NULL, 'e09873c62cfab523b2b4048d9681c9ae', 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-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:23:13', NULL, '0d79acd088da9d0b240ee5473c3a21c4', 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-271', 'task', 'T-48', 'Edit menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Edit top-level menu to the hat-bar application menu (T-48). Items (undo, redo, cut, copy, paste, select-all, find/replace) reference registered command ids and route to the focused surface; inapplicable items render disabled (greyed), per T-48''s show-but-disable context behavior. Depends on focused-surface command routing so the menu reflects the active editor/pane. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:32', '2026-06-07 10:42:32', NULL, '4896030bd5e76b49f4bcb56239b2cb96', 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-272', 'task', 'T-48', 'Selection menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Selection top-level menu to the hat-bar application menu (T-48). Selection operations reference registered command ids and route to the focused surface; inapplicable items render disabled per T-48''s show-but-disable context behavior. Depends on focused-surface command routing. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:38', '2026-06-07 10:42:38', NULL, '9bc20e9a0cd664e6de878fec4fe199de', 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-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'done', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:43:18', NULL, '28f5c3ff053d5a7bf209acfa34ddfd8b', 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-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links. + +## Refinement (decisions, 2026-06-07) + +**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing. +- View submenu: panel toggles, zoom, focus mode. +- Help: About modal (version, license, links). + +**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry. + +**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant. + +**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:43:22', NULL, '56aea322a8228babee96dec5befced5a', 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 d50fa675..868cbbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- The sidebar/dock **filter boxes are now CLI-addressable** (D-6 parity): `clide + ui filter
` drives a pane's filter exactly as typing in it + would, and `clide ui filter
` reads the current value back. Addresses + are the box ids from `clide pane list` (e.g. `decisions.panel`, `files.tree`, + `search.findInFiles`). Routed entirely through the MessageBus pub/sub — a box + reacts to a published `filter.set` the same whether the trigger was a click or + the CLI, keeping extensions first-class. (T-270) - Click an inline image card to open it in a full-screen **lightbox** — zoom (scroll/pinch), pan, double-click to reset, `Esc`/backdrop to dismiss — since the cards are often too small to read. `clide image show --fullscreen` diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart index e40e4854..ca1a0236 100644 --- a/lib/builtin/decisions/src/decisions_view.dart +++ b/lib/builtin/decisions/src/decisions_view.dart @@ -150,7 +150,7 @@ class _DecisionsViewState extends State { children: [ Row( children: [ - Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))), + Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))), Padding( padding: const EdgeInsets.only(right: 8), child: ClideTappable( diff --git a/lib/builtin/files/src/file_tree_view.dart b/lib/builtin/files/src/file_tree_view.dart index 42a04f3a..b7ea02e4 100644 --- a/lib/builtin/files/src/file_tree_view.dart +++ b/lib/builtin/files/src/file_tree_view.dart @@ -65,7 +65,7 @@ class _FileTreeViewState extends State { final rootName = root.split(Platform.pathSeparator).last; return Column( children: [ - ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), + ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), Expanded( child: Semantics( label: 'file tree — $rootName', diff --git a/lib/builtin/git/src/git_panel_view.dart b/lib/builtin/git/src/git_panel_view.dart index f8f97e88..26339e14 100644 --- a/lib/builtin/git/src/git_panel_view.dart +++ b/lib/builtin/git/src/git_panel_view.dart @@ -75,7 +75,7 @@ class _GitPanelViewState extends State { explicitChildNodes: true, child: Column( children: [ - ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)), + ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/builtin/output/src/output_view.dart b/lib/builtin/output/src/output_view.dart index e60794e1..abb33111 100644 --- a/lib/builtin/output/src/output_view.dart +++ b/lib/builtin/output/src/output_view.dart @@ -125,7 +125,7 @@ class _OutputViewState extends State { child: Row( children: [ Expanded( - child: ClideFilterBox(hint: 'Filter…', onChanged: _c.setText), + child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText), ), const SizedBox(width: 8), _Chip( diff --git a/lib/builtin/pql/src/pql_search_body.dart b/lib/builtin/pql/src/pql_search_body.dart index ee3dd117..724fa0e8 100644 --- a/lib/builtin/pql/src/pql_search_body.dart +++ b/lib/builtin/pql/src/pql_search_body.dart @@ -103,11 +103,16 @@ class _PqlSearchBodyState extends State { children: [ if (widget.mode == PqlPaneMode.markdown) ClideFilterBox( + address: 'search.pql.markdown', hint: 'Filter markdown…', onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')), ) else - _PqlSearchInput(controller: c, dsl: widget.mode == PqlPaneMode.query), + _PqlSearchInput( + controller: c, + dsl: widget.mode == PqlPaneMode.query, + address: widget.mode == PqlPaneMode.query ? 'search.pql.query' : 'search.pql.vault', + ), if (c.error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -146,13 +151,15 @@ class _PqlSearchBodyState extends State { } class _PqlSearchInput extends StatelessWidget { - const _PqlSearchInput({required this.controller, required this.dsl}); + const _PqlSearchInput({required this.controller, required this.dsl, this.address}); final PqlController controller; final bool dsl; + final String? address; @override Widget build(BuildContext context) { return ClideFilterBox( + address: address, hint: dsl ? 'PQL query…' : 'Search vault…', debounce: dsl ? Duration.zero : const Duration(milliseconds: 300), onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)), diff --git a/lib/builtin/problems/src/problems_view.dart b/lib/builtin/problems/src/problems_view.dart index 1b75f2a9..065d5112 100644 --- a/lib/builtin/problems/src/problems_view.dart +++ b/lib/builtin/problems/src/problems_view.dart @@ -54,7 +54,7 @@ class _ProblemsViewState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ClideFilterBox(hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)), + ClideFilterBox(address: 'problems.panel', hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), child: Row( diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart index 5d4cbda2..6b6a3473 100644 --- a/lib/builtin/search/src/search_panel_view.dart +++ b/lib/builtin/search/src/search_panel_view.dart @@ -105,7 +105,7 @@ class _SearchPanelViewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ClideFilterBox(hint: 'Search', onChanged: c.run, onSubmitted: c.run), + ClideFilterBox(address: 'search.findInFiles', hint: 'Search', onChanged: c.run, onSubmitted: c.run), const SizedBox(height: 6), Row( children: [ @@ -137,7 +137,9 @@ class _SearchPanelViewState extends State { const SizedBox(height: 6), Row( children: [ - Expanded(child: ClideFilterBox(hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)), + Expanded( + child: ClideFilterBox( + address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)), const SizedBox(width: 6), _ReplaceAllButton( enabled: c.replacement.isNotEmpty && c.matchCount > 0, @@ -147,9 +149,15 @@ class _SearchPanelViewState extends State { ], ), const SizedBox(height: 6), - ClideFilterBox(hint: 'files to include (e.g. *.dart)', icon: null, debounce: Duration.zero, onChanged: (v) => c.include = v), + ClideFilterBox( + address: 'search.findInFiles.include', + hint: 'files to include (e.g. *.dart)', + icon: null, + debounce: Duration.zero, + onChanged: (v) => c.include = v), const SizedBox(height: 4), - ClideFilterBox(hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v), + ClideFilterBox( + address: 'search.findInFiles.exclude', hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v), ], ), ), diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 0f2df85f..e122c884 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -159,7 +159,7 @@ class _TicketsViewState extends State { children: [ Row( children: [ - Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))), + Expanded(child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))), Padding( padding: const EdgeInsets.only(right: 8), child: ClideTappable( diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 6dd1657f..27adcf5f 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -12,6 +12,7 @@ library; export 'src/events/bus.dart'; +export 'src/events/filter_state.dart'; export 'src/events/message_bus.dart'; export 'src/events/types.dart'; export 'src/ipc/client.dart'; diff --git a/lib/kernel/src/events/filter_state.dart b/lib/kernel/src/events/filter_state.dart new file mode 100644 index 00000000..5d85bda2 --- /dev/null +++ b/lib/kernel/src/events/filter_state.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'message_bus.dart'; + +/// Caches the latest filter value per address, fed by `filter.state` +/// messages on the [MessageBus] (T-270, D-6 parity). +/// +/// Sidebar filter boxes are addressable from the CLI: `clide ui filter` +/// with an address and text publishes a `filter.set` message the box +/// reacts to (the drive-half). The observe-half — the same verb with no +/// text — needs to read the box's *current* value back, but the bus is +/// a plain broadcast stream with no retention. Each box republishes its +/// value on the `filter.state` channel whenever it changes; this cache +/// listens once and remembers the latest per address, giving the observe +/// verb something to read. +/// +/// Pure Dart — no Flutter import — so it serialises through the kernel and +/// stays usable from `dart test`. +class FilterStateCache { + FilterStateCache({required MessageBus messages}) { + _sub = messages.subscribe(channel: 'filter.state').listen((m) { + _values[m.publisher] = m.data['query'] as String? ?? ''; + }); + } + + final Map _values = {}; + StreamSubscription? _sub; + + /// The last reported filter value for [address], or null if no box at + /// that address has reported yet. + String? get(String address) => _values[address]; + + void dispose() { + _sub?.cancel(); + } +} diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index acffab97..18f36c1f 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -10,6 +10,7 @@ import 'package:clide/kernel/src/commands/palette.dart'; import 'package:clide/kernel/src/commands/registry.dart'; import 'package:clide/kernel/src/dialog.dart'; import 'package:clide/kernel/src/events/bus.dart'; +import 'package:clide/kernel/src/events/filter_state.dart'; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/kernel/src/extensions_manager.dart'; import 'package:clide/kernel/src/files.dart'; @@ -47,6 +48,7 @@ class KernelServices { required this.settings, required this.events, required this.messages, + required this.filterStates, required this.ipc, required this.theme, required this.i18n, @@ -82,6 +84,10 @@ class KernelServices { final SettingsStore settings; final DaemonBus events; final MessageBus messages; + + /// Latest filter value per addressable box, fed by `filter.state` + /// messages — backs the observe-half of `clide ui filter` (T-270). + final FilterStateCache filterStates; final DaemonClient ipc; final ThemeController theme; final I18n i18n; @@ -135,6 +141,7 @@ class KernelServices { final log = Logger(sinks: [stderrSink, logRing.add]); final events = sharedBus ?? DaemonBus(); final messages = MessageBus(); + final filterStates = FilterStateCache(messages: messages); final settings = SettingsStore(appDir: appDir); await settings.load(); @@ -236,6 +243,7 @@ class KernelServices { settings: settings, events: events, messages: messages, + filterStates: filterStates, ipc: ipc, theme: theme, i18n: i18n, @@ -292,6 +300,7 @@ class KernelServices { keymap.dispose(); textZoom.dispose(); await log.dispose(); + filterStates.dispose(); messages.dispose(); await events.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index 390efc5c..3d362efe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -98,6 +98,9 @@ Future main() async { // The kernel MessageBus, captured post-boot so `ui.open` can drive the GUI // readers (publish a 'selection') from the CLI — the drive-half of D-6 (T-231). MessageBus? kernelMessages; + // The filter-state cache, captured post-boot so `ui.filter` can read a + // box's current value back — the observe-half of D-6 (T-270). + FilterStateCache? kernelFilterStates; // IPC socket server (T-99 / T-124, per D-70/71/72). One server per // workspace; restarted when the active project switches because the // socket path is workspace-derived. The local DaemonClient connects @@ -216,7 +219,11 @@ Future main() async { // `clide ui open ` — drive the GUI readers from the CLI // (T-231, drive-half of D-6). Publishes a 'selection' to the kernel // MessageBus, captured post-boot; null in headless contexts. - registerUiCommands(dispatcher, () => kernelMessages?.publish); + registerUiCommands( + dispatcher, + () => kernelMessages?.publish, + filterValue: (address) => kernelFilterStates?.get(address), + ); // `clide image show ` — drive an image card into the Claude // conversation log (T-249, drive-half of D-6). Resolves the path // (workspace-relative → absolute, must exist) here where workRoot is in @@ -340,6 +347,7 @@ Future main() async { // only reads it at request time (post-boot), so capturing it here is safe. kernelReaderNav = services.readerNav; kernelMessages = services.messages; + kernelFilterStates = services.filterStates; // Tee the IPC/MCP logger into the shared ring so the output dock (T-54) // shows socket-side logs alongside kernel/extension ones. ipcLog.addSink(services.logRing.add); diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart index 16f07556..f44d19a2 100644 --- a/lib/src/daemon/ui_command.dart +++ b/lib/src/daemon/ui_command.dart @@ -1,4 +1,5 @@ -/// Registers `ui.open` — the drive-half of D-6 parity (T-231). +/// Registers the `ui.*` drive/observe verbs — the parity peers of D-6 +/// (`ui.open` T-231, `ui.toast` T-50, `ui.filter` T-270). /// /// Epic C (T-218) gave the CLI the *observe* half: `clide status` / `pane /// list` read live UI state. This is the complement — the agent asks the @@ -16,6 +17,7 @@ /// Flutter-free and runs under `dart test`. library; +import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; import 'dispatcher.dart'; @@ -40,9 +42,26 @@ const Map _readers = { /// literal so this file stays Flutter-free for `dart test`). const Set _toastSeverities = {'success', 'warning', 'error', 'info'}; -void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) { +/// Reads the current filter value for an addressable box from the kernel's +/// `FilterStateCache` — the observe-half of `ui.filter`. Returns null when +/// nothing has reported a value for that address (or there is no live UI). +typedef FilterValueSource = String? Function(String address); + +void registerUiCommands( + DaemonDispatcher d, + MessagePublisher? Function() publisher, { + FilterValueSource? filterValue, +}) { d.register('ui.open', (req) async => _open(req, publisher)); d.register('ui.toast', (req) async => _toast(req, publisher)); + d.register( + 'ui.filter', + (req) async => _filter(req, publisher, filterValue), + schema: const CommandSchema( + positional: ['address', 'query'], + args: {'address': ArgSpec(required: true), 'query': ArgSpec()}, + ), + ); } IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err( @@ -122,3 +141,50 @@ Future _toast(IpcRequest req, MessagePublisher? Function() publishe }); return IpcResponse.ok(id: req.id, data: {'message': message, 'severity': severity, 'shown': true}); } + +/// `clide ui filter
[]` — the drive+observe parity peer for +/// the sidebar filter boxes (T-270, D-6). Routed through the MessageBus so a +/// box reacts identically whether the trigger was a UI keystroke or this +/// verb, keeping extensions first-class (no dispatcher→widget wiring). +/// +/// clide ui filter decisions.panel git # set the filter to "git" +/// clide ui filter decisions.panel "" # clear it +/// clide ui filter decisions.panel # read the current value +/// +/// `address` is a pane/box id from `clide pane list`. With a `query` arg the +/// verb *drives* — publishes a `filter.set` the box consumes. Without one it +/// *observes* — reads the box's last reported value from the FilterStateCache. +Future _filter( + IpcRequest req, + MessagePublisher? Function() publisherSource, + FilterValueSource? filterValue, +) async { + final address = req.args['address'] as String?; + if (address == null || address.isEmpty) { + return _userErr(req.id, 'an address is required', hint: 'a pane/box id from `clide pane list` (e.g. decisions.panel)'); + } + + // Absent query → observe; present (even empty string) → drive. The schema + // drops unmapped positionals, so a missing query leaves no `query` key. + if (!req.args.containsKey('query')) { + final getter = filterValue; + if (getter == null) { + return IpcResponse.err( + id: req.id, + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to observe (clide is not running a GUI)'), + ); + } + return IpcResponse.ok(id: req.id, data: {'address': address, 'query': getter(address)}); + } + + final query = (req.args['query'] as String?) ?? ''; + final publish = publisherSource(); + if (publish == null) { + return IpcResponse.err( + id: req.id, + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'), + ); + } + publish(address, 'filter.set', {'query': query}); + return IpcResponse.ok(id: req.id, data: {'address': address, 'query': query, 'set': true}); +} diff --git a/lib/widgets/src/clide_filter_box.dart b/lib/widgets/src/clide_filter_box.dart index 01cfd356..f99c490e 100644 --- a/lib/widgets/src/clide_filter_box.dart +++ b/lib/widgets/src/clide_filter_box.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:clide/kernel/src/events/message_bus.dart'; +import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/widgets/src/clide_icon.dart'; import 'package:clide/widgets/src/icons/phosphor.dart'; @@ -14,6 +16,7 @@ class ClideFilterBox extends StatefulWidget { this.debounce = const Duration(milliseconds: 200), this.onSubmitted, this.icon = PhosphorIcons.magnifyingGlass, + this.address, }); final ValueChanged onChanged; @@ -25,6 +28,15 @@ class ClideFilterBox extends StatefulWidget { /// that aren't searches (e.g. a replace or glob field). final ClideIconPainter? icon; + /// Makes this box addressable from the CLI (D-6 parity, T-270). When set, + /// the box listens on the MessageBus `filter.set` channel for its address + /// — so `clide ui filter
` drives it exactly as a UI + /// keystroke would — and republishes its value on `filter.state` so the + /// observe-half (`clide ui filter
`) can read it back. The + /// address is the pane/box id surfaced by `clide pane list`. When null the + /// box is a plain UI-only widget and never touches the kernel. + final String? address; + @override State createState() => _ClideFilterBoxState(); } @@ -33,18 +45,55 @@ class _ClideFilterBoxState extends State { final _controller = TextEditingController(); final _focus = FocusNode(); Timer? _debounceTimer; + StreamSubscription? _busSub; + MessageBus? _bus; + bool _wired = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final address = widget.address; + if (address == null || _wired) return; + _wired = true; + final bus = ClideKernel.of(context).messages; + _bus = bus; + _busSub = bus.subscribe(publisher: address, channel: 'filter.set').listen(_onRemoteSet); + // Report the initial value so an observe before any change still reads it. + _publishState(_controller.text); + } @override void dispose() { _debounceTimer?.cancel(); + _busSub?.cancel(); _controller.dispose(); _focus.dispose(); super.dispose(); } + /// Apply a value pushed in over the bus. Programmatic sets take effect + /// immediately (no debounce) and report their new state. + void _onRemoteSet(Message m) { + final value = m.data['query'] as String? ?? ''; + _debounceTimer?.cancel(); + _controller.text = value; + widget.onChanged(value); + _publishState(value); + setState(() {}); + } + + void _publishState(String value) { + final address = widget.address; + if (address == null) return; + _bus?.publish(address, 'filter.state', {'query': value}); + } + void _onChanged(String value) { _debounceTimer?.cancel(); - _debounceTimer = Timer(widget.debounce, () => widget.onChanged(value)); + _debounceTimer = Timer(widget.debounce, () { + widget.onChanged(value); + _publishState(value); + }); setState(() {}); } @@ -52,6 +101,7 @@ class _ClideFilterBoxState extends State { _controller.clear(); _debounceTimer?.cancel(); widget.onChanged(''); + _publishState(''); _focus.requestFocus(); setState(() {}); } diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart index 1ca60a4e..82dafa4f 100644 --- a/test/daemon/ui_command_test.dart +++ b/test/daemon/ui_command_test.dart @@ -12,13 +12,22 @@ void main() { late List<({String publisher, String channel, Map data})> published; late DaemonDispatcher d; + // Backing store for the ui.filter observe-half (the FilterStateCache in + // production). Null getter ⇒ no live UI to observe. + late Map filterValues; + void wire({bool liveUi = true}) { published = []; + filterValues = {}; d = DaemonDispatcher(); - registerUiCommands(d, () { - if (!liveUi) return null; - return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data)); - }); + registerUiCommands( + d, + () { + if (!liveUi) return null; + return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data)); + }, + filterValue: liveUi ? (address) => filterValues[address] : null, + ); } Future open(List positional) => d.dispatch( @@ -138,4 +147,65 @@ void main() { expect(r.ok, isFalse); expect(r.error?.kind, IpcErrorKind.toolError); }); + + // -- ui.filter (T-270 drive+observe half) --------------------------------- + + Future filter(List positional) => d.dispatch( + IpcRequest(id: '1', cmd: 'ui.filter', args: {'positional': positional}), + ); + + test('ui filter
publishes a filter.set', () async { + wire(); + final r = await filter(['decisions.panel', 'git']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['set'], isTrue); + expect(published.single.publisher, 'decisions.panel'); + expect(published.single.channel, 'filter.set'); + expect(published.single.data, {'query': 'git'}); + }); + + test('ui filter
"" clears (drives an empty query)', () async { + wire(); + final r = await filter(['decisions.panel', '']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'query': ''}); + }); + + test('ui filter
with no text observes the cached value', () async { + wire(); + filterValues['decisions.panel'] = 'git'; + final r = await filter(['decisions.panel']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data, {'address': 'decisions.panel', 'query': 'git'}); + expect(published, isEmpty, reason: 'observe must not publish'); + }); + + test('ui filter observe of an unknown address returns a null query', () async { + wire(); + final r = await filter(['never.touched']); + expect(r.ok, isTrue); + expect(r.data, {'address': 'never.touched', 'query': null}); + }); + + test('ui filter with no address → userError', () async { + wire(); + final r = await filter([]); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('ui filter drive with no live UI → toolError', () async { + wire(liveUi: false); + final r = await filter(['decisions.panel', 'git']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.toolError); + }); + + test('ui filter observe with no live UI → toolError', () async { + wire(liveUi: false); + final r = await filter(['decisions.panel']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.toolError); + }); } diff --git a/test/kernel/filter_state_test.dart b/test/kernel/filter_state_test.dart new file mode 100644 index 00000000..056f06c1 --- /dev/null +++ b/test/kernel/filter_state_test.dart @@ -0,0 +1,67 @@ +/// Tests for [FilterStateCache] — the observe-half backing store for +/// `clide ui filter` (T-270). It listens on the MessageBus `filter.state` +/// channel and remembers the latest value per address. +library; + +import 'package:clide/kernel/src/events/filter_state.dart'; +import 'package:clide/kernel/src/events/message_bus.dart'; +import 'package:test/test.dart'; + +void main() { + late MessageBus bus; + late FilterStateCache cache; + + setUp(() { + bus = MessageBus(); + cache = FilterStateCache(messages: bus); + }); + + tearDown(() { + cache.dispose(); + bus.dispose(); + }); + + // Bus delivery is async (broadcast stream), so settle a turn after publish. + Future settle() => Future.delayed(Duration.zero); + + test('returns null for an address that never reported', () { + expect(cache.get('decisions.panel'), isNull); + }); + + test('remembers the latest filter.state value per address', () async { + bus.publish('decisions.panel', 'filter.state', {'query': 'git'}); + await settle(); + expect(cache.get('decisions.panel'), 'git'); + + bus.publish('decisions.panel', 'filter.state', {'query': 'pql'}); + await settle(); + expect(cache.get('decisions.panel'), 'pql', reason: 'latest wins'); + }); + + test('keeps addresses independent', () async { + bus.publish('decisions.panel', 'filter.state', {'query': 'git'}); + bus.publish('files.tree', 'filter.state', {'query': 'lib'}); + await settle(); + expect(cache.get('decisions.panel'), 'git'); + expect(cache.get('files.tree'), 'lib'); + }); + + test('ignores other channels', () async { + bus.publish('decisions.panel', 'filter.set', {'query': 'git'}); + await settle(); + expect(cache.get('decisions.panel'), isNull, reason: 'only filter.state feeds the cache'); + }); + + test('a missing query is treated as empty', () async { + bus.publish('decisions.panel', 'filter.state', {}); + await settle(); + expect(cache.get('decisions.panel'), ''); + }); + + test('stops updating after dispose', () async { + cache.dispose(); + bus.publish('decisions.panel', 'filter.state', {'query': 'git'}); + await settle(); + expect(cache.get('decisions.panel'), isNull); + }); +} diff --git a/test/widgets/src/clide_filter_box_test.dart b/test/widgets/src/clide_filter_box_test.dart new file mode 100644 index 00000000..73bd0cbe --- /dev/null +++ b/test/widgets/src/clide_filter_box_test.dart @@ -0,0 +1,124 @@ +/// Tests for [ClideFilterBox], focused on the CLI-addressable behaviour +/// added in T-270: an addressed box reacts to `filter.set` messages and +/// republishes its value on `filter.state`, while a plain (unaddressed) +/// box stays a kernel-free UI widget. +/// +/// The box has an internal `Expanded`, so it needs a bounded-width +/// ancestor — we build a tight tree rather than the shared `harness()` +/// (whose canSizeOverlay hands unbounded width). +library; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart' show pumpAsync; + +void main() { + late KernelFixture fixture; + + setUp(() async => fixture = await KernelFixture.create()); + tearDown(() async => fixture.dispose()); + + // Tight, bounded tree with a live ClideKernel so an addressed box can + // resolve the MessageBus. + Future mountAddressed(WidgetTester tester, Widget child) { + return tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: ClideKernel( + services: fixture.services, + child: ClideTheme( + controller: fixture.services.theme, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 300, height: 60, child: child), + ), + ), + ), + ), + ), + ); + } + + String editableText(WidgetTester tester) => tester.widget(find.byType(EditableText)).controller.text; + + testWidgets('filter.set drives the box: updates field, fires onChanged, reports state', (tester) async { + String? captured; + await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v)); + await pumpAsync(tester); + + fixture.services.messages.publish('test.box', 'filter.set', {'query': 'git'}); + await pumpAsync(tester); + + expect(captured, 'git', reason: 'onChanged fires for a programmatic set'); + expect(editableText(tester), 'git', reason: 'the field shows the pushed value'); + expect(fixture.services.filterStates.get('test.box'), 'git', reason: 'state is reported back for observe'); + }); + + testWidgets('typing reports the value on filter.state (for observe)', (tester) async { + await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (_) {})); + await pumpAsync(tester); + // Initial mount reports the empty value. + expect(fixture.services.filterStates.get('test.box'), ''); + + await tester.enterText(find.byType(EditableText), 'lib'); + await tester.pump(const Duration(milliseconds: 250)); // past the 200ms debounce + expect(fixture.services.filterStates.get('test.box'), 'lib'); + }); + + testWidgets('only the addressed box reacts (addresses are isolated)', (tester) async { + String? captured; + await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v)); + await pumpAsync(tester); + + fixture.services.messages.publish('other.box', 'filter.set', {'query': 'nope'}); + await pumpAsync(tester); + expect(captured, isNull); + expect(editableText(tester), isEmpty); + }); + + testWidgets('the clear affordance empties the field and reports an empty value', (tester) async { + String? captured; + await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v)); + await pumpAsync(tester); + + await tester.enterText(find.byType(EditableText), 'git'); + await tester.pump(const Duration(milliseconds: 250)); + expect(captured, 'git'); + + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(captured, ''); + expect(editableText(tester), isEmpty); + expect(fixture.services.filterStates.get('test.box'), ''); + }); + + testWidgets('an unaddressed box needs no ClideKernel and still fires onChanged', (tester) async { + String? captured; + // ClideTheme is required by every box's build; ClideKernel is NOT — an + // unaddressed box must never reach for the MessageBus. No kernel here. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: ClideTheme( + controller: fixture.services.theme, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 300, height: 60, child: ClideFilterBox(onChanged: (v) => captured = v)), + ), + ), + ), + ), + ); + await tester.enterText(find.byType(EditableText), 'x'); + await tester.pump(const Duration(milliseconds: 250)); + expect(captured, 'x'); + }); +}