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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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