feat: sealed-area connection switch + identity bar (multi-project stage 3)
Some checks failed
Security / Security check (push) Failing after 1s

The workspace switcher now lists the operator's sealed areas (read from
~/.chain/sealed/ manifests, the same source the CLI uses) below the
shared projects, each with a lock icon and a running/stopped status.
Selecting one is a real connection switch: Studio reconnects its hub
client to the area's own port with a full state reload — one window,
one truth. A stopped area is started first (chain project start) with a
visible notice; a failure surfaces as a copyable error and rolls back to
the shared hub.

While in a sealed area an identity bar under the AppBar is painted in
the area's accent colour and names it, with a one-click Leave back to
the shared hub. The area colour is marking, not theming — Studio's blue
stays the app accent. Selecting a shared project from inside an area
switches the connection back first. The sealed connection is never
persisted across restarts.

New: SealedAreaService (manifest + PID discovery), Workspace sealed
switch logic, ChainSealedIdentityBar, SystemActions.chainProjectStart.
l10n DE+EN. flutter analyze clean; 33 tests green (switcher lists sealed
with lock+status, pill shows active area, identity bar renders in the
area colour). Runtime plumbing (discovery, start, endpoint, reach)
verified headlessly against real sealed instances under a redirected
HOME; the identity-bar screenshot is deferred (display click-automation
failed after sleep on the shared desktop — an environment issue, not a
code gap; the visible components are widget-tested).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 17:31:39 +02:00
parent 54ccd3936a
commit b7468dc7ec
15 changed files with 930 additions and 135 deletions

View file

