diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d03cb697..df97d835 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 7338cfd7..702bdf2a 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea73f6d..e770561e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/POLICY.md b/POLICY.md index 4d4050be..4661bb74 100644 --- a/POLICY.md +++ b/POLICY.md @@ -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 '||'` 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. diff --git a/README.md b/README.md index b29e8ea6..a9915beb 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/lib/builtin/settings_ui/src/settings_category_view.dart b/lib/builtin/settings_ui/src/settings_category_view.dart index 438f5cd1..0a9c7d19 100644 --- a/lib/builtin/settings_ui/src/settings_category_view.dart +++ b/lib/builtin/settings_ui/src/settings_category_view.dart @@ -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(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(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(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), ), ), ), diff --git a/lib/builtin/settings_ui/src/settings_modal.dart b/lib/builtin/settings_ui/src/settings_modal.dart index f68d6f18..e93d1122 100644 --- a/lib/builtin/settings_ui/src/settings_modal.dart +++ b/lib/builtin/settings_ui/src/settings_modal.dart @@ -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)), + ], + ), + ), + ), ], ), ), diff --git a/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json index b8a3a43b..74f6111e 100644 --- a/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json +++ b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json @@ -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" } } diff --git a/lib/kernel/src/settings.dart b/lib/kernel/src/settings.dart index e3c8535b..8e27b231 100644 --- a/lib/kernel/src/settings.dart +++ b/lib/kernel/src/settings.dart @@ -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 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 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 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> _readFile(File f) async { String txt; try { diff --git a/test/builtin/settings_ui/category_view_test.dart b/test/builtin/settings_ui/category_view_test.dart index 0841fdb6..6570b6b4 100644 --- a/test/builtin/settings_ui/category_view_test.dart +++ b/test/builtin/settings_ui/category_view_test.dart @@ -102,14 +102,28 @@ void main() { expect(f.services.settings.get('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('app.demo.flag'), isFalse); + await tester.tap(find.text('Reset to default')); + await tester.pump(); + expect(f.services.settings.effectiveLayer('app.demo.flag'), isNull); }); }); diff --git a/test/kernel/src/settings_scope_test.dart b/test/kernel/src/settings_scope_test.dart new file mode 100644 index 00000000..d3c51077 --- /dev/null +++ b/test/kernel/src/settings_scope_test.dart @@ -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('ext.k'), 'a'); + + await store.setAt(SettingsScope.project, 'ext.k', 'p'); + expect(store.effectiveLayer('ext.k'), SettingsScope.project); + expect(store.get('ext.k'), 'p'); + + await store.removeAt(SettingsScope.project, 'ext.k'); + expect(store.effectiveLayer('ext.k'), SettingsScope.app); + expect(store.get('ext.k'), 'a'); + + await store.removeAt(SettingsScope.app, 'ext.k'); + expect(store.effectiveLayer('ext.k'), isNull); + expect(store.get('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('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); + }); + }); +}