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:
@@ -14,12 +14,16 @@ Follow these conventions whenever you create a commit in this repository. These
|
|||||||
|
|
||||||
## Message style
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## 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', '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 ('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', '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;
|
||||||
|
|||||||
@@ -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.', '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 ('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.', '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);
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
`effectiveLayer`/`writableLayers`); `ext.*` keys layer project-over-app,
|
`effectiveLayer`/`writableLayers`); `ext.*` keys layer project-over-app,
|
||||||
`app.*`/`project.*` keys live in their prefix's file. (T-449)
|
`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
|
### Changed
|
||||||
|
|
||||||
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
||||||
|
|||||||
+8
-4
@@ -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
|
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||||
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||||
|
|
||||||
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
- [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||||
(this isn't a Conventional Commits repo — the archived Python
|
`type(scope): imperative subject`, ≤ 72 chars including the prefix.
|
||||||
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
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.
|
- One logical change per commit. If the subject needs "and", split it.
|
||||||
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||||
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||||
Removed, Fixed, Security). Keep entries to one or two short
|
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-author trailer:
|
||||||
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||||
any of the diff.
|
any of the diff.
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ Adding a dependency means updating the license manifest in the same PR. No excep
|
|||||||
|
|
||||||
## Changelog and commit conventions
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const _settingsNs = 'builtin.settings-ui';
|
|||||||
/// `SettingsStore` key — read with `get` (falling back to the schema default),
|
/// `SettingsStore` key — read with `get` (falling back to the schema default),
|
||||||
/// written with `set` on edit. Rebuilds live as the store changes.
|
/// 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
|
/// Each row ends in a per-field scope tag (T-449) whose menu also resets the
|
||||||
/// the row in their own tickets; the trailing slot here is the reset control.
|
/// value; cross-category search lives in [SettingsSearchResults] (T-450).
|
||||||
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
|
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
|
||||||
class SettingsCategoryView extends StatelessWidget {
|
class SettingsCategoryView extends StatelessWidget {
|
||||||
const SettingsCategoryView({super.key, required this.category});
|
const SettingsCategoryView({super.key, required this.category});
|
||||||
@@ -28,15 +28,88 @@ class SettingsCategoryView extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [for (final section in category.sections) _SectionCard(section: section, store: store)],
|
||||||
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.
|
/// One section: a small-caps header above an elevated card of field rows.
|
||||||
class _SectionCard extends StatelessWidget {
|
class _SectionCard extends StatelessWidget {
|
||||||
const _SectionCard({required this.section, required this.store});
|
const _SectionCard({required this.section, required this.store});
|
||||||
@@ -54,12 +127,7 @@ class _SectionCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||||
child: ClideText(
|
child: ClideText(section.label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||||
section.label.toUpperCase(),
|
|
||||||
fontSize: clideFontCaption,
|
|
||||||
color: tokens.sidebarSectionHeader,
|
|
||||||
fontFamily: clideMonoFamily,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ClideSurface(
|
ClideSurface(
|
||||||
// Card surface (surface.md): panelHeader resolves to the `surface`
|
// Card surface (surface.md): panelHeader resolves to the `surface`
|
||||||
@@ -71,10 +139,7 @@ class _SectionCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < section.fields.length; i++) ...[
|
for (var i = 0; i < section.fields.length; i++) ...[if (i > 0) const ClideDivider(), _FieldRow(field: section.fields[i], store: store)],
|
||||||
if (i > 0) const ClideDivider(),
|
|
||||||
_FieldRow(field: section.fields[i], store: store),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -399,8 +464,7 @@ class _ScopeTagState extends State<_ScopeTag> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _ns(String key, String fallback) =>
|
String _ns(String key, String fallback) => ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback);
|
||||||
ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback);
|
|
||||||
|
|
||||||
({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) {
|
({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
@@ -452,12 +516,7 @@ class _ScopeTagState extends State<_ScopeTag> {
|
|||||||
overlayBuilder: (ctx, c) => ClideMenu(
|
overlayBuilder: (ctx, c) => ClideMenu(
|
||||||
onClose: c.close,
|
onClose: c.close,
|
||||||
entries: [
|
entries: [
|
||||||
for (final layer in layers)
|
for (final layer in layers) ClideMenuItem(label: _appearance(layer).label, active: layer == current, onSelect: () => _moveTo(layer)),
|
||||||
ClideMenuItem(
|
|
||||||
label: _appearance(layer).label,
|
|
||||||
active: layer == current,
|
|
||||||
onSelect: () => _moveTo(layer),
|
|
||||||
),
|
|
||||||
const ClideMenuSeparator(),
|
const ClideMenuSeparator(),
|
||||||
ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset),
|
ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class _SettingsModalState extends State<SettingsModal> {
|
|||||||
/// The rail sets this in T-447.
|
/// The rail sets this in T-447.
|
||||||
String? _selectedId;
|
String? _selectedId;
|
||||||
|
|
||||||
|
/// Cross-category search query (T-450); non-empty swaps the panel to results.
|
||||||
|
String _query = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i = ClideKernel.of(context).i18n;
|
final i = ClideKernel.of(context).i18n;
|
||||||
@@ -82,11 +85,18 @@ class _SettingsModalState extends State<SettingsModal> {
|
|||||||
width: SettingsModal._railWidth,
|
width: SettingsModal._railWidth,
|
||||||
child: _CategoryRail(
|
child: _CategoryRail(
|
||||||
selectedId: _selectedId,
|
selectedId: _selectedId,
|
||||||
|
query: _query,
|
||||||
onSelect: (id) => setState(() => _selectedId = id),
|
onSelect: (id) => setState(() => _selectedId = id),
|
||||||
|
onQueryChanged: (q) => setState(() => _query = q),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const ClideDivider(axis: Axis.vertical),
|
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,
|
onTap: onTap,
|
||||||
builder: (ctx, hovered, pressed) => Container(
|
builder: (ctx, hovered, pressed) => Container(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||||
color: hovered ? tokens.listItemHoverBackground : null,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
|
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
|
/// Left rail — a cross-category search box atop the registered categories,
|
||||||
/// [SettingsRegistry]. Selecting one swaps the panel (T-447). The cross-category
|
/// data-driven from the [SettingsRegistry]. Selecting one swaps the panel
|
||||||
/// search box sits atop the rail in T-450.
|
/// (T-447); while searching, each row shows its match count and zero-match
|
||||||
|
/// rows dim (T-450).
|
||||||
class _CategoryRail extends StatelessWidget {
|
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).
|
/// The modal's chosen category id (null → the first category).
|
||||||
final String? selectedId;
|
final String? selectedId;
|
||||||
|
final String query;
|
||||||
final void Function(String id) onSelect;
|
final void Function(String id) onSelect;
|
||||||
|
final ValueChanged<String> onQueryChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final i = ClideKernel.of(context).i18n;
|
final i = ClideKernel.of(context).i18n;
|
||||||
final registry = ClideKernel.of(context).settingsRegistry;
|
final registry = ClideKernel.of(context).settingsRegistry;
|
||||||
|
final searching = query.trim().isNotEmpty;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: registry,
|
listenable: registry,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -179,7 +190,14 @@ class _CategoryRail extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
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(
|
child: ClideText(
|
||||||
i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'),
|
i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'),
|
||||||
fontSize: clideFontCaption,
|
fontSize: clideFontCaption,
|
||||||
@@ -193,7 +211,12 @@ class _CategoryRail extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (final c in categories)
|
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
|
/// 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 {
|
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 SettingsCategory category;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final int? matchCount;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
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(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
@@ -239,11 +265,11 @@ class _RailRow extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (category.iconName != null) ...[
|
if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)],
|
||||||
ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
],
|
),
|
||||||
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
|
/// Right panel — renders the selected category's schema (or the first
|
||||||
/// registered one) via [SettingsCategoryView]; the empty state shows when no
|
/// registered one) via [SettingsCategoryView]; the empty state shows when no
|
||||||
/// category is registered. The region recedes to panelBackground so the
|
/// category is registered. The region recedes to panelBackground (set by the
|
||||||
/// panelHeader cards pop (surface.md).
|
/// modal) so the panelHeader cards pop (surface.md).
|
||||||
class _SettingsPanel extends StatelessWidget {
|
class _SettingsPanel extends StatelessWidget {
|
||||||
const _SettingsPanel({required this.selectedId});
|
const _SettingsPanel({required this.selectedId});
|
||||||
|
|
||||||
@@ -267,19 +293,15 @@ class _SettingsPanel extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
|
||||||
final registry = ClideKernel.of(context).settingsRegistry;
|
final registry = ClideKernel.of(context).settingsRegistry;
|
||||||
return ColoredBox(
|
return ListenableBuilder(
|
||||||
color: tokens.panelBackground,
|
listenable: registry,
|
||||||
child: ListenableBuilder(
|
builder: (context, _) {
|
||||||
listenable: registry,
|
final categories = registry.categories;
|
||||||
builder: (context, _) {
|
if (categories.isEmpty) return const _EmptyState();
|
||||||
final categories = registry.categories;
|
final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first;
|
||||||
if (categories.isEmpty) return const _EmptyState();
|
return SettingsCategoryView(category: selected);
|
||||||
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" },
|
"modal.close.hint": { "translation": "Close settings without changing anything" },
|
||||||
"rail.header": { "translation": "Categories" },
|
"rail.header": { "translation": "Categories" },
|
||||||
"panel.empty": { "translation": "No settings categories are registered yet." },
|
"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.project": { "translation": "This project" },
|
||||||
"scope.always": { "translation": "All clide" },
|
"scope.always": { "translation": "All clide" },
|
||||||
"scope.default": { "translation": "Default" },
|
"scope.default": { "translation": "Default" },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -21,7 +22,10 @@ const _category = SettingsCategory(
|
|||||||
kind: SettingsFieldKind.select,
|
kind: SettingsFieldKind.select,
|
||||||
label: 'Level',
|
label: 'Level',
|
||||||
defaultValue: 'info',
|
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.name', kind: SettingsFieldKind.text, label: 'Name', defaultValue: ''),
|
||||||
SettingsField(key: 'app.demo.size', kind: SettingsFieldKind.number, label: 'Size', defaultValue: 4, min: 1, max: 8),
|
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();
|
await tester.pump();
|
||||||
expect(f.services.settings.get<String>('app.demo.level'), 'debug');
|
expect(f.services.settings.get<String>('app.demo.level'), 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('scope tag (T-449)', () {
|
group('scope tag (T-449)', () {
|
||||||
@@ -150,5 +153,20 @@ void main() {
|
|||||||
expect(find.text('OtherFlag'), findsOneWidget);
|
expect(find.text('OtherFlag'), findsOneWidget);
|
||||||
expect(find.text('Flag'), findsNothing);
|
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,8 +1,7 @@
|
|||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
SettingsCategory _cat(String id, {String? title, int priority = 0}) =>
|
SettingsCategory _cat(String id, {String? title, int priority = 0}) => SettingsCategory(id: id, title: title ?? id, priority: priority, sections: const []);
|
||||||
SettingsCategory(id: id, title: title ?? id, priority: priority, sections: const []);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('SettingsRegistry', () {
|
group('SettingsRegistry', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user