@ -0,0 +1,75 @@
// ChainSealedIdentityBar the identity strip shown under the AppBar
// while Studio is connected to a sealed area.
//
// Painted in the area's accent colour so "which area am I in?" is
// answerable at a glance (one window, one truth). The area colour is
// marking, not theming Studio's blue stays the app accent
// everywhere else. Offers a one-click way back to the shared hub.
// Renders nothing when Studio is on the shared hub.
import 'package:flutter/material.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
import 'chain_workspace_switcher.dart' show parseAreaColor;
class ChainSealedIdentityBar extends StatelessWidget {
const ChainSealedIdentityBar({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Workspace.instance,
builder: (context, _) {
final area = Workspace.instance.activeSealed;
if (area == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final accent = parseAreaColor(area.color) ?? theme.colorScheme.primary;
// A readable foreground on the tinted bar in both themes.
final onAccent =
accent.computeLuminance() > 0.5 ? Colors.black87 : Colors.white;
return Material(
color: accent,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.lg,
vertical: 6,
),
child: Row(
children: [
Icon(Icons.lock_outline, size: 15, color: onAccent),
const SizedBox(width: ChainSpace.sm),
Text(
area.name,
style: theme.textTheme.labelLarge?.copyWith(
color: onAccent,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: ChainSpace.md),
Expanded(
child: Text(
l.sealedIdentityBar,
style: theme.textTheme.bodySmall?.copyWith(
color: onAccent.withValues(alpha: 0.85),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: ChainSpace.sm),
TextButton.icon(
onPressed: () => Workspace.instance.switchToShared(),
style: TextButton.styleFrom(foregroundColor: onAccent),
icon: const Icon(Icons.logout, size: 15),
label: Text(l.sealedLeave),
),
],
),
),
);
},
);
}
}

View file

@ -3,19 +3,25 @@
// One control, two mechanics (docs/architecture/projects.md,
// § Studio): for open/protected projects the choice sets the page
// filter AND the label new runs are stamped with. "All projects"
// stays reachable a filter, not a jail. Sealed areas never
// appear here (own hub instance; stage-3 connection switch).
//
// The project colour is a marking dot only Studio's blue stays
// the app accent (registered design deviation).
// stays reachable a filter, not a jail. Sealed areas are a real
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
// own hub (starting it first if stopped, with a notice) and colours
// the identity bar. Studio's blue stays the app accent — the project
// colour is marking, not theming.
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/sealed_areas.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
/// `s:<slug>` = sealed area.
const _kAll = '';
const _pProject = 'p:';
const _pSealed = 's:';
class ChainWorkspaceSwitcher extends StatelessWidget {
const ChainWorkspaceSwitcher({super.key});
@ -27,118 +33,245 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final ws = Workspace.instance;
final active = ws.active;
final label = ws.isAll
? l.workspaceAll
: (active?.name ?? ws.activeSlug);
final label = ws.inSealedArea
? ws.activeSealed!.name
: (ws.isAll ? l.workspaceAll : (ws.active?.name ?? ws.activeSlug));
return Tooltip(
message: l.workspaceSwitcherTooltip,
child: PopupMenuButton<String>(
onOpened: ws.refresh,
onSelected: ws.setActive,
itemBuilder: (context) => [
PopupMenuItem(
value: '',
child: Row(
children: [
Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.workspaceAll),
],
),
),
if (ws.projects.isNotEmpty) const PopupMenuDivider(),
for (final p in ws.projects)
PopupMenuItem(
value: p.slug,
child: Row(
children: [
_ProjectDot(project: p),
const SizedBox(width: ChainSpace.sm),
Flexible(
child: Text(p.name, overflow: TextOverflow.ellipsis),
),
if (p.isProtected) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.workspaceProtectedHint,
child: Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.md,
vertical: 5,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (active != null) ...[
_ProjectDot(project: active),
const SizedBox(width: 6),
] else ...[
Icon(
Icons.grid_view_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (active?.isProtected ?? false) ...[
const SizedBox(width: 4),
Icon(
Icons.shield_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
onSelected: (v) => _onSelected(context, v),
itemBuilder: (context) => _items(context, ws, l, theme),
child: _pill(context, ws, l, theme, label),
),
);
},
);
}
List<PopupMenuEntry<String>> _items(
BuildContext context,
Workspace ws,
AppLocalizations l,
ThemeData theme,
) {
final items = <PopupMenuEntry<String>>[
PopupMenuItem(
value: _kAll,
child: Row(
children: [
Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.workspaceAll),
],
),
),
];
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
for (final p in ws.projects) {
items.add(
PopupMenuItem(
value: '$_pProject${p.slug}',
child: Row(
children: [
_ProjectDot(color: p.color),
const SizedBox(width: ChainSpace.sm),
Flexible(child: Text(p.name, overflow: TextOverflow.ellipsis)),
if (p.isProtected) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.workspaceProtectedHint,
child: Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
if (ws.sealedAreas.isNotEmpty) {
items.add(const PopupMenuDivider());
items.add(
PopupMenuItem(
enabled: false,
height: 28,
child: Text(
l.workspaceSealedHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
);
for (final a in ws.sealedAreas) {
items.add(
PopupMenuItem(
value: '$_pSealed${a.slug}',
child: Row(
children: [
_ProjectDot(color: a.color),
const SizedBox(width: ChainSpace.sm),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(child: Text(a.name, overflow: TextOverflow.ellipsis)),
const SizedBox(width: ChainSpace.sm),
Text(
a.running ? l.workspaceSealedRunning : l.workspaceSealedStopped,
style: theme.textTheme.labelSmall?.copyWith(
color: a.running
? ChainColors.success
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
return items;
}
Future<void> _onSelected(BuildContext context, String value) async {
final ws = Workspace.instance;
final l = AppLocalizations.of(context)!;
final messenger = ScaffoldMessenger.of(context);
if (value == _kAll) {
await ws.setActive('');
return;
}
if (value.startsWith(_pProject)) {
await ws.setActive(value.substring(_pProject.length));
return;
}
if (value.startsWith(_pSealed)) {
final slug = value.substring(_pSealed.length);
SealedArea? area;
for (final a in ws.sealedAreas) {
if (a.slug == slug) area = a;
}
if (area == null) return;
// Announce the intent starting a stopped area takes a beat.
if (!area.running) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
);
}
final r = await ws.switchToSealed(area);
messenger.hideCurrentSnackBar();
if (!r.ok) {
messenger.showSnackBar(
SnackBar(
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
duration: const Duration(seconds: 8),
),
);
} else if (r.started) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
);
}
}
}
Widget _pill(
BuildContext context,
Workspace ws,
AppLocalizations l,
ThemeData theme,
String label,
) {
final Widget leading;
if (ws.inSealedArea) {
leading = Row(
mainAxisSize: MainAxisSize.min,
children: [
_ProjectDot(color: ws.activeSealed!.color),
const SizedBox(width: 4),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
],
);
} else if (ws.active != null) {
leading = Padding(
padding: const EdgeInsets.only(right: 6),
child: _ProjectDot(color: ws.active!.color),
);
} else {
leading = Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
Icons.grid_view_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
leading,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
const SizedBox(width: 4),
Icon(
Icons.shield_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
);
}
}
/// The project's registry colour as a small marking dot. Falls
/// back to the theme's outline colour when the project has none.
/// The project's registry colour as a small marking dot. Falls back
/// to the theme's outline colour when unset.
class _ProjectDot extends StatelessWidget {
final ProjectRef project;
final String color;
const _ProjectDot({required this.project});
const _ProjectDot({required this.color});
@override
Widget build(BuildContext context) {
@ -147,17 +280,18 @@ class _ProjectDot extends StatelessWidget {
width: 10,
height: 10,
decoration: BoxDecoration(
color: _parseHex(project.color) ?? theme.colorScheme.outline,
color: parseAreaColor(color) ?? theme.colorScheme.outline,
shape: BoxShape.circle,
),
);
}
static Color? _parseHex(String hex) {
final h = hex.replaceFirst('#', '');
if (h.length != 6) return null;
final v = int.tryParse(h, radix: 16);
if (v == null) return null;
return Color(0xFF000000 | v);
}
}
/// Parse a `#rrggbb` hex colour, or null when unparseable.
Color? parseAreaColor(String hex) {
final h = hex.replaceFirst('#', '');
if (h.length != 6) return null;
final v = int.tryParse(h, radix: 16);
if (v == null) return null;
return Color(0xFF000000 | v);
}

View file

@ -19,4 +19,5 @@ export 'chain_settings_dialog.dart';
export 'chain_stores_dialog.dart';
export 'chain_status_dot.dart';
export 'chain_system_ai_editor.dart';
export 'chain_sealed_identity_bar.dart';
export 'chain_workspace_switcher.dart';