feat(claude): Settings → Claude per-workspace account picker (T-482 part 1)
A custom settings control under the Claude category: a dropdown of the registered accounts plus Default. Picking one binds (or unbinds) this workspace via the AccountRegistry and publishes set/unset on accountActionChannel — the same channel the CLI verbs use, so the session respawns onto the account (T-480) and the IDE lock re-syncs (T-479). It reads live off the shared settings notifier, so a CLI `account set` updates the dropdown too. Empty states cover no-workspace and no-accounts, each pointing at the CLI. Registry writes set the in-memory binding synchronously then flush, so the control publishes the bus event before the disk write completes. Part 2 (the global Accounts registry CRUD list + sign-in probe) is still open on T-482. en/nl strings added; widget test covers the states + bind/unbind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Settings control for "Account for this workspace" (T-482, epic T-476). A
|
||||
/// dropdown of the registered Claude accounts plus a Default option; picking one
|
||||
/// binds (or unbinds) the current workspace and publishes on
|
||||
/// [accountActionChannel] — the same channel the CLI `set`/`unset` verbs use, so
|
||||
/// the session respawns onto the account (T-480) and the IDE lock re-syncs
|
||||
/// (T-479). Registry writes flow through the shared [SettingsStore], whose
|
||||
/// notifier this control listens to, so it stays live for both UI and CLI edits.
|
||||
class ClaudeWorkspaceAccountControl extends StatefulWidget {
|
||||
const ClaudeWorkspaceAccountControl({super.key});
|
||||
|
||||
@override
|
||||
State<ClaudeWorkspaceAccountControl> createState() => _ClaudeWorkspaceAccountControlState();
|
||||
}
|
||||
|
||||
class _ClaudeWorkspaceAccountControlState extends State<ClaudeWorkspaceAccountControl> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
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);
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _defaultLabel = 'Default';
|
||||
|
||||
Future<void> _bind(KernelServices services, String cwd, String? name) async {
|
||||
_overlay.close();
|
||||
final reg = AccountRegistry(services.settings);
|
||||
// The registry write sets the in-memory binding synchronously, then flushes
|
||||
// to disk; publish once the binding is live (before the flush completes) so
|
||||
// the respawn (T-480) + lock-sync (T-479) consumers see the new state. The
|
||||
// settings notifier rebuilds us once the write lands.
|
||||
if (name == null) {
|
||||
final previous = reg.boundName(cwd);
|
||||
final write = reg.unbindWorkspace(cwd);
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': previous});
|
||||
await write;
|
||||
} else {
|
||||
final write = reg.bindWorkspace(cwd, name);
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
|
||||
await write;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final services = ClideKernel.maybeOf(context);
|
||||
final cwd = services?.settings.projectDir?.path;
|
||||
if (services == null || cwd == null) {
|
||||
return ClideText('Open a workspace to bind a Claude account.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
|
||||
}
|
||||
|
||||
final reg = AccountRegistry(services.settings);
|
||||
final accounts = reg.accounts;
|
||||
if (accounts.isEmpty) {
|
||||
return ClideText('No accounts yet — add one with `clide claude account add <name>`.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
|
||||
}
|
||||
|
||||
final boundName = reg.boundName(cwd);
|
||||
final selectedLabel = boundName ?? _defaultLabel;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.start,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
ClideMenuItem(
|
||||
label: _defaultLabel,
|
||||
active: boundName == null,
|
||||
semanticLabel: 'Account for this workspace: $_defaultLabel',
|
||||
onSelect: () => _bind(services, cwd, null),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
for (final a in accounts)
|
||||
ClideMenuItem(
|
||||
label: a.name,
|
||||
active: a.name == boundName,
|
||||
semanticLabel: 'Account for this workspace: ${a.name}',
|
||||
onSelect: () => _bind(services, cwd, a.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: 'Account for this workspace: $selectedLabel. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(selectedLabel, color: tokens.globalForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
@@ -198,9 +199,31 @@ class ClaudeExtension extends ClideExtension {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Per-repo Claude account (T-482, epic T-476). The dropdown binds this
|
||||
// workspace to a registered account; manage the registry itself with
|
||||
// the `clide claude account` verbs (T-480).
|
||||
SettingsSection(
|
||||
label: 'Account',
|
||||
labelKey: 'settings.claude.account.label',
|
||||
fields: [
|
||||
SettingsField(
|
||||
// Placeholder key — a custom field is rendered by its control,
|
||||
// never stored here; kept clear of the app.claude.account.<hash>
|
||||
// binding namespace the registry scans (T-480).
|
||||
key: 'app.claude.workspaceAccount',
|
||||
kind: SettingsFieldKind.custom,
|
||||
label: 'Account for this workspace',
|
||||
labelKey: 'settings.claude.account.workspace.label',
|
||||
help: 'Which Claude account this repo runs under; Default uses the system login.',
|
||||
helpKey: 'settings.claude.account.workspace.help',
|
||||
customId: 'claude.workspace-account',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 <sessionId>
|
||||
CommandContribution(
|
||||
|
||||
Reference in New Issue
Block a user