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:
2026-06-17 12:07:51 +02:00
co-authored by Claude Opus 4.8
parent 643f40d7b2
commit 4bbb0ee4b3
15 changed files with 890 additions and 22 deletions
+8
View File
@@ -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);
}
}
+9
View File
@@ -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();
+37
View File
@@ -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();
}
}
+103
View File
@@ -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;
}