diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d72a3a58..0ce5e5cd 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7025,3 +7025,82 @@ Dropdown/select listing registered accounts + a "(default)" option. Selecting is Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', NULL, '2026-06-27 21:46:33', '2026-06-27 21:46:33.584', '2026-06-27 21:46:33.584', NULL, '4f4f8d74d775750d79f054ba5821ce5a', 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 ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 21:47:58', '2026-06-27 21:47:58.084', '2026-06-27 21:47:58.084', NULL, '36e3db47395a6678771e97e108c2b88b', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'description', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1). + +Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.216', '2026-06-27 22:35:36.216', NULL, 'fb876cec752b1ac391234569789ad282', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'status', 'in_progress', 'done', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.250', '2026-06-27 22:35:36.250', NULL, 'd6bed97cc8a298c7cab9ae5fb5b1ede1', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 44735351..c1945907 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -8936,3 +8936,128 @@ Dropdown/select listing registered accounts + a "(default)" option. Selecting is Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:46:33.584', NULL, '31cf98ecc2a8e496cd9cb324fa75a404', 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 ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: UI — Claude pane badge + welcome view accounts section', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances. + +## Claude pane chrome badge + +Small badge in the pane chrome (next to the existing status/banner row — see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound. + +- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry. +- Selecting an account dispatches `clide claude account set `; "Add account…" runs the add+login flow. +- The badge live-updates from the `claude.account` MessageBus channel published by T-480. +- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account. + +## Welcome view: Claude accounts section + +New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`): + +- **Registered accounts** — one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login. +- **Detected (not yet registered)** — rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add --dir `. +- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` path. + +All actions go through T-480''s CLI verbs; this widget never writes the registry directly. + +## Constraints + +- Use the existing UI-design vocabulary (theme tokens, control geometry — see the `ui-design` skill). No Material/Cupertino. +- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense — opening the picker is a navigation gesture, the selection happens in the picker modal. +- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout). + +## Acceptance + +1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions. +2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account. +3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance. +4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection). +5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client). +6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable. + +## Depends on + +- T-480 (CLI verbs). Wires every action to the dispatcher. + +## Out of scope + +- Settings-UI rows — T-482. +- Project-picker integration (each project shows its bound account) — desirable, file as a follow-up if it grows complex. +', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:47:58.084', NULL, '2ef8c7fb006f7bf2eb18d981a0126e24', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1). + +Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:35:36.216', NULL, '5543c116e734c8d891dad897a844a727', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1). + +Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:35:36.250', NULL, '9ed2e68e4ff8955cf57a3ce2ef27a8a9', 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 ed9a7342..ecb3b744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- **Settings → Claude → Account picker.** A per-workspace dropdown binds this - repo to a registered Claude account (or Default); switching respawns the pane - onto it. (T-482, epic T-476) +- **Settings → Claude → Accounts.** A registry list (sign-in status, dir, + re-login / remove, add) plus a per-workspace picker that binds this repo to an + account (or Default); switching respawns the pane onto it. (T-482, epic T-476) - **Per-repo Claude accounts — `clide claude account` verbs.** Manage named Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/ `remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index f9d2a10d..a49929d4 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -239,6 +239,8 @@ "settings.claude.permissionMode.label": { "translation": "Permission mode" }, "settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." }, "settings.claude.account.label": { "translation": "Account" }, + "settings.claude.account.registry.label": { "translation": "Accounts" }, + "settings.claude.account.registry.help": { "translation": "Registered Claude accounts (each a separate config dir + login)." }, "settings.claude.account.workspace.label": { "translation": "Account for this workspace" }, "settings.claude.account.workspace.help": { "translation": "Which Claude account this repo runs under; Default uses the system login." } } diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 6f75cef6..1084fd9a 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -239,6 +239,8 @@ "settings.claude.permissionMode.label": { "translation": "Permissiemodus" }, "settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." }, "settings.claude.account.label": { "translation": "Account" }, + "settings.claude.account.registry.label": { "translation": "Accounts" }, + "settings.claude.account.registry.help": { "translation": "Geregistreerde Claude-accounts (elk een eigen configmap + login)." }, "settings.claude.account.workspace.label": { "translation": "Account voor deze werkmap" }, "settings.claude.account.workspace.help": { "translation": "Welk Claude-account deze repo gebruikt; Standaard gebruikt de systeemlogin." } } diff --git a/lib/builtin/claude/src/account_registry.dart b/lib/builtin/claude/src/account_registry.dart index 1e5745db..9ecd50a0 100644 --- a/lib/builtin/claude/src/account_registry.dart +++ b/lib/builtin/claude/src/account_registry.dart @@ -153,6 +153,22 @@ class AccountRegistry { } } +/// Best-effort, read-only check of whether an account config dir holds live +/// credentials (T-482) — for the "signed in / not signed in" indicator. True +/// when the dir has a `.credentials.json` (Linux/Windows) or its `.claude.json` +/// carries an `oauthAccount` marker. Never mutates; under-reports on macOS, +/// where Claude Code keeps credentials in the system keychain rather than a file. +bool accountIsSignedIn(String dir) { + if (File('$dir/.credentials.json').existsSync()) return true; + final cfg = File('$dir/.claude.json'); + if (!cfg.existsSync()) return false; + try { + return cfg.readAsStringSync().contains('"oauthAccount"'); + } catch (_) { + return false; + } +} + /// Whether [dir] is safe to `rm -rf` as a purged account config dir /// (`remove --purge`, T-480): it must be a `~/.claude-*` directory that is a /// DIRECT child of [home]. Anything else — an absolute path elsewhere, a nested diff --git a/lib/builtin/claude/src/account_settings_control.dart b/lib/builtin/claude/src/account_settings_control.dart index c6c1328d..dcc39716 100644 --- a/lib/builtin/claude/src/account_settings_control.dart +++ b/lib/builtin/claude/src/account_settings_control.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:clide/builtin/claude/src/account_registry.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; @@ -132,3 +134,178 @@ class _ClaudeWorkspaceAccountControlState extends State createState() => _ClaudeAccountsListControlState(); +} + +class _ClaudeAccountsListControlState extends State { + final TextEditingController _name = TextEditingController(); + final FocusNode _focus = FocusNode(debugLabel: 'add-account'); + SettingsStore? _settings; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final settings = ClideKernel.maybeOf(context)?.settings; + if (identical(settings, _settings)) return; + _settings?.removeListener(_onChange); + _settings = settings; + _settings?.addListener(_onChange); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _settings?.removeListener(_onChange); + _name.dispose(); + _focus.dispose(); + super.dispose(); + } + + Future _add(KernelServices services) async { + final name = _name.text.trim(); + if (name.isEmpty) return; + final reg = AccountRegistry(services.settings); + if (reg.accountByName(name) != null) { + _name.clear(); + return; // idempotent — already registered + } + final dir = '${Platform.environment['HOME'] ?? ''}/.claude-$name'; + _name.clear(); + final write = reg.registerAccount(name, dir); + // Kick off the login flow for the new account (T-485 consumer opens it). + services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': name, 'dir': dir}); + await write; + } + + void _relogin(KernelServices services, Account a) { + services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': a.name, 'dir': a.dir}); + } + + Future _remove(KernelServices services, String name) => AccountRegistry(services.settings).removeAccount(name); + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + final services = ClideKernel.maybeOf(context); + if (services == null) return const SizedBox.shrink(); + final reg = AccountRegistry(services.settings); + final accounts = reg.accounts; + final bound = reg.boundAccountNames(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (accounts.isEmpty) + ClideText('No accounts registered yet.', fontSize: clideFontCaption, color: tokens.globalTextMuted) + else + for (final a in accounts) _row(context, services, tokens, a, bound.contains(a.name)), + const SizedBox(height: 10), + _addRow(context, services, tokens), + ], + ); + } + + Widget _row(BuildContext context, KernelServices services, SurfaceTokens tokens, Account a, bool isBound) { + final signedIn = accountIsSignedIn(a.dir); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: signedIn ? tokens.statusSuccess : tokens.globalTextMuted), + ), + const SizedBox(width: 8), + ClideText(a.name, color: tokens.globalForeground), + const SizedBox(width: 10), + Expanded( + child: ClideText(a.dir, fontSize: clideFontCaption, muted: true, fontFamily: ClideSettings.fonts.monoOf(context), overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + _iconButton(context, 'sign-in', signedIn ? 'Re-sign in to ${a.name}' : 'Sign in to ${a.name}', () => _relogin(services, a)), + const SizedBox(width: 6), + _iconButton( + context, + 'trash', + isBound ? '${a.name} is bound to a workspace — unset it first' : 'Remove ${a.name}', + isBound ? null : () => _remove(services, a.name), + color: isBound ? tokens.globalTextMuted : tokens.statusError, + ), + ], + ), + ); + } + + Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) { + final tokens = ClideSettings.theme.of(context).surface; + return Semantics( + button: true, + enabled: onTap != null, + label: semantic, + excludeSemantics: true, + child: ClideTappable( + cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic, + onTap: onTap, + builder: (ctx, hovered, _) => + ClideIcon(PhosphorIcons.byName(icon), size: 14, color: color ?? (hovered ? tokens.globalForeground : tokens.globalTextMuted)), + ), + ); + } + + Widget _addRow(BuildContext context, KernelServices services, SurfaceTokens tokens) { + return Row( + children: [ + Expanded( + child: Container( + height: 26, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: EditableText( + controller: _name, + focusNode: _focus, + style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalTextMuted, + maxLines: 1, + onSubmitted: (_) => _add(services), + ), + ), + ), + const SizedBox(width: 8), + Semantics( + button: true, + label: 'Add account', + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: () => _add(services), + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)), + child: ClideText('Add account', color: tokens.buttonForeground, fontSize: clideFontCaption), + ), + ), + ), + ], + ); + } +} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 061c81a2..0768c3ae 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -207,9 +207,18 @@ class ClaudeExtension extends ClideExtension { labelKey: 'settings.claude.account.label', fields: [ SettingsField( - // Placeholder key — a custom field is rendered by its control, + // Placeholder keys — custom fields are rendered by their control, // never stored here; kept clear of the app.claude.account. // binding namespace the registry scans (T-480). + key: 'app.claude.accountsRegistry', + kind: SettingsFieldKind.custom, + label: 'Accounts', + labelKey: 'settings.claude.account.registry.label', + help: 'Registered Claude accounts (each a separate config dir + login).', + helpKey: 'settings.claude.account.registry.help', + customId: 'claude.accounts', + ), + SettingsField( key: 'app.claude.workspaceAccount', kind: SettingsFieldKind.custom, label: 'Account for this workspace', @@ -223,6 +232,7 @@ class ClaudeExtension extends ClideExtension { ], ), ), + SettingsControlContribution(id: 'claude.accounts', customId: 'claude.accounts', builder: (_) => const ClaudeAccountsListControl()), SettingsControlContribution(id: 'claude.workspace-account', customId: 'claude.workspace-account', builder: (_) => const ClaudeWorkspaceAccountControl()), // T-171: agent roster controls (D-6 CLI/UI parity). // Usage: clide claude.agent.show diff --git a/test/builtin/claude/account_registry_test.dart b/test/builtin/claude/account_registry_test.dart index a598a847..752ccbb5 100644 --- a/test/builtin/claude/account_registry_test.dart +++ b/test/builtin/claude/account_registry_test.dart @@ -138,6 +138,31 @@ void main() { }); }); + group('sign-in probe (T-482)', () { + test('true with a .credentials.json or an oauthAccount marker; false otherwise', () async { + final home = await Directory.systemTemp.createTemp('clide-signin-'); + addTearDown(() => home.deleteSync(recursive: true)); + + // .credentials.json present → signed in. + final a = Directory('${home.path}/.claude-a')..createSync(); + File('${a.path}/.credentials.json').writeAsStringSync('{}'); + expect(accountIsSignedIn(a.path), isTrue); + + // .claude.json with an oauthAccount marker → signed in. + final b = Directory('${home.path}/.claude-b')..createSync(); + File('${b.path}/.claude.json').writeAsStringSync('{"oauthAccount": {"email": "x@y.z"}}'); + expect(accountIsSignedIn(b.path), isTrue); + + // .claude.json without the marker → not signed in. + final c = Directory('${home.path}/.claude-c')..createSync(); + File('${c.path}/.claude.json').writeAsStringSync('{"theme": "dark"}'); + expect(accountIsSignedIn(c.path), isFalse); + + // No config dir at all → not signed in. + expect(accountIsSignedIn('${home.path}/.claude-missing'), isFalse); + }); + }); + group('purge guard (T-480)', () { const home = '/home/u'; test('accepts ~/.claude-* direct children only, rejects everything else', () { diff --git a/test/builtin/claude/account_settings_control_test.dart b/test/builtin/claude/account_settings_control_test.dart index ebbeab75..b3653697 100644 --- a/test/builtin/claude/account_settings_control_test.dart +++ b/test/builtin/claude/account_settings_control_test.dart @@ -95,4 +95,64 @@ void main() { expect(published.single['action'], 'unset'); expect(published.single['previous'], 'work'); }); + + // The global registry list control (T-482 part 2). + Future pumpList(WidgetTester tester) => tester.pumpWidget( + harness( + f, + const Align( + alignment: Alignment.center, + child: SizedBox(width: 460, child: ClaudeAccountsListControl()), + ), + ), + ); + + testWidgets('registry list: empty shows a hint + the add row', (tester) async { + await pumpList(tester); + await tester.pump(); + expect(find.textContaining('No accounts registered'), findsOneWidget); + expect(find.text('Add account'), findsOneWidget); + }); + + testWidgets('registry list: renders each account name + dir', (tester) async { + final reg = AccountRegistry(f.services.settings); + await tester.runAsync(() => reg.registerAccount('work', '/home/u/.claude-work')); + await pumpList(tester); + await tester.pump(); + expect(find.text('work'), findsOneWidget); + expect(find.text('/home/u/.claude-work'), findsOneWidget); + }); + + testWidgets('registry list: typing a name + Add registers it and publishes login', (tester) async { + final reg = AccountRegistry(f.services.settings); + final published = >[]; + final sub = f.services.messages.subscribe(channel: accountActionChannel).listen((m) => published.add(m.data)); + addTearDown(sub.cancel); + + await pumpList(tester); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'work'); + await tester.tap(find.text('Add account')); + await tester.pump(); + expect(reg.accountByName('work'), isNotNull); + expect(published.single['action'], 'login'); + expect(published.single['name'], 'work'); + }); + + testWidgets('registry list: remove deletes an unbound account; a bound one is guarded', (tester) async { + final reg = AccountRegistry(f.services.settings); + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await reg.registerAccount('work', '/home/u/.claude-work'); + await reg.registerAccount('personal', '/home/u/.claude-personal'); + await reg.bindWorkspace(f.tempDir.path, 'work'); + }); + + await pumpList(tester); + await tester.pump(); + await tester.tap(find.bySemanticsLabel('Remove personal')); + await tester.pump(); + expect(reg.accountByName('personal'), isNull); + expect(find.bySemanticsLabel(RegExp('bound to a workspace')), findsOneWidget, reason: 'the bound account guards its remove'); + }); }