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:
2026-06-17 12:18:38 +02:00
co-authored by Claude Opus 4.8
parent 6c6b0c731e
commit f56ad88439
11 changed files with 320 additions and 51 deletions
@@ -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),
),
),
),
+19 -10
View File
@@ -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" }
}
+79
View File
@@ -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 {