feat(settings): schema-driven settings engine (T-448)
The core of the settings panel (epic T-444). Subsystems describe a category as data — a SettingsCategory of carded SettingsSections of SettingsFields (toggle / select / text / number / opens-external-file), each bound to a SettingsStore key with help text, a default, and reset-to-default. Registration is declarative: a new SettingsCategoryContribution carries the category; the extension manager routes it into a new kernel SettingsRegistry (exposed on KernelServices), which the panel reads via ClideKernel. Adding a category is now pure data + a contribution — no widget code. SettingsCategoryView renders a category into carded sections per ui-design surface.md: panelHeader card fill, dividerColor border, inputs receding to panelBackground; select reuses the anchored-overlay menu, text/number commit on Enter or blur (numeric clamps to bounds). The modal panel now shows the selected/first registered category, falling back to the empty state. Tests: registry (sort / dedup / notify), contribution routing on activation, renderer (render + toggle/select write-through + reset), modal-with-category. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5894,3 +5894,9 @@ It should be visually flagged with a **red error border** on the card so it stan
|
||||
|
||||
**Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', NULL, '2026-06-17 07:41:47', '2026-06-17 07:41:47.267', '2026-06-17 07:41:47.267', NULL, 'aa0330e9165b9b9fc3f1c516abc3a07c', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'status', 'backlog', 'done', NULL, '2026-06-17 09:21:55', '2026-06-17 09:21:55.041', '2026-06-17 09:21:55.041', NULL, 'cd1f735c94e8bca9adcd7d0f9cead676', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 09:50:39', '2026-06-17 09:50:39.449', '2026-06-17 09:50:39.449', NULL, 'c94b125eb458442b012bfb1a86860ae8', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'backlog', 'ready', NULL, '2026-06-17 09:50:52', '2026-06-17 09:50:52.176', '2026-06-17 09:50:52.176', NULL, '6fe266e126c9edb86fa2386093f33bd4', 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', 'backlog', 'ready', NULL, '2026-06-17 09:50:52', '2026-06-17 09:50:52.665', '2026-06-17 09:50:52.665', NULL, 'e6a90cb467880a6a4e35e860ba416f99', 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', 'backlog', 'ready', NULL, '2026-06-17 09:50:53', '2026-06-17 09:50:53.074', '2026-06-17 09:50:53.074', NULL, 'aa94551043d54ff96acad1054a095b5b', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:06:59', '2026-06-17 10:06:59.713', '2026-06-17 10:06:59.713', NULL, '8d93c1cb133b480fdd5e8407977df764', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:07:00', '2026-06-17 10:07:00.144', '2026-06-17 10:07:00.144', NULL, '18b1ae31f194073a8f6dbb7ca9d58963', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -287,3 +287,11 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'T-459', '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, '44d5db70ef13ead00c4dcc437c387cec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91A7VEW3VCY7QX1END2Z3G', 'T-460', '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'e7e749d35dc413a560d3347be5f4a9ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91RWDWZPHFPJ72FHHHWS08', 'T-461', '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '4e3fba7aaad762b7b7cd854459cf664a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0TPYSWEM10RP0Q76XAP58', 'T-462', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4be5f7eb00ca81eb07015af05ed144d3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'T-463', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, 'd5d02453175411c13d21361b084494bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0WCPG458GF5FXTXM9VY98', 'T-464', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, 'c787ef61befc6698286ebdaf613e2a41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0X5WFME83GS0DKYG3WVJ8', 'T-465', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, '116b1a377950dfcef4e5b6db90141faa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'T-466', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, 'd25bc26eb29445fc35cf53f3d54049e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'T-467', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a47c675ca67b86ca3c8d4c1a8acc5b80', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'T-468', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'b988d9578b1ca079c040f07265a008b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA10DRQE3SED10JAW2CZDCR', 'T-469', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, '2a72e3e647afc1b5b235ab1a97d298f8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -7098,3 +7098,73 @@ It should be visually flagged with a **red error border** on the card so it stan
|
||||
|
||||
**Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:47.266', NULL, 'e4c3db66fb4ba42ba08896b97ef3ad89', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-17 09:21:55.041', NULL, '82094d971c514c6524dd0c2197d171a4', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 09:50:39.449', NULL, '6511e1989b01d0928e9128d691d7725c', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 09:50:52.176', NULL, '2e229c01974646fbb2c44e746d36171c', 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.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 09:50:52.665', NULL, '3e2912b50d8c6791faadbedae162f53b', 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.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 09:50:53.074', NULL, 'c8b9f21f3a69d28376846b3b6c9e6ecd', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
|
||||
|
||||
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
|
||||
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
|
||||
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
|
||||
|
||||
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime — so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
|
||||
|
||||
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels — that is this epic.
|
||||
|
||||
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
|
||||
|
||||
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4f70e7d5f578903c7ea33e488e5fa20a', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: fix concrete catalog gaps (theme-picker missing keys + diff has no catalog)', 'Two confirmed catalog-behind-code gaps found in the June 2026 audit. Small, isolated fix — do this first.
|
||||
|
||||
1) builtin.theme-picker requests two keys that are NOT in lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json, so they silently fall through to the inline placeholder:
|
||||
- section.appearance (lib/builtin/theme_picker/src/settings_view.dart:75)
|
||||
- toggle.highContrast (lib/builtin/theme_picker/src/settings_view.dart:83)
|
||||
Add both keys with their current placeholder text as the translation.
|
||||
|
||||
2) builtin.diff contributes a TabContribution with titleKey ''tab.title'' + i18nNamespace ''builtin.diff'' (lib/builtin/diff/src/extension.dart) but ships NO catalog file. There is no lib/kernel/src/i18n/catalog/builtin.diff_en_us.json. Create it with tab.title (= ''Diff'', matching the placeholder).
|
||||
|
||||
Acceptance: both theme-picker keys resolve from the catalog (not the placeholder); a builtin.diff catalog exists with tab.title; no ''missing key'' i18n warnings on boot for these namespaces.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, '9716086bea4e0317f6be4948f132d633', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.claude UI labels to the catalog (~35 strings)', 'Largest single offender in the audit: ~35 hardcoded user-facing strings in builtin.claude bypass i18n, despite builtin.claude_en_us.json already existing (currently tab.title + 4 orphaned status.* keys).
|
||||
|
||||
Route the hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(key, namespace: ''builtin.claude'', placeholder: <current literal>), and add the keys to the catalog. Hotspots from the audit include lib/builtin/claude/src/prompt_card.dart (e.g. :314 ''Review your answers'', :372 ''Review'') and the permission/AskUserQuestion interaction widgets — sweep the whole extension, not just those lines.
|
||||
|
||||
Watch the interaction-zone widgets (D-78): keep them display-only; the i18n change is label-only, no behavioural change.
|
||||
|
||||
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/claude/ (single-char glyphs like ''<'' / ''+'' used as icons are exempt — note any left); all new keys present in the catalog; existing orphaned status.* keys either wired up or removed.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, '661934ec63b9365ac504af912d421b5b', 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 ('06FDA0X5WFME83GS0DKYG3WVJ8', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.welcome UI labels to the catalog (~15 strings)', 'builtin.welcome ships a full 10-key catalog (title, subtitle, open-project, open-project.hint, etc.) that is entirely orphaned — the view hardcodes ~15 strings instead. Wire the existing catalog up and add any missing keys.
|
||||
|
||||
Hotspots from the audit: lib/builtin/welcome/src/welcome_view.dart — e.g. :108 ''TIPS'', :173 ''START'', :258 ''RECENT'', plus the title/subtitle/open-project block the catalog already has translations for.
|
||||
|
||||
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/welcome/; the pre-existing builtin.welcome catalog keys are actually consumed (no orphans); section headers added as keys.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, 'cd2edbe49bfb6af002467e5a7da82abe', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate git + diff panel labels to the catalog (~16 strings)', 'builtin.git (~9 hardcoded) and builtin.diff (~7 hardcoded) panel/view labels bypass i18n. builtin.git has a tab.title-only catalog; builtin.diff''s catalog is created by the gaps task (T-463) — extend both.
|
||||
|
||||
Hotspots from the audit:
|
||||
- lib/builtin/git/src/git_status_item.dart:167 ''Switch branch'', :179 ''Loading…''
|
||||
- lib/builtin/git/src/git_panel_view.dart:94 ''Nothing to commit, working tree clean.''
|
||||
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
|
||||
|
||||
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, '1b085e7af9f7720886f23f25a2dc8280', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
|
||||
|
||||
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
|
||||
|
||||
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a5e3b32ca26332d6bf7fbcfb2c52555e', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
|
||||
|
||||
Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) — wire their hardcoded literals up and consume the orphans where they match.
|
||||
|
||||
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
|
||||
|
||||
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'ff32568083c6143d7ea8472c204cbb6e', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: route framework-level labels in lib/widgets + lib/kernel (~10 strings)', '~9 hardcoded user-facing strings in lib/widgets/ and ~1 in lib/kernel/ live outside any extension, so they have no obvious i18n namespace. Resolve the namespace question, then migrate.
|
||||
|
||||
Hotspots from the audit:
|
||||
- lib/widgets/src/ex_line_overlay.dart:163 '':'' , :183 ''Not an editor command'' (these are vim ex-line — may belong to builtin.vim''s namespace rather than a generic one)
|
||||
- lib/widgets/src/multitab_pane.dart:358 ''+'' (likely an icon glyph — exempt if so)
|
||||
|
||||
Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left.
|
||||
|
||||
Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'backlog', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, 'f284696fc306e1e2452f89c5fa8b7057', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 10:06:59.713', NULL, 'f0a3bc1d6ab084ce36fb529cadca2d52', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:07:00.144', NULL, '67beda85d5d212fb1f3493e575a2d338', 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);
|
||||
|
||||
@@ -24,6 +24,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
category rail and scrolling carded panel that later work fills in; closes on
|
||||
✕, Esc, or a barrier tap. (T-445)
|
||||
|
||||
- **Schema-driven settings engine.** Subsystems register a `SettingsCategory`
|
||||
(a `SettingsCategoryContribution` routed into the kernel `SettingsRegistry`);
|
||||
the panel renders it as carded sections of field rows — toggle, select,
|
||||
text, number, and an "opens external file" button — each bound to a
|
||||
`SettingsStore` key with help text and reset-to-default. Inputs recede to
|
||||
`panelBackground` inside the elevated `panelHeader` cards (ui-design
|
||||
surface.md). Categories are data; registering one surfaces a new tab. (T-448)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/settings_category_view.dart';
|
||||
export 'src/settings_modal.dart';
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The schema-driven field renderer (T-448, epic T-444): turns a
|
||||
/// [SettingsCategory] into carded sections of field rows. Each field binds to a
|
||||
/// `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.
|
||||
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
|
||||
class SettingsCategoryView extends StatelessWidget {
|
||||
const SettingsCategoryView({super.key, required this.category});
|
||||
|
||||
final SettingsCategory category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ClideKernel.of(context).settings;
|
||||
// Re-read on every settings change so edits (and resets) reflect at once.
|
||||
return ListenableBuilder(
|
||||
listenable: store,
|
||||
builder: (context, _) => SingleChildScrollView(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
|
||||
final SettingsSection section;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: ClideText(
|
||||
section.label.toUpperCase(),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
ClideSurface(
|
||||
// Card surface (surface.md): panelHeader resolves to the `surface`
|
||||
// palette key (the elevated card tone); inputs inside recede to
|
||||
// panelBackground.
|
||||
color: tokens.panelHeader,
|
||||
border: tokens.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label/help block + the field's control + a reset affordance.
|
||||
class _FieldRow extends StatelessWidget {
|
||||
const _FieldRow({required this.field, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final raw = store.get<Object>(field.key);
|
||||
final effective = raw ?? field.defaultValue;
|
||||
final canReset = field.defaultValue != null && effective != field.defaultValue;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(field.label, color: tokens.globalForeground),
|
||||
if (field.help != null && field.help!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: ClideText(field.help!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_Control(field: field, value: effective, store: store),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: canReset
|
||||
? _ResetButton(onTap: () => store.set<Object?>(field.key, field.defaultValue))
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches to the control widget for the field's [SettingsFieldKind].
|
||||
class _Control extends StatelessWidget {
|
||||
const _Control({required this.field, required this.value, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final Object? value;
|
||||
final SettingsStore store;
|
||||
|
||||
void _set(Object? v) => store.set<Object?>(field.key, v);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (field.kind) {
|
||||
case SettingsFieldKind.toggle:
|
||||
return _ToggleControl(checked: value == true, onChanged: _set);
|
||||
case SettingsFieldKind.select:
|
||||
return _SelectControl(field: field, value: value?.toString(), onPick: _set);
|
||||
case SettingsFieldKind.text:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, onCommit: _set);
|
||||
case SettingsFieldKind.number:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set);
|
||||
case SettingsFieldKind.file:
|
||||
return _FileControl(field: field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox toggle (mirrors the theme-picker high-contrast box).
|
||||
class _ToggleControl extends StatelessWidget {
|
||||
const _ToggleControl({required this.checked, required this.onChanged});
|
||||
|
||||
final bool checked;
|
||||
final void Function(bool value) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: checked,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => onChanged(!checked),
|
||||
builder: (ctx, hovered, pressed) => Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tokens.buttonBackground : (hovered ? tokens.listItemHoverBackground : null),
|
||||
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: checked ? ClideIcon(const CheckIcon(), size: 11, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum picker — current value on an anchored popover of options.
|
||||
class _SelectControl extends StatefulWidget {
|
||||
const _SelectControl({required this.field, required this.value, required this.onPick});
|
||||
|
||||
final SettingsField field;
|
||||
final String? value;
|
||||
final void Function(String value) onPick;
|
||||
|
||||
@override
|
||||
State<_SelectControl> createState() => _SelectControlState();
|
||||
}
|
||||
|
||||
class _SelectControlState extends State<_SelectControl> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _label {
|
||||
for (final o in widget.field.options) {
|
||||
if (o.value == widget.value) return o.label;
|
||||
}
|
||||
return widget.value ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.field.options)
|
||||
ClideMenuItem(
|
||||
label: o.label,
|
||||
active: o.value == widget.value,
|
||||
semanticLabel: '${widget.field.label}: ${o.label}',
|
||||
onSelect: () => widget.onPick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.field.label}: $_label. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(_label, color: tokens.globalForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline text / numeric editor. Commits on Enter and on focus loss; numeric
|
||||
/// fields parse + clamp to the field's bounds, ignoring unparseable input.
|
||||
class _EditControl extends StatefulWidget {
|
||||
const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit});
|
||||
|
||||
final SettingsField field;
|
||||
final String value;
|
||||
final bool numeric;
|
||||
final void Function(Object value) onCommit;
|
||||
|
||||
@override
|
||||
State<_EditControl> createState() => _EditControlState();
|
||||
}
|
||||
|
||||
class _EditControlState extends State<_EditControl> {
|
||||
late final TextEditingController _controller = TextEditingController(text: widget.value);
|
||||
late final FocusNode _focus = FocusNode(debugLabel: 'settings-${widget.field.key}');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() {
|
||||
if (!_focus.hasFocus) _commit();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_EditControl old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Reflect external changes (reset, scope flip) only when not being edited.
|
||||
if (!_focus.hasFocus && widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final text = _controller.text.trim();
|
||||
if (widget.numeric) {
|
||||
final n = num.tryParse(text);
|
||||
if (n == null) {
|
||||
_controller.text = widget.value; // revert unparseable input
|
||||
return;
|
||||
}
|
||||
var clamped = n;
|
||||
final min = widget.field.min;
|
||||
final max = widget.field.max;
|
||||
if (min != null && clamped < min) clamped = min;
|
||||
if (max != null && clamped > max) clamped = max;
|
||||
// Preserve int vs double per the parsed text.
|
||||
final out = clamped == clamped.roundToDouble() && !text.contains('.') ? clamped.toInt() : clamped;
|
||||
_controller.text = '$out';
|
||||
widget.onCommit(out);
|
||||
} else {
|
||||
widget.onCommit(text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
textField: true,
|
||||
label: widget.field.label,
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
width: widget.numeric ? 88 : 180,
|
||||
height: 26,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Opens external file" affordance — a button that runs the field's command.
|
||||
class _FileControl extends StatelessWidget {
|
||||
const _FileControl({required this.field});
|
||||
|
||||
final SettingsField field;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commands = ClideKernel.of(context).commands;
|
||||
return ClideButton(
|
||||
label: field.label,
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset-to-default control — a circular-arrow icon shown when the value
|
||||
/// differs from the schema default.
|
||||
class _ResetButton extends StatelessWidget {
|
||||
const _ResetButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Reset to default',
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
tooltip: 'Reset to default',
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: ClideIcon(
|
||||
PhosphorIcons.byName('arrow-counter-clockwise'),
|
||||
size: 14,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:clide/builtin/settings_ui/src/settings_category_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,19 +8,19 @@ import 'package:flutter/widgets.dart';
|
||||
///
|
||||
/// A centered modal over the dimmed app (hosted by [DialogHost] via
|
||||
/// `ctx.dialog.show`), built from the `modalSurface*` tokens (D-7, no
|
||||
/// Material). It frames the two regions the rest of the epic fills in:
|
||||
/// Material). It frames the two regions the epic fills in:
|
||||
///
|
||||
/// - the **category rail** on the left (navigation lands in T-447; the
|
||||
/// list is data-driven from the schemas each subsystem registers), and
|
||||
/// - the **scrolling carded panel** on the right (the schema field
|
||||
/// renderer is T-448).
|
||||
/// - the **category rail** on the left (interactive navigation lands in
|
||||
/// T-447; the list is data-driven from the registered schemas), and
|
||||
/// - the **scrolling carded panel** on the right (the schema field renderer
|
||||
/// is [SettingsCategoryView], T-448).
|
||||
///
|
||||
/// Until any category registers a schema the panel shows its empty state —
|
||||
/// that is the correct runtime state, not a placeholder. Dismiss with the
|
||||
/// close button, Esc, or a barrier tap (the last handled by [DialogHost]).
|
||||
/// Categories come from the kernel [SettingsRegistry]; until one registers a
|
||||
/// schema the panel shows its empty state. Dismiss with the close button, Esc,
|
||||
/// or a barrier tap (the last handled by [DialogHost]).
|
||||
///
|
||||
/// Wireframe: `docs/design/wireframes/settings/settings-screen.png`.
|
||||
class SettingsModal extends StatelessWidget {
|
||||
class SettingsModal extends StatefulWidget {
|
||||
const SettingsModal({super.key, required this.onDismiss});
|
||||
|
||||
/// Closes the modal. Wired to the dialog router's `dismiss` by the
|
||||
@@ -34,17 +35,26 @@ class SettingsModal extends StatelessWidget {
|
||||
static const double _height = 560;
|
||||
static const double _railWidth = 196;
|
||||
|
||||
@override
|
||||
State<SettingsModal> createState() => _SettingsModalState();
|
||||
}
|
||||
|
||||
class _SettingsModalState extends State<SettingsModal> {
|
||||
/// Selected category id; null falls back to the first registered category.
|
||||
/// The rail sets this in T-447.
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = ClideKernel.of(context).i18n;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final title = i.string('modal.title', namespace: ns, placeholder: 'Settings');
|
||||
final title = i.string('modal.title', namespace: SettingsModal.ns, placeholder: 'Settings');
|
||||
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onDismiss();
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
@@ -54,23 +64,23 @@ class SettingsModal extends StatelessWidget {
|
||||
label: title,
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: _width,
|
||||
height: _height,
|
||||
width: SettingsModal._width,
|
||||
height: SettingsModal._height,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(title: title, onClose: onDismiss),
|
||||
_Header(title: title, onClose: widget.onDismiss),
|
||||
const ClideDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: _railWidth, child: const _CategoryRail()),
|
||||
const SizedBox(width: SettingsModal._railWidth, child: _CategoryRail()),
|
||||
const ClideDivider(axis: Axis.vertical),
|
||||
const Expanded(child: _SettingsPanel()),
|
||||
Expanded(child: _SettingsPanel(selectedId: _selectedId)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -128,7 +138,10 @@ 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),
|
||||
),
|
||||
),
|
||||
@@ -136,8 +149,8 @@ class _CloseButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Left rail. The category list is populated from registered schemas in
|
||||
/// T-447; for now it shows only its section header.
|
||||
/// Left rail. The interactive category list lands in T-447; for now it shows
|
||||
/// only its section header.
|
||||
class _CategoryRail extends StatelessWidget {
|
||||
const _CategoryRail();
|
||||
|
||||
@@ -162,10 +175,36 @@ class _CategoryRail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right panel. The schema-driven field renderer fills this in T-448; with
|
||||
/// no category registered yet it shows the empty state.
|
||||
/// 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).
|
||||
class _SettingsPanel extends StatelessWidget {
|
||||
const _SettingsPanel();
|
||||
const _SettingsPanel({required this.selectedId});
|
||||
|
||||
final String? selectedId;
|
||||
|
||||
@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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:clide/kernel/src/settings_schema.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// One atom contributed by a [ClideExtension]. Extensions declare N of
|
||||
@@ -138,3 +139,13 @@ class LayoutSlot {
|
||||
final double? maxSize;
|
||||
final bool visible;
|
||||
}
|
||||
|
||||
/// A category in the Settings panel (epic T-444). The kernel routes it into the
|
||||
/// `SettingsRegistry`; `builtin.settings-ui` renders its [SettingsCategory]
|
||||
/// schema into carded field rows (T-448). Categories are data — register one to
|
||||
/// surface a new settings tab.
|
||||
class SettingsCategoryContribution extends ContributionPoint {
|
||||
const SettingsCategoryContribution({required super.id, required this.category});
|
||||
|
||||
final SettingsCategory category;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export 'src/log.dart';
|
||||
export 'src/file_log_sink.dart';
|
||||
export 'src/watchdog.dart';
|
||||
export 'src/settings.dart';
|
||||
export 'src/settings_schema.dart';
|
||||
export 'src/settings_registry.dart';
|
||||
export 'src/facade.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/commands/keybindings.dart';
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/settings_registry.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -55,6 +56,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.ipc,
|
||||
required this.settingsRegistry,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -81,6 +83,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final DaemonClient ipc;
|
||||
final SettingsRegistry settingsRegistry;
|
||||
|
||||
final Map<String, ClideExtension> _known = {};
|
||||
final Set<String> _activated = {};
|
||||
@@ -256,6 +259,9 @@ class ExtensionManager extends ChangeNotifier {
|
||||
// Presets are consumed by the default-layout extension in its
|
||||
// own activate(); nothing for the kernel to do here.
|
||||
break;
|
||||
case SettingsCategoryContribution s:
|
||||
// register() throws on a duplicate id, rolling activation back.
|
||||
settingsRegistry.register(s.category);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +282,8 @@ class ExtensionManager extends ChangeNotifier {
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
break;
|
||||
case SettingsCategoryContribution s:
|
||||
settingsRegistry.unregister(s.category.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'package:clide/kernel/src/recent_files.dart';
|
||||
import 'package:clide/kernel/src/scheduler.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/settings_registry.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/loader.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
@@ -81,10 +82,14 @@ class KernelServices {
|
||||
required this.textZoom,
|
||||
required this.toast,
|
||||
required this.logRing,
|
||||
required this.settingsRegistry,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final SettingsStore settings;
|
||||
|
||||
/// Categories registered for the Settings panel (T-444).
|
||||
final SettingsRegistry settingsRegistry;
|
||||
final DaemonBus events;
|
||||
final MessageBus messages;
|
||||
|
||||
@@ -155,6 +160,7 @@ class KernelServices {
|
||||
|
||||
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
|
||||
await settings.load();
|
||||
final settingsRegistry = SettingsRegistry();
|
||||
|
||||
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
|
||||
for (final ns in preloadNamespaces) {
|
||||
@@ -238,6 +244,7 @@ class KernelServices {
|
||||
focus: focus,
|
||||
project: project,
|
||||
ipc: ipc,
|
||||
settingsRegistry: settingsRegistry,
|
||||
);
|
||||
|
||||
if (autoStartDaemonClient) {
|
||||
@@ -248,6 +255,7 @@ class KernelServices {
|
||||
log: log,
|
||||
logRing: logRing,
|
||||
settings: settings,
|
||||
settingsRegistry: settingsRegistry,
|
||||
events: events,
|
||||
messages: messages,
|
||||
filterStates: filterStates,
|
||||
@@ -287,6 +295,7 @@ class KernelServices {
|
||||
await ipc.stop();
|
||||
ipc.dispose();
|
||||
settings.dispose();
|
||||
settingsRegistry.dispose();
|
||||
theme.dispose();
|
||||
panels.dispose();
|
||||
arrangement.dispose();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:clide/kernel/src/settings_schema.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Holds the [SettingsCategory] schemas subsystems register against the kernel
|
||||
/// (via `SettingsCategoryContribution`, routed by the extension manager). The
|
||||
/// settings panel reads this to build its rail + panels (T-447/T-448) and
|
||||
/// rebuilds when the set changes.
|
||||
class SettingsRegistry extends ChangeNotifier {
|
||||
final Map<String, SettingsCategory> _byId = <String, SettingsCategory>{};
|
||||
|
||||
/// Registered categories, sorted by (priority, then case-insensitive title).
|
||||
List<SettingsCategory> get categories {
|
||||
final list = _byId.values.toList()
|
||||
..sort((a, b) {
|
||||
final p = a.priority.compareTo(b.priority);
|
||||
return p != 0 ? p : a.title.toLowerCase().compareTo(b.title.toLowerCase());
|
||||
});
|
||||
return List.unmodifiable(list);
|
||||
}
|
||||
|
||||
SettingsCategory? byId(String id) => _byId[id];
|
||||
|
||||
/// Register a category. Throws on a duplicate id — a collision is a wiring
|
||||
/// bug that should roll the contributing extension's activation back, the
|
||||
/// same way duplicate command/slot ids do.
|
||||
void register(SettingsCategory category) {
|
||||
if (_byId.containsKey(category.id)) {
|
||||
throw StateError('duplicate settings category id: ${category.id}');
|
||||
}
|
||||
_byId[category.id] = category;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void unregister(String id) {
|
||||
if (_byId.remove(id) != null) notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// Schema model for the settings panel (T-448, epic T-444).
|
||||
///
|
||||
/// Pure data — no Flutter imports — so any subsystem can declare a category
|
||||
/// without depending on the widget layer. The settings-ui renderer turns a
|
||||
/// [SettingsCategory] into carded sections of field rows; each field binds to
|
||||
/// a `SettingsStore` key and is read/written through the store.
|
||||
library;
|
||||
|
||||
/// The control a [SettingsField] renders as.
|
||||
enum SettingsFieldKind {
|
||||
/// On/off boolean.
|
||||
toggle,
|
||||
|
||||
/// One value chosen from [SettingsField.options].
|
||||
select,
|
||||
|
||||
/// Free-text input.
|
||||
text,
|
||||
|
||||
/// Numeric input (optionally bounded by [SettingsField.min]/[max]).
|
||||
number,
|
||||
|
||||
/// A row that opens an external file/editor (e.g. `.editorconfig`) instead
|
||||
/// of editing a value inline — the action is a command id, keeping the
|
||||
/// schema widget-free.
|
||||
file,
|
||||
}
|
||||
|
||||
/// One choice in a [SettingsFieldKind.select] field.
|
||||
class SettingsOption {
|
||||
const SettingsOption({required this.value, required this.label});
|
||||
|
||||
/// Stored value.
|
||||
final String value;
|
||||
|
||||
/// Human label shown in the picker.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// One editable setting. [key] is a `SettingsStore` key — its `app.`/
|
||||
/// `project.`/`ext.` prefix determines the scope (and the per-field scope tag,
|
||||
/// T-449). The renderer reads the current value with `store.get`, falling back
|
||||
/// to [defaultValue] when unset, and writes edits with `store.set`.
|
||||
class SettingsField {
|
||||
const SettingsField({
|
||||
required this.key,
|
||||
required this.kind,
|
||||
required this.label,
|
||||
this.help,
|
||||
this.defaultValue,
|
||||
this.options = const [],
|
||||
this.min,
|
||||
this.max,
|
||||
this.fileCommand,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final SettingsFieldKind kind;
|
||||
final String label;
|
||||
|
||||
/// Optional one-line help shown under the label.
|
||||
final String? help;
|
||||
|
||||
/// Value shown / restored when the key is unset (reset-to-default target).
|
||||
final Object? defaultValue;
|
||||
|
||||
/// Choices for [SettingsFieldKind.select].
|
||||
final List<SettingsOption> options;
|
||||
|
||||
/// Optional inclusive bounds for [SettingsFieldKind.number].
|
||||
final num? min;
|
||||
final num? max;
|
||||
|
||||
/// For [SettingsFieldKind.file]: the command id the row's button invokes.
|
||||
final String? fileCommand;
|
||||
}
|
||||
|
||||
/// A carded group of fields (surface.md "sectioned cards"). [label] is the
|
||||
/// small-caps header rendered just above the card.
|
||||
class SettingsSection {
|
||||
const SettingsSection({required this.label, required this.fields});
|
||||
|
||||
final String label;
|
||||
final List<SettingsField> fields;
|
||||
}
|
||||
|
||||
/// One settings category — a rail entry (T-447) plus the sections its panel
|
||||
/// shows. Subsystems register these via `SettingsCategoryContribution`; the
|
||||
/// renderer draws them.
|
||||
class SettingsCategory {
|
||||
const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
/// Phosphor glyph name, resolved via `PhosphorIcons.byName` at render (T-314).
|
||||
final String? iconName;
|
||||
|
||||
/// Rail ordering — lower sorts first; ties broken by [title].
|
||||
final int priority;
|
||||
|
||||
final List<SettingsSection> sections;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
const _category = SettingsCategory(
|
||||
id: 'demo',
|
||||
title: 'Demo',
|
||||
iconName: 'gear',
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Group',
|
||||
fields: [
|
||||
SettingsField(key: 'app.demo.flag', kind: SettingsFieldKind.toggle, label: 'Flag', defaultValue: false),
|
||||
SettingsField(
|
||||
key: 'app.demo.level',
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Level',
|
||||
defaultValue: 'info',
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// Tiny extension that registers [_category] via the contribution.
|
||||
class _DemoSettingsExt extends ClideExtension {
|
||||
@override
|
||||
String get id => 'test.demo-settings';
|
||||
@override
|
||||
String get title => 'Demo settings';
|
||||
@override
|
||||
String get version => '1.0.0';
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [SettingsCategoryContribution(id: 'demo', category: _category)];
|
||||
}
|
||||
|
||||
Widget _bounded(Widget child) => SizedBox(width: 620, height: 520, child: child);
|
||||
|
||||
Finder get _toggle => find.byWidgetPredicate((w) => w is Semantics && w.properties.checked != null);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
});
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
group('SettingsCategoryContribution routing', () {
|
||||
test('an activated extension registers its category in the registry', () async {
|
||||
f.services.extensions.register(_DemoSettingsExt());
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.settingsRegistry.byId('demo')?.title, 'Demo');
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsCategoryView', () {
|
||||
testWidgets('renders the section header, field labels, and current values', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
expect(find.text('GROUP'), findsOneWidget); // small-caps section header
|
||||
expect(find.text('Flag'), findsOneWidget);
|
||||
expect(find.text('Level'), findsOneWidget);
|
||||
expect(find.text('Name'), findsOneWidget);
|
||||
expect(find.text('Size'), findsOneWidget);
|
||||
// Select shows the default option's label when the key is unset.
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping the toggle writes the flipped value to the store', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
expect(_toggle, findsOneWidget);
|
||||
await tester.tap(_toggle);
|
||||
await tester.pump();
|
||||
expect(f.services.settings.get<bool>('app.demo.flag'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('picking a select option writes it to the store', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
await tester.tap(find.bySemanticsLabel(RegExp(r'^Level:')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Debug'));
|
||||
await tester.pump();
|
||||
expect(f.services.settings.get<String>('app.demo.level'), 'debug');
|
||||
});
|
||||
|
||||
testWidgets('reset control appears for a non-default value and restores the default', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.set('app.demo.flag', true));
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
final reset = find.bySemanticsLabel('Reset to default');
|
||||
expect(reset, findsOneWidget);
|
||||
await tester.tap(reset);
|
||||
await tester.pump();
|
||||
expect(f.services.settings.get<bool>('app.demo.flag'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsModal with a registered category', () {
|
||||
testWidgets('renders the category instead of the empty state', (tester) async {
|
||||
f.services.settingsRegistry.register(_category);
|
||||
await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {})));
|
||||
expect(find.text('Flag'), findsOneWidget);
|
||||
expect(find.text('No settings categories are registered yet.'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 []);
|
||||
|
||||
void main() {
|
||||
group('SettingsRegistry', () {
|
||||
test('register exposes categories sorted by (priority, title)', () {
|
||||
final r = SettingsRegistry();
|
||||
r.register(_cat('b', title: 'Beta', priority: 10));
|
||||
r.register(_cat('a', title: 'Alpha', priority: 10));
|
||||
r.register(_cat('z', title: 'Zeta', priority: 0));
|
||||
|
||||
expect(r.categories.map((c) => c.id).toList(), ['z', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('byId resolves a registered category', () {
|
||||
final r = SettingsRegistry()..register(_cat('editor', title: 'Editor'));
|
||||
expect(r.byId('editor')?.title, 'Editor');
|
||||
expect(r.byId('missing'), isNull);
|
||||
});
|
||||
|
||||
test('duplicate id throws (rolls activation back)', () {
|
||||
final r = SettingsRegistry()..register(_cat('dup'));
|
||||
expect(() => r.register(_cat('dup')), throwsStateError);
|
||||
});
|
||||
|
||||
test('notifies on register and unregister', () {
|
||||
final r = SettingsRegistry();
|
||||
var n = 0;
|
||||
r.addListener(() => n++);
|
||||
r.register(_cat('x'));
|
||||
expect(n, 1);
|
||||
r.unregister('x');
|
||||
expect(n, 2);
|
||||
expect(r.categories, isEmpty);
|
||||
});
|
||||
|
||||
test('unregister of an unknown id is a no-op (no notify)', () {
|
||||
final r = SettingsRegistry();
|
||||
var n = 0;
|
||||
r.addListener(() => n++);
|
||||
r.unregister('nope');
|
||||
expect(n, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user