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:
@@ -102,14 +102,28 @@ void main() {
|
||||
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 {
|
||||
});
|
||||
|
||||
group('scope tag (T-449)', () {
|
||||
testWidgets('an unset field shows the Default scope tag', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
expect(find.bySemanticsLabel('Size scope: Default'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a value stored at app scope shows the All clide tag', (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);
|
||||
expect(find.bySemanticsLabel('Flag scope: All clide'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the scope menu resets the value to default', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.set('app.demo.flag', true));
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
|
||||
await tester.tap(find.bySemanticsLabel('Flag scope: All clide'));
|
||||
await tester.pump();
|
||||
expect(f.services.settings.get<bool>('app.demo.flag'), isFalse);
|
||||
await tester.tap(find.text('Reset to default'));
|
||||
await tester.pump();
|
||||
expect(f.services.settings.effectiveLayer('app.demo.flag'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory appDir;
|
||||
late Directory projDir;
|
||||
late SettingsStore store;
|
||||
|
||||
setUp(() async {
|
||||
appDir = await Directory.systemTemp.createTemp('clide_app_');
|
||||
projDir = await Directory.systemTemp.createTemp('clide_proj_');
|
||||
store = SettingsStore(appDir: appDir, projectDir: projDir);
|
||||
await store.load();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
store.dispose();
|
||||
await appDir.delete(recursive: true);
|
||||
await projDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
group('SettingsStore scope-explicit access (T-449)', () {
|
||||
test('writableLayers honours the key prefix', () {
|
||||
expect(store.writableLayers('app.x'), [SettingsScope.app]);
|
||||
expect(store.writableLayers('project.x'), [SettingsScope.project]);
|
||||
expect(store.writableLayers('ext.x'), [SettingsScope.project, SettingsScope.app]);
|
||||
});
|
||||
|
||||
test('ext.* value: project overrides app, and effectiveLayer tracks it', () async {
|
||||
await store.setAt(SettingsScope.app, 'ext.k', 'a');
|
||||
expect(store.rawAt(SettingsScope.app, 'ext.k'), 'a');
|
||||
expect(store.effectiveLayer('ext.k'), SettingsScope.app);
|
||||
expect(store.get<String>('ext.k'), 'a');
|
||||
|
||||
await store.setAt(SettingsScope.project, 'ext.k', 'p');
|
||||
expect(store.effectiveLayer('ext.k'), SettingsScope.project);
|
||||
expect(store.get<String>('ext.k'), 'p');
|
||||
|
||||
await store.removeAt(SettingsScope.project, 'ext.k');
|
||||
expect(store.effectiveLayer('ext.k'), SettingsScope.app);
|
||||
expect(store.get<String>('ext.k'), 'a');
|
||||
|
||||
await store.removeAt(SettingsScope.app, 'ext.k');
|
||||
expect(store.effectiveLayer('ext.k'), isNull);
|
||||
expect(store.get<String>('ext.k'), isNull);
|
||||
});
|
||||
|
||||
test('writes survive a reload from disk', () async {
|
||||
await store.setAt(SettingsScope.project, 'ext.k', 'p');
|
||||
await store.setAt(SettingsScope.app, 'app.y', 1);
|
||||
await store.load();
|
||||
expect(store.rawAt(SettingsScope.project, 'ext.k'), 'p');
|
||||
expect(store.get<int>('app.y'), 1);
|
||||
});
|
||||
|
||||
test('setAt(project) with no project open throws', () async {
|
||||
final noProj = SettingsStore(appDir: appDir);
|
||||
await noProj.load();
|
||||
expect(() => noProj.setAt(SettingsScope.project, 'project.x', 1), throwsStateError);
|
||||
noProj.dispose();
|
||||
});
|
||||
|
||||
test('ext is a key class, not a storage layer', () {
|
||||
expect(() => store.setAt(SettingsScope.ext, 'ext.k', 1), throwsArgumentError);
|
||||
expect(() => store.removeAt(SettingsScope.ext, 'ext.k'), throwsArgumentError);
|
||||
expect(store.rawAt(SettingsScope.ext, 'ext.k'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user