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
@@ -5902,3 +5902,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:07:00', '2026-06-17 10:07:00.144', '2026-06-17 10:07:00.144', NULL, '18b1ae31f194073a8f6dbb7ca9d58963', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.725', '2026-06-17 10:09:41.725', NULL, 'af6eaeeae0e9e27a80ad26f9ab78c7c2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.911', '2026-06-17 10:09:41.911', NULL, '5588d681ec1d926adbcdc378ef37eb47', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.353', '2026-06-17 10:16:22.353', NULL, '290e5da4942b778213253ef665c3a4b1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.702', '2026-06-17 10:16:22.702', NULL, 'faabfb970849aacda3919f1501ba7bfd', 2) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -7170,3 +7170,5 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:07:00.144', NULL, '67beda85d5d212fb1f3493e575a2d338', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:09:41.725', NULL, 'ccd735c9480310dd9a849881c39ce100', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:09:41.911', NULL, 'db4e636e73d5ca1486f9416f47515e03', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:16:22.353', NULL, 'a8d1080773ce726edebb44a0d2d56023', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:16:22.701', NULL, 'e8b9bc4e621f5f39d6bba4904eadf0ac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+8
View File
@@ -36,6 +36,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
categories (icon + title, data-driven from the registry) with an accent
left-stripe + surfaceHi selection; picking one swaps the panel. (T-447)
- **Per-field scope tags.** Each settings field shows where its value lives —
folder = Project (`.clide`), globe = Always (`~/.clide`), circle-dashed =
Default/unset — colour-coded with a tooltip. Tapping opens a menu to move the
value between the scopes the key supports or reset it to default. Backed by
scope-explicit `SettingsStore` access (`rawAt`/`setAt`/`removeAt`/
`effectiveLayer`/`writableLayers`); `ext.*` keys layer project-over-app,
`app.*`/`project.*` keys live in their prefix's file. (T-449)
### Changed
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
+5 -5
View File
@@ -2,7 +2,7 @@
This document governs what clide is allowed to do at runtime, what it's allowed to depend on, and how contributors — human and agent — introduce code into the project. It is binding on all contributors. When in doubt, stop and ask.
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `decisions/`. This document sets the rules; `decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `governance/decisions/`. This document sets the rules; `governance/decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
## Why this document exists
@@ -124,9 +124,9 @@ When removing a dependency:
1. **Grep the entire repository** for references to the package, its exports, and any type names it contributed. `rg '<package>|<PackageType>|<prefix_>'` across the repo. Zero hits outside git history is the goal. A single lingering import will break the build; a single lingering FFI stub or type alias will compile fine and fail at runtime.
2. **Regenerate the lockfile** as part of the same PR. A `pubspec.yaml` with the dep removed but a `pubspec.lock` that still pins it is a partial removal, and CI or a fresh clone will happily continue installing the package.
3. **Update `app/assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
3. **Update `assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
4. **Remove any vendored artifacts** tied to the dep — binaries, prebuilt assets, generated bindings — and delete their `BUILD.md` records. An orphaned vendored binary is worse than a removed one because it looks legitimate.
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `decisions/`) needs updating.
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `governance/decisions/`) needs updating.
A dependency is not removed until all five are true. "I deleted the line from pubspec.yaml" is the start of the removal, not the end.
@@ -188,12 +188,12 @@ When in doubt about a license, the dependency does not land until the question i
### Attribution requirements
- The license manifest at `app/assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
- The license manifest at `assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
- Transitive dependencies are listed, not just direct ones. If `wasm_run` pulls in `wasmtime` which pulls in `cranelift`, all three appear.
- Apache-2.0 dependencies get their `NOTICE` file content preserved verbatim, not summarized.
- Apache-2.0-with-LLVM-exception (e.g., Cranelift, parts of LLVM) requires the LLVM exception text specifically, not just the Apache-2.0 boilerplate.
- Fonts and icon sets get attributed even if the license doesn't strictly require it. It's the right thing to do.
- `app/assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
- `assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
Adding a dependency means updating the license manifest in the same PR. No exceptions.
+3 -3
View File
@@ -4,7 +4,7 @@ An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-po
## Architecture
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence (D-41).
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the tmux-backed D-41).
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
@@ -15,7 +15,7 @@ Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI su
## Built-in extensions
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
canvas, claude, claude_control, cli_install, decisions, deeplink, default_layout, diff, editor, extensions_ui, files, git, grammars_core, graph, ipc_status, keybindings_ui, markdown, menubar, output, pql, problems, search, settings_ui, terminal, theme_picker, tickets, todos, view, vim, welcome.
## Building
@@ -44,7 +44,7 @@ make push-check # pre-push gate: decisions + core + fast + a11y + coverage
## Status
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
Active development; the interaction model, panel system, and settings engine have landed. The Python Textual predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
## Documentation
@@ -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 {
@@ -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);
});
});
+71
View File
@@ -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);
});
});
}