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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user