The always-visible affordance for the multi-account epic: a compact badge in the pane header showing which account this workspace is bound to (or "default"), colour-tinted per account so two windows are distinguishable at a glance. Tapping opens a picker of the registered accounts + Default; selecting binds/unbinds via the shared bindWorkspaceAccount helper (respawn + lock-sync follow on the bus). Hidden when no accounts are registered, so it adds no chrome for users not using the feature. accountAccent derives the tint by hashing the name into a fixed set of theme tokens — never an arbitrary colour, so the palette stays theme-owned. The welcome-view accounts section (the other half of T-481) is split to T-486: the welcome builtin is intentionally decoupled from feature builtins, so it needs a welcome-section contribution point rather than importing claude directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
434 lines
16 KiB
Dart
434 lines
16 KiB
Dart
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;
|
|
import 'package:clide/widgets/widgets.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
/// Bind (or, with [name] null, unbind) [cwd] to a Claude account and publish on
|
|
/// [accountActionChannel] so the session respawns (T-480) and the IDE lock
|
|
/// re-syncs (T-479). The registry write sets the in-memory binding
|
|
/// synchronously then flushes; publishing before the flush keeps the bus
|
|
/// consumers in step. Shared by the settings picker and the pane badge (T-481).
|
|
Future<void> bindWorkspaceAccount(KernelServices services, String cwd, String? name) async {
|
|
final reg = AccountRegistry(services.settings);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// A stable, token-derived accent for an account [name] so each window's badge
|
|
/// reads at a glance (T-481). Hash-indexed into a fixed set of theme tokens —
|
|
/// never an arbitrary colour (the palette stays theme-owned). Null name (the
|
|
/// default account) returns the muted token.
|
|
Color accountAccent(String? name, SurfaceTokens tokens) {
|
|
if (name == null || name.isEmpty) return tokens.globalTextMuted;
|
|
final accents = [tokens.globalFocus, tokens.statusSuccess, tokens.statusWarning, tokens.statusError, tokens.buttonBackground];
|
|
var h = 0;
|
|
for (final unit in name.codeUnits) {
|
|
h = (h * 31 + unit) & 0x7fffffff;
|
|
}
|
|
return accents[h % accents.length];
|
|
}
|
|
|
|
/// 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();
|
|
await bindWorkspaceAccount(services, cwd, name);
|
|
}
|
|
|
|
@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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Settings control for the global Claude accounts registry (T-482, epic
|
|
/// T-476). Lists each registered account — sign-in status dot, name, config
|
|
/// dir — with re-login and remove affordances, plus an inline "add account"
|
|
/// field. Management flows through the AccountRegistry + accountActionChannel
|
|
/// (the CLI verbs' path); removal is refused while a workspace is bound, to
|
|
/// match `clide claude account remove`.
|
|
class ClaudeAccountsListControl extends StatefulWidget {
|
|
const ClaudeAccountsListControl({super.key});
|
|
|
|
@override
|
|
State<ClaudeAccountsListControl> createState() => _ClaudeAccountsListControlState();
|
|
}
|
|
|
|
class _ClaudeAccountsListControlState extends State<ClaudeAccountsListControl> {
|
|
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<void> _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<void> _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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Compact account badge for the Claude pane chrome (T-481, epic T-476). Shows
|
|
/// the workspace's bound account (or "default"), tinted by [accountAccent] so
|
|
/// each window is distinguishable at a glance. Tapping opens a picker of the
|
|
/// registered accounts + Default. Hidden when no accounts are registered (the
|
|
/// feature is unused). Live via the settings notifier.
|
|
class ClaudeAccountBadge extends StatefulWidget {
|
|
const ClaudeAccountBadge({super.key, required this.workspaceRoot});
|
|
|
|
final String? workspaceRoot;
|
|
|
|
@override
|
|
State<ClaudeAccountBadge> createState() => _ClaudeAccountBadgeState();
|
|
}
|
|
|
|
class _ClaudeAccountBadgeState extends State<ClaudeAccountBadge> {
|
|
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();
|
|
}
|
|
|
|
Future<void> _pick(KernelServices services, String? name) async {
|
|
_overlay.close();
|
|
final cwd = widget.workspaceRoot;
|
|
if (cwd != null) await bindWorkspaceAccount(services, cwd, name);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideSettings.theme.of(context).surface;
|
|
final services = ClideKernel.maybeOf(context);
|
|
final cwd = widget.workspaceRoot;
|
|
if (services == null || cwd == null) return const SizedBox.shrink();
|
|
final reg = AccountRegistry(services.settings);
|
|
final accounts = reg.accounts;
|
|
if (accounts.isEmpty) return const SizedBox.shrink(); // feature unused — no chrome noise
|
|
final boundName = reg.boundName(cwd);
|
|
final label = boundName ?? 'default';
|
|
final accent = accountAccent(boundName, tokens);
|
|
return ClideAnchoredOverlay(
|
|
controller: _overlay,
|
|
align: ClideAnchorAlign.end,
|
|
overlayBuilder: (ctx, c) => ClideMenu(
|
|
onClose: c.close,
|
|
entries: [
|
|
ClideMenuItem(label: 'default', active: boundName == null, semanticLabel: 'Account: default', onSelect: () => _pick(services, null)),
|
|
const ClideMenuSeparator(),
|
|
for (final a in accounts)
|
|
ClideMenuItem(label: a.name, active: a.name == boundName, semanticLabel: 'Account: ${a.name}', onSelect: () => _pick(services, a.name)),
|
|
],
|
|
),
|
|
anchor: Semantics(
|
|
button: true,
|
|
label: 'Claude account: $label. 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: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: hovered ? tokens.listItemHoverBackground : null,
|
|
border: Border.all(color: accent),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: accent),
|
|
),
|
|
const SizedBox(width: 5),
|
|
ClideText(label, fontSize: clideFontCaption, color: boundName == null ? tokens.globalTextMuted : tokens.globalForeground),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|