feat(settings): per-field scope tags + scope resolution (T-449)
Each settings field gains a scope tag showing where its value lives — folder = Project (.clide), globe = Always (~/.clide), circle-dashed = Default/unset — colour-coded (statusSuccess / statusWarning / muted) with a tooltip. Tapping opens a menu to move the value between the scopes the key supports, or reset to default; the tag's menu replaces the interim reset button. Backs it with scope-explicit SettingsStore access — rawAt / setAt / removeAt / effectiveLayer / writableLayers — over the two storage files (app ~/.clide, project .clide). ext.* keys layer project-over-app; app.*/project.* keys live only in their prefix's file, so their menu offers that one scope + reset. Tests: store scope ops (layering, reload, guards) and the tag (Default vs All-clide rendering, menu reset). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// i18n namespace shared with the settings modal shell.
|
||||
const _settingsNs = 'builtin.settings-ui';
|
||||
|
||||
/// 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),
|
||||
@@ -93,7 +96,6 @@ class _FieldRow extends StatelessWidget {
|
||||
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),
|
||||
@@ -115,12 +117,8 @@ class _FieldRow extends StatelessWidget {
|
||||
),
|
||||
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(),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_ScopeTag(field: field, store: store, effectiveValue: effective),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -373,30 +371,109 @@ class _FileControl extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
/// Per-field scope tag (T-449): a colour-coded glyph showing where the value
|
||||
/// lives — folder = Project (`.clide`), globe = Always (`~/.clide`),
|
||||
/// circle-dashed = Default/unset. Tapping opens a menu to move the value
|
||||
/// between the scopes the key supports, or reset it to default.
|
||||
///
|
||||
/// Storage layering follows the SettingsStore key prefix: `ext.*` keys may
|
||||
/// live in either file (project overrides app); `app.*`/`project.*` keys live
|
||||
/// only in their prefix's layer, so their menu offers that one scope + reset.
|
||||
class _ScopeTag extends StatefulWidget {
|
||||
const _ScopeTag({required this.field, required this.store, required this.effectiveValue});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final SettingsField field;
|
||||
final SettingsStore store;
|
||||
final Object? effectiveValue;
|
||||
|
||||
@override
|
||||
State<_ScopeTag> createState() => _ScopeTagState();
|
||||
}
|
||||
|
||||
class _ScopeTagState extends State<_ScopeTag> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _ns(String key, String fallback) =>
|
||||
ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback);
|
||||
|
||||
({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return switch (layer) {
|
||||
SettingsScope.project => (
|
||||
glyph: 'folder',
|
||||
color: tokens.statusSuccess,
|
||||
tip: _ns('scope.tip.project', 'Stored in this project (.clide)'),
|
||||
label: _ns('scope.project', 'This project'),
|
||||
),
|
||||
SettingsScope.app => (
|
||||
glyph: 'globe',
|
||||
color: tokens.statusWarning,
|
||||
tip: _ns('scope.tip.always', 'Stored for all clide (~/.clide)'),
|
||||
label: _ns('scope.always', 'All clide'),
|
||||
),
|
||||
_ => (
|
||||
glyph: 'circle-dashed',
|
||||
color: tokens.globalTextMuted,
|
||||
tip: _ns('scope.tip.default', 'Unset — using the default'),
|
||||
label: _ns('scope.default', 'Default'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Move the value into [layer], clearing it from the key's other layers.
|
||||
void _moveTo(SettingsScope layer) {
|
||||
final value = widget.store.get<Object>(widget.field.key) ?? widget.field.defaultValue;
|
||||
widget.store.setAt(layer, widget.field.key, value);
|
||||
for (final other in widget.store.writableLayers(widget.field.key)) {
|
||||
if (other != layer) widget.store.removeAt(other, widget.field.key);
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
for (final layer in widget.store.writableLayers(widget.field.key)) {
|
||||
widget.store.removeAt(layer, widget.field.key);
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
final current = widget.store.effectiveLayer(widget.field.key);
|
||||
final look = _appearance(current);
|
||||
final layers = widget.store.writableLayers(widget.field.key);
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final layer in layers)
|
||||
ClideMenuItem(
|
||||
label: _appearance(layer).label,
|
||||
active: layer == current,
|
||||
onSelect: () => _moveTo(layer),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.field.label} scope: ${look.label}',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
tooltip: look.tip,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ClideIcon(PhosphorIcons.byName(look.glyph), size: 15, color: look.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -227,18 +227,27 @@ class _RailRow extends StatelessWidget {
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? tokens.sidebarItemSelected : (hovered ? tokens.sidebarItemHover : null),
|
||||
border: Border(left: BorderSide(color: selected ? tokens.panelActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 7),
|
||||
// color: null => no paint (shows the modal surface behind the rail).
|
||||
color: selected ? tokens.sidebarItemSelected : (hovered ? tokens.sidebarItemHover : null),
|
||||
child: Row(
|
||||
children: [
|
||||
if (category.iconName != null) ...[
|
||||
ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
// Accent left-stripe; the 2px slot is reserved either way so the
|
||||
// row never shifts. Painted only when selected — no color literal.
|
||||
SizedBox(width: 2, child: selected ? ColoredBox(color: tokens.panelActiveBorder) : null),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||||
child: Row(
|
||||
children: [
|
||||
if (category.iconName != null) ...[
|
||||
ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,5 +3,12 @@
|
||||
"modal.close": { "translation": "Close" },
|
||||
"modal.close.hint": { "translation": "Close settings without changing anything" },
|
||||
"rail.header": { "translation": "Categories" },
|
||||
"panel.empty": { "translation": "No settings categories are registered yet." }
|
||||
"panel.empty": { "translation": "No settings categories are registered yet." },
|
||||
"scope.project": { "translation": "This project" },
|
||||
"scope.always": { "translation": "All clide" },
|
||||
"scope.default": { "translation": "Default" },
|
||||
"scope.reset": { "translation": "Reset to default" },
|
||||
"scope.tip.project": { "translation": "Stored in this project (.clide)" },
|
||||
"scope.tip.always": { "translation": "Stored for all clide (~/.clide)" },
|
||||
"scope.tip.default": { "translation": "Unset — using the default" }
|
||||
}
|
||||
|
||||
@@ -97,6 +97,85 @@ class SettingsStore extends ChangeNotifier {
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
// --- Scope-explicit access (per-field scope tags, T-449) ---------------
|
||||
//
|
||||
// [get]/[set] resolve a key by its prefix; the settings panel's scope tag
|
||||
// needs to read, write, and clear a key at a *specific* storage layer. There
|
||||
// are two storage files: app (`~/.clide`, "Always") and project (`.clide`,
|
||||
// "Project"). `ext.*` keys may live in either (project overrides app);
|
||||
// `app.*`/`project.*` keys live only in their prefix's layer.
|
||||
|
||||
/// Raw value stored in a specific storage layer (no cross-layer fallback).
|
||||
/// [SettingsScope.ext] is a key class, not a layer, so it returns null.
|
||||
Object? rawAt(SettingsScope layer, String key) => switch (layer) {
|
||||
SettingsScope.app => _appValues[key],
|
||||
SettingsScope.project => _projectValues[key],
|
||||
SettingsScope.ext => null,
|
||||
};
|
||||
|
||||
/// The storage layer currently supplying [key]'s value (project overrides app
|
||||
/// for `ext.*`), or null when unset (Default). Honors the key's prefix.
|
||||
SettingsScope? effectiveLayer(String key) {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
return _appValues.containsKey(key) ? SettingsScope.app : null;
|
||||
case SettingsScope.project:
|
||||
return _projectValues.containsKey(key) ? SettingsScope.project : null;
|
||||
case SettingsScope.ext:
|
||||
if (_projectValues.containsKey(key)) return SettingsScope.project;
|
||||
if (_appValues.containsKey(key)) return SettingsScope.app;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The storage layers [key] may be written to, by prefix: `app.*` → [app];
|
||||
/// `project.*` → [project]; `ext.*` → [project, app].
|
||||
List<SettingsScope> writableLayers(String key) {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
return const [SettingsScope.app];
|
||||
case SettingsScope.project:
|
||||
return const [SettingsScope.project];
|
||||
case SettingsScope.ext:
|
||||
return const [SettingsScope.project, SettingsScope.app];
|
||||
}
|
||||
}
|
||||
|
||||
/// Write [key] = [value] into a specific storage layer. Throws if the project
|
||||
/// layer is requested with no project open, or if [SettingsScope.ext] (not a
|
||||
/// layer) is passed.
|
||||
Future<void> setAt(SettingsScope layer, String key, Object? value) async {
|
||||
switch (layer) {
|
||||
case SettingsScope.app:
|
||||
_appValues[key] = value;
|
||||
await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir == null) {
|
||||
throw StateError('Cannot set project-scoped key with no project open: $key');
|
||||
}
|
||||
_projectValues[key] = value;
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
case SettingsScope.ext:
|
||||
throw ArgumentError('ext is a key class, not a storage layer');
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Remove [key] from a specific storage layer (no-op if absent).
|
||||
Future<void> removeAt(SettingsScope layer, String key) async {
|
||||
switch (layer) {
|
||||
case SettingsScope.app:
|
||||
if (_appValues.remove(key) != null) await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir != null && _projectValues.remove(key) != null) {
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
}
|
||||
case SettingsScope.ext:
|
||||
throw ArgumentError('ext is a key class, not a storage layer');
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readFile(File f) async {
|
||||
String txt;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user