feat(settings): cross-category settings search (T-450)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:24:24 +02:00
co-authored by Claude Opus 4.8
parent f56ad88439
commit f643d6e747
12 changed files with 184 additions and 68 deletions
+7 -3
View File
@@ -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
@@ -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;
+1
View File
@@ -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);
+5
View File
@@ -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
+8 -4
View File
@@ -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 <noreply@anthropic.com>` when Claude wrote
any of the diff.
+1 -1
View File
@@ -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.
+1
View File
@@ -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
@@ -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<SettingsSection> settingsMatchingSections(SettingsCategory category, String query) {
final out = <SettingsSection>[];
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 = <Widget>[];
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),
],
+55 -33
View File
@@ -44,6 +44,9 @@ class _SettingsModalState extends State<SettingsModal> {
/// 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<SettingsModal> {
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<String> 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);
},
);
}
}
@@ -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" },
@@ -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<String>('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);
});
});
}
+1 -2
View File
@@ -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', () {