feat: sealed-area connection switch + identity bar (multi-project stage 3)
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
54ccd3936a
commit
b7468dc7ec
15 changed files with 930 additions and 135 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue