From f643d6e7479ac1308dc84343edbb41835c15f5c5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 17 Jun 2026 12:24:24 +0200 Subject: [PATCH] feat(settings): cross-category settings search (T-450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search box (ClideFilterBox) atop the rail filters fields across every registered category. While searching, the panel swaps to a results view that groups the matching fields under category subheaders — rendered with the same carded rows and editable inline — and each rail row shows its match count with zero-match categories dimmed. Completes the settings-UI infra spine (T-444): shell, engine, rail, scope tags, search. Tests: search filters across categories, hides non-matches, and surfaces the per-category rail count. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/git-commit/SKILL.md | 10 +- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 1 + CHANGELOG.md | 5 + CONTRIBUTING.md | 12 +- POLICY.md | 2 +- governance/decisions/process.md | 1 + .../src/settings_category_view.dart | 105 ++++++++++++++---- .../settings_ui/src/settings_modal.dart | 88 +++++++++------ .../catalog/builtin.settings-ui_en_us.json | 2 + .../settings_ui/category_view_test.dart | 22 +++- test/kernel/src/settings_registry_test.dart | 3 +- 12 files changed, 184 insertions(+), 68 deletions(-) diff --git a/.claude/skills/git-commit/SKILL.md b/.claude/skills/git-commit/SKILL.md index d1a651c3..328eaa20 100644 --- a/.claude/skills/git-commit/SKILL.md +++ b/.claude/skills/git-commit/SKILL.md @@ -14,12 +14,16 @@ Follow these conventions whenever you create a commit in this repository. These ## Message style -- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`. +This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) (per [D-37](../../../governance/decisions/process.md#d-37)). + +- **First line:** `type(scope): imperative subject`, ≤ 72 characters **including** the prefix. Examples: `feat(settings): add Appearance font picker (T-460)`, `fix(ipc): reconnect after app reload`, `docs(readme): drop brittle version line`. +- **Type:** one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`. Use `feat`/`fix` for user-visible behavior; `chore` for bookkeeping (`chore(plan)` is the convention for pql ticket housekeeping). Append `!` after the scope for a breaking change (`feat(ipc)!: …`). +- **Scope (optional but preferred):** the subsystem the change lives in — `settings`, `vim`, `pty`, `git`, `plan`, etc. Lower-case, no spaces. +- **Ticket ref:** keep a trailing `(T-NNN)` on the subject when the work has a ticket — `feat(settings): category rail + navigation (T-447)`. - **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose. - **No emojis.** Anywhere. -- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.) - **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves. -- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`. +- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the `clide` CLI is a thin C client (`native/clide-cli/`). The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`. ## Logically-separated commits diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index df97d835..ba387a28 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5904,3 +5904,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.911', '2026-06-17 10:09:41.911', NULL, '5588d681ec1d926adbcdc378ef37eb47', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.353', '2026-06-17 10:16:22.353', NULL, '290e5da4942b778213253ef665c3a4b1', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.702', '2026-06-17 10:16:22.702', NULL, 'faabfb970849aacda3919f1501ba7bfd', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:22:42', '2026-06-17 10:22:42.544', '2026-06-17 10:22:42.544', NULL, 'b150c458bb2754f0369000e65406dec2', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 702bdf2a..1928a04f 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7172,3 +7172,4 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:09:41.911', NULL, 'db4e636e73d5ca1486f9416f47515e03', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:16:22.353', NULL, 'a8d1080773ce726edebb44a0d2d56023', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:16:22.701', NULL, 'e8b9bc4e621f5f39d6bba4904eadf0ac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:22:42.544', NULL, 'a10a68df4d4cf7ed0f639d74dda41d37', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 e770561e..df4d818d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. `effectiveLayer`/`writableLayers`); `ext.*` keys layer project-over-app, `app.*`/`project.*` keys live in their prefix's file. (T-449) +- **Cross-category settings search.** A search box atop the rail filters fields + across every category; the panel shows the matches grouped under category + subheaders (editable inline), and each rail row shows its match count with + zero-match categories dimmed. (T-450) + ### Changed - **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce33bbed..8d6a9cbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,14 +181,18 @@ tickets before the diff lands. Trivial typo fixes don't need one. See [D-37](governance/decisions/process.md#d-37) and the bundled [`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short: -- Imperative subject ≤ 70 chars, no Conventional Commits prefix - (this isn't a Conventional Commits repo — the archived Python - predecessor under [`legacy/`](legacy/) is, but the rebuild isn't). +- [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/): + `type(scope): imperative subject`, ≤ 72 chars including the prefix. + Types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, + `test`, `build`, `chore`; scope is the subsystem (`settings`, + `vim`, `pty`…); keep a trailing `(T-NNN)` ticket ref where one + applies — e.g. `feat(settings): category rail + navigation (T-447)`. - One logical change per commit. If the subject needs "and", split it. - Every user-visible commit adds an entry to `CHANGELOG.md` under `[Unreleased]` in the right subsection (Added, Changed, Deprecated, Removed, Fixed, Security). Keep entries to one or two short - sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`. + sentences — the 60-word cap is enforced by the pre-push gate + (`make changelog-gate`). - Co-author trailer: `Co-Authored-By: Claude ` when Claude wrote any of the diff. diff --git a/POLICY.md b/POLICY.md index 4661bb74..3dfed44a 100644 --- a/POLICY.md +++ b/POLICY.md @@ -199,7 +199,7 @@ Adding a dependency means updating the license manifest in the same PR. No excep ## Changelog and commit conventions -clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit. +clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages (see [D-37](governance/decisions/process.md#d-37)). Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit. Security-relevant changes — CVE responses, dependency-driven vulnerability fixes, the removal of a phoning-home transitive dep, anything where the rules in this document were the reason for the change — go under the `Security` heading of the release's changelog entry, regardless of whether the code change itself looks security-shaped. That heading is the trail future-us follows to reconstruct why a dep was bumped or removed. Lumping security fixes under `Fixed` because the diff looks like a normal bug fix loses that signal and is the wrong choice even when it's technically accurate. diff --git a/governance/decisions/process.md b/governance/decisions/process.md index f85e81d0..0f808017 100644 --- a/governance/decisions/process.md +++ b/governance/decisions/process.md @@ -31,6 +31,7 @@ Q&D record system itself, kanban, commit conventions, changelog. - **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already. - **Cost:** Contributors with Conventional Commits muscle memory adjust. - **Raised by:** 2026-04-21 planning. +- **Amendment (2026-06-17):** Reversed — the rebuild **does** use [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) after all. Format is `type(scope): imperative subject`, with the standard type set (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`); `scope` is the subsystem (`settings`, `vim`, `pty`, `plan`, …); append `!` after the scope for a breaking change; keep a trailing `(T-NNN)` ticket ref where one applies. Subject (prefix included) stays ≤ 72 chars; the no-emoji, body, HEREDOC, and attribution-trailer rules from the original decision are unchanged. Practice had already drifted to this form (`feat(settings): category rail + navigation (T-447)`); the decision now matches it. `.claude/skills/git-commit/SKILL.md`, `CONTRIBUTING.md`, and `POLICY.md` updated to suit. **Why the reversal:** the original "types are noise" call didn't hold up — scoped types make `git log` skimmable and the changelog subsection (Added/Fixed/…) maps cleanly onto the commit type. ### D-38: Changelog discipline — Keep a Changelog 1.1.0 - **Date:** 2026-04-21 diff --git a/lib/builtin/settings_ui/src/settings_category_view.dart b/lib/builtin/settings_ui/src/settings_category_view.dart index 0a9c7d19..b4ef27fb 100644 --- a/lib/builtin/settings_ui/src/settings_category_view.dart +++ b/lib/builtin/settings_ui/src/settings_category_view.dart @@ -10,8 +10,8 @@ const _settingsNs = 'builtin.settings-ui'; /// `SettingsStore` key — read with `get` (falling back to the schema default), /// written with `set` on edit. Rebuilds live as the store changes. /// -/// Per-field scope tags (T-449) and cross-category search (T-450) layer onto -/// the row in their own tickets; the trailing slot here is the reset control. +/// Each row ends in a per-field scope tag (T-449) whose menu also resets the +/// value; cross-category search lives in [SettingsSearchResults] (T-450). /// Carded layout follows ui-design `surface.md` ("sectioned cards"). class SettingsCategoryView extends StatelessWidget { const SettingsCategoryView({super.key, required this.category}); @@ -28,15 +28,88 @@ class SettingsCategoryView extends StatelessWidget { padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final section in category.sections) _SectionCard(section: section, store: store), - ], + children: [for (final section in category.sections) _SectionCard(section: section, store: store)], ), ), ); } } +/// True when [field] matches [query] (case-insensitive; label or help). An +/// empty query matches everything. +bool settingsFieldMatches(SettingsField field, String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + return field.label.toLowerCase().contains(q) || (field.help?.toLowerCase().contains(q) ?? false); +} + +/// [category]'s sections holding only the fields matching [query]; empty +/// sections are dropped. +List settingsMatchingSections(SettingsCategory category, String query) { + final out = []; + for (final s in category.sections) { + final fields = s.fields.where((f) => settingsFieldMatches(f, query)).toList(); + if (fields.isNotEmpty) out.add(SettingsSection(label: s.label, fields: fields)); + } + return out; +} + +/// How many fields in [category] match [query] (the rail's per-category count). +int settingsMatchCount(SettingsCategory category, String query) => + category.sections.fold(0, (n, s) => n + s.fields.where((f) => settingsFieldMatches(f, query)).length); + +/// Cross-category search results (T-450): every matching field across ALL +/// registered categories, grouped under a category subheader, rendered with the +/// same carded field rows and editable inline. +class SettingsSearchResults extends StatelessWidget { + const SettingsSearchResults({super.key, required this.query}); + + final String query; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final i = ClideKernel.of(context).i18n; + final store = ClideKernel.of(context).settings; + final registry = ClideKernel.of(context).settingsRegistry; + return ListenableBuilder( + listenable: store, + builder: (context, _) { + final blocks = []; + for (final c in registry.categories) { + final sections = settingsMatchingSections(c, query); + if (sections.isEmpty) continue; + blocks.add( + Padding( + padding: EdgeInsets.only(top: blocks.isEmpty ? 0 : 14, bottom: 8), + child: ClideText(c.title, fontSize: 13, fontWeight: FontWeight.w600, color: tokens.globalForeground), + ), + ); + for (final s in sections) { + blocks.add(_SectionCard(section: s, store: store)); + } + } + if (blocks.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ClideText( + i.string('search.empty', namespace: _settingsNs, placeholder: 'No settings match your search.'), + color: tokens.globalTextMuted, + textAlign: TextAlign.center, + ), + ), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks), + ); + }, + ); + } +} + /// One section: a small-caps header above an elevated card of field rows. class _SectionCard extends StatelessWidget { const _SectionCard({required this.section, required this.store}); @@ -54,12 +127,7 @@ class _SectionCard extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only(left: 2, bottom: 6), - child: ClideText( - section.label.toUpperCase(), - fontSize: clideFontCaption, - color: tokens.sidebarSectionHeader, - fontFamily: clideMonoFamily, - ), + child: ClideText(section.label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), ), ClideSurface( // Card surface (surface.md): panelHeader resolves to the `surface` @@ -71,10 +139,7 @@ class _SectionCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (var i = 0; i < section.fields.length; i++) ...[ - if (i > 0) const ClideDivider(), - _FieldRow(field: section.fields[i], store: store), - ], + for (var i = 0; i < section.fields.length; i++) ...[if (i > 0) const ClideDivider(), _FieldRow(field: section.fields[i], store: store)], ], ), ), @@ -399,8 +464,7 @@ class _ScopeTagState extends State<_ScopeTag> { super.dispose(); } - String _ns(String key, String fallback) => - ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback); + String _ns(String key, String fallback) => ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback); ({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) { final tokens = ClideTheme.of(context).surface; @@ -452,12 +516,7 @@ class _ScopeTagState extends State<_ScopeTag> { overlayBuilder: (ctx, c) => ClideMenu( onClose: c.close, entries: [ - for (final layer in layers) - ClideMenuItem( - label: _appearance(layer).label, - active: layer == current, - onSelect: () => _moveTo(layer), - ), + for (final layer in layers) ClideMenuItem(label: _appearance(layer).label, active: layer == current, onSelect: () => _moveTo(layer)), const ClideMenuSeparator(), ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset), ], diff --git a/lib/builtin/settings_ui/src/settings_modal.dart b/lib/builtin/settings_ui/src/settings_modal.dart index e93d1122..6e3f718b 100644 --- a/lib/builtin/settings_ui/src/settings_modal.dart +++ b/lib/builtin/settings_ui/src/settings_modal.dart @@ -44,6 +44,9 @@ class _SettingsModalState extends State { /// The rail sets this in T-447. String? _selectedId; + /// Cross-category search query (T-450); non-empty swaps the panel to results. + String _query = ''; + @override Widget build(BuildContext context) { final i = ClideKernel.of(context).i18n; @@ -82,11 +85,18 @@ class _SettingsModalState extends State { width: SettingsModal._railWidth, child: _CategoryRail( selectedId: _selectedId, + query: _query, onSelect: (id) => setState(() => _selectedId = id), + onQueryChanged: (q) => setState(() => _query = q), ), ), const ClideDivider(axis: Axis.vertical), - Expanded(child: _SettingsPanel(selectedId: _selectedId)), + Expanded( + child: ColoredBox( + color: tokens.panelBackground, + child: _query.trim().isEmpty ? _SettingsPanel(selectedId: _selectedId) : SettingsSearchResults(query: _query), + ), + ), ], ), ), @@ -144,10 +154,7 @@ class _CloseButton extends StatelessWidget { onTap: onTap, builder: (ctx, hovered, pressed) => Container( padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: hovered ? tokens.listItemHoverBackground : null, - borderRadius: BorderRadius.circular(4), - ), + decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)), child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground), ), ), @@ -155,21 +162,25 @@ class _CloseButton extends StatelessWidget { } } -/// Left rail — the registered categories, data-driven from the -/// [SettingsRegistry]. Selecting one swaps the panel (T-447). The cross-category -/// search box sits atop the rail in T-450. +/// Left rail — a cross-category search box atop the registered categories, +/// data-driven from the [SettingsRegistry]. Selecting one swaps the panel +/// (T-447); while searching, each row shows its match count and zero-match +/// rows dim (T-450). class _CategoryRail extends StatelessWidget { - const _CategoryRail({required this.selectedId, required this.onSelect}); + const _CategoryRail({required this.selectedId, required this.query, required this.onSelect, required this.onQueryChanged}); /// The modal's chosen category id (null → the first category). final String? selectedId; + final String query; final void Function(String id) onSelect; + final ValueChanged onQueryChanged; @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; final i = ClideKernel.of(context).i18n; final registry = ClideKernel.of(context).settingsRegistry; + final searching = query.trim().isNotEmpty; return ListenableBuilder( listenable: registry, builder: (context, _) { @@ -179,7 +190,14 @@ class _CategoryRail extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 12, 6), + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: ClideFilterBox( + hint: i.string('search.hint', namespace: SettingsModal.ns, placeholder: 'Search settings…'), + onChanged: onQueryChanged, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 12, 6), child: ClideText( i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'), fontSize: clideFontCaption, @@ -193,7 +211,12 @@ class _CategoryRail extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final c in categories) - _RailRow(category: c, selected: c.id == effectiveId, onTap: () => onSelect(c.id)), + _RailRow( + category: c, + selected: !searching && c.id == effectiveId, + matchCount: searching ? settingsMatchCount(c, query) : null, + onTap: () => onSelect(c.id), + ), ], ), ), @@ -206,18 +229,21 @@ class _CategoryRail extends StatelessWidget { } /// One category row: optional glyph + title, accent left-stripe + surfaceHi -/// fill when selected (surface.md side panels). +/// fill when selected (surface.md side panels). When [matchCount] is non-null +/// (searching) it shows the count and dims to muted on zero matches (T-450). class _RailRow extends StatelessWidget { - const _RailRow({required this.category, required this.selected, required this.onTap}); + const _RailRow({required this.category, required this.selected, required this.matchCount, required this.onTap}); final SettingsCategory category; final bool selected; + final int? matchCount; final VoidCallback onTap; @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - final fg = selected ? tokens.globalForeground : tokens.sidebarForeground; + final dim = matchCount == 0; + final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground); return Semantics( button: true, selected: selected, @@ -239,11 +265,11 @@ class _RailRow extends StatelessWidget { padding: const EdgeInsets.fromLTRB(10, 7, 12, 7), child: Row( children: [ - if (category.iconName != null) ...[ - ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), - const SizedBox(width: 8), - ], - Expanded(child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis)), + if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)], + Expanded( + child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + if (matchCount != null) ClideText('$matchCount', fontSize: clideFontCaption, color: tokens.globalTextMuted), ], ), ), @@ -258,8 +284,8 @@ class _RailRow extends StatelessWidget { /// Right panel — renders the selected category's schema (or the first /// registered one) via [SettingsCategoryView]; the empty state shows when no -/// category is registered. The region recedes to panelBackground so the -/// panelHeader cards pop (surface.md). +/// category is registered. The region recedes to panelBackground (set by the +/// modal) so the panelHeader cards pop (surface.md). class _SettingsPanel extends StatelessWidget { const _SettingsPanel({required this.selectedId}); @@ -267,19 +293,15 @@ class _SettingsPanel extends StatelessWidget { @override Widget build(BuildContext context) { - final tokens = ClideTheme.of(context).surface; final registry = ClideKernel.of(context).settingsRegistry; - return ColoredBox( - color: tokens.panelBackground, - child: ListenableBuilder( - listenable: registry, - builder: (context, _) { - final categories = registry.categories; - if (categories.isEmpty) return const _EmptyState(); - final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first; - return SettingsCategoryView(category: selected); - }, - ), + return ListenableBuilder( + listenable: registry, + builder: (context, _) { + final categories = registry.categories; + if (categories.isEmpty) return const _EmptyState(); + final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first; + return SettingsCategoryView(category: selected); + }, ); } } diff --git a/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json index 74f6111e..75f51022 100644 --- a/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json +++ b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json @@ -4,6 +4,8 @@ "modal.close.hint": { "translation": "Close settings without changing anything" }, "rail.header": { "translation": "Categories" }, "panel.empty": { "translation": "No settings categories are registered yet." }, + "search.hint": { "translation": "Search settings…" }, + "search.empty": { "translation": "No settings match your search." }, "scope.project": { "translation": "This project" }, "scope.always": { "translation": "All clide" }, "scope.default": { "translation": "Default" }, diff --git a/test/builtin/settings_ui/category_view_test.dart b/test/builtin/settings_ui/category_view_test.dart index 6570b6b4..f23ea2c5 100644 --- a/test/builtin/settings_ui/category_view_test.dart +++ b/test/builtin/settings_ui/category_view_test.dart @@ -1,6 +1,7 @@ import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,7 +22,10 @@ const _category = SettingsCategory( kind: SettingsFieldKind.select, label: 'Level', defaultValue: 'info', - options: [SettingsOption(value: 'info', label: 'Info'), SettingsOption(value: 'debug', label: 'Debug')], + options: [ + SettingsOption(value: 'info', label: 'Info'), + SettingsOption(value: 'debug', label: 'Debug'), + ], ), SettingsField(key: 'app.demo.name', kind: SettingsFieldKind.text, label: 'Name', defaultValue: ''), SettingsField(key: 'app.demo.size', kind: SettingsFieldKind.number, label: 'Size', defaultValue: 4, min: 1, max: 8), @@ -101,7 +105,6 @@ void main() { await tester.pump(); expect(f.services.settings.get('app.demo.level'), 'debug'); }); - }); group('scope tag (T-449)', () { @@ -150,5 +153,20 @@ void main() { expect(find.text('OtherFlag'), findsOneWidget); expect(find.text('Flag'), findsNothing); }); + + testWidgets('searching filters fields across categories with rail counts', (tester) async { + f.services.settingsRegistry.register(_category); + f.services.settingsRegistry.register(_other); + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {}))); + final box = find.descendant(of: find.byType(ClideFilterBox), matching: find.byType(EditableText)); + await tester.enterText(box, 'Other'); + await tester.pump(const Duration(milliseconds: 250)); // past the filter debounce + await tester.pump(); + // Only the matching field (in the Other category) is shown. + expect(find.text('OtherFlag'), findsOneWidget); + expect(find.text('Flag'), findsNothing); + // The rail shows the Other category's match count. + expect(find.text('1'), findsOneWidget); + }); }); } diff --git a/test/kernel/src/settings_registry_test.dart b/test/kernel/src/settings_registry_test.dart index d0614e4a..80d2882c 100644 --- a/test/kernel/src/settings_registry_test.dart +++ b/test/kernel/src/settings_registry_test.dart @@ -1,8 +1,7 @@ import 'package:clide/kernel/kernel.dart'; import 'package:flutter_test/flutter_test.dart'; -SettingsCategory _cat(String id, {String? title, int priority = 0}) => - SettingsCategory(id: id, title: title ?? id, priority: priority, sections: const []); +SettingsCategory _cat(String id, {String? title, int priority = 0}) => SettingsCategory(id: id, title: title ?? id, priority: priority, sections: const []); void main() { group('SettingsRegistry', () {