From b7468dc7ec7e44b4e8914ae2933fbf3f4454e907 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 17:31:39 +0200 Subject: [PATCH] feat: sealed-area connection switch + identity bar (multi-project stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 20 ++ lib/data/sealed_areas.dart | 158 +++++++++ lib/data/system_actions.dart | 11 + lib/data/workspace.dart | 186 +++++++++-- lib/l10n/app_de.arb | 13 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_localizations.dart | 48 +++ lib/l10n/app_localizations_de.dart | 31 ++ lib/l10n/app_localizations_en.dart | 31 ++ lib/main.dart | 1 + lib/widgets/chain_sealed_identity_bar.dart | 75 +++++ lib/widgets/chain_workspace_switcher.dart | 360 ++++++++++++++------- lib/widgets/widgets.dart | 1 + test/sealed_identity_bar_test.dart | 57 ++++ test/workspace_switcher_test.dart | 60 ++++ 15 files changed, 930 insertions(+), 135 deletions(-) create mode 100644 lib/data/sealed_areas.dart create mode 100644 lib/widgets/chain_sealed_identity_bar.dart create mode 100644 test/sealed_identity_bar_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index aafcd69..5ca0b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Added (multi-project, stage ③ — sealed areas) + +- **Sealed-area connection switch.** 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. +- **Identity bar.** While connected to a sealed area, a strip 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. (The window-title + tint is a small follow-up; the identity bar is the primary signal.) +- Selecting a shared project or "All projects" from inside a sealed area + switches the connection back to the shared hub first. The sealed + connection is never persisted across restarts — Studio always launches + on the shared hub and the operator re-enters an area deliberately. + ### Added (detached-runs monitor — T3 parity) - **Runs page.** A new sidebar destination lists detached invocations diff --git a/lib/data/sealed_areas.dart b/lib/data/sealed_areas.dart new file mode 100644 index 0000000..cfc0d38 --- /dev/null +++ b/lib/data/sealed_areas.dart @@ -0,0 +1,158 @@ +// SealedAreaService — discovers the operator's sealed project +// instances by reading their manifests directly, the same way the +// CLI's `chain project list` does. +// +// A sealed project is NOT a row in the shared hub's registry — it is +// its own hub instance (own data dir, port, store config). The shared +// hub never learns it exists (docs/architecture/projects.md). So +// Studio cannot ask a hub for the list; it scans the manifest +// directory itself: +// +// ~/.chain/sealed//project.json — the manifest +// ~/.chain/run/sealed-.pid — running PID (if up) +// ~/.chain/run/sealed-.endpoint — bound endpoint (if up) +// +// This mirrors crates/chain_runtime_mgmt/src/sealed.rs. Reading the +// real ~/.chain honours the operator's actual areas; a test harness +// redirects $HOME so it never touches them. + +import 'dart:convert'; +import 'dart:io'; + +/// One sealed project instance as Studio needs it for the switcher +/// and the connection switch. +class SealedArea { + final String slug; + final String name; + + /// Accent colour hex (e.g. `#e0a458`), empty when unset. This is + /// the area's identity colour — the marking on the switcher and + /// the identity bar. (Studio's blue stays the app accent.) + final String color; + + /// Fixed loopback port of the instance's hub. + final int port; + + /// True when the instance's hub daemon is currently running (a + /// live PID in `~/.chain/run/sealed-.pid`). + final bool running; + + const SealedArea({ + required this.slug, + required this.name, + required this.color, + required this.port, + required this.running, + }); + + /// Loopback gRPC endpoint of the instance's hub. + String get endpoint => 'http://127.0.0.1:$port'; + + @override + bool operator ==(Object other) => + other is SealedArea && + other.slug == slug && + other.name == name && + other.color == color && + other.port == port && + other.running == running; + + @override + int get hashCode => Object.hash(slug, name, color, port, running); +} + +class SealedAreaService { + SealedAreaService._(); + static final SealedAreaService instance = SealedAreaService._(); + + /// `~/.chain` root, or null when the home dir can't be determined. + String? _chainHome() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) return null; + return '$home${Platform.pathSeparator}.chain'; + } + + /// Every sealed area found under `~/.chain/sealed/`, sorted by + /// display name (case-insensitive). Soft-fails to an empty list — + /// an unreadable or absent directory is the normal "no sealed + /// areas" case, not an error. + Future> list() async { + final base = _chainHome(); + if (base == null) return const []; + final sep = Platform.pathSeparator; + final dir = Directory('$base${sep}sealed'); + if (!dir.existsSync()) return const []; + + final areas = []; + for (final entry in dir.listSync()) { + if (entry is! Directory) continue; + final manifest = File('${entry.path}${sep}project.json'); + if (!manifest.existsSync()) continue; + try { + final m = jsonDecode(manifest.readAsStringSync()) as Map; + final slug = (m['slug'] as String?) ?? ''; + final port = (m['port'] as num?)?.toInt() ?? 0; + if (slug.isEmpty || port == 0) continue; + areas.add( + SealedArea( + slug: slug, + name: (m['name'] as String?)?.isNotEmpty == true + ? m['name'] as String + : slug, + color: (m['color'] as String?) ?? '', + port: port, + running: _isRunning(base, sep, slug), + ), + ); + } catch (_) { + // Skip an unparseable manifest rather than failing the list. + } + } + areas.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return areas; + } + + /// True when `~/.chain/run/sealed-.pid` holds a live PID. + bool _isRunning(String base, String sep, String slug) { + try { + final pidFile = File('$base${sep}run${sep}sealed-$slug.pid'); + if (!pidFile.existsSync()) return false; + final pid = int.tryParse(pidFile.readAsStringSync().trim()); + if (pid == null) return false; + return _pidAlive(pid); + } catch (_) { + return false; + } + } + + /// Liveness probe without killing: `kill -0` on POSIX, tasklist on + /// Windows. Matches the CLI's is_pid_alive semantics closely + /// enough for a UI status dot. + bool _pidAlive(int pid) { + try { + if (Platform.isWindows) { + final r = Process.runSync('tasklist', ['/FI', 'PID eq $pid']); + return r.stdout.toString().contains('$pid'); + } + final r = Process.runSync('kill', ['-0', '$pid']); + return r.exitCode == 0; + } catch (_) { + return false; + } + } + + /// Read the bound endpoint the instance's daemon wrote, if any. + /// Falls back to the manifest port when the run-file is absent. + Future boundEndpoint(String slug) async { + final base = _chainHome(); + if (base == null) return null; + final sep = Platform.pathSeparator; + final f = File('$base${sep}run${sep}sealed-$slug.endpoint'); + if (!f.existsSync()) return null; + final raw = f.readAsStringSync().trim(); + return raw.isEmpty ? null : raw; + } +} diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index b17fdb8..950c74f 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -205,6 +205,17 @@ class SystemActions { return _runFai(args); } + /// Start a sealed project's isolated hub instance + /// (`chain project start `). Detached daemon on the + /// instance's fixed port — returns once the CLI has spawned it. + /// Used by Studio's workspace switch when the operator selects a + /// stopped sealed area. + static Future<({bool ok, String stdout, String stderr})> chainProjectStart( + String slug, + ) async { + return _runFai(['project', 'start', slug]); + } + static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { diff --git a/lib/data/workspace.dart b/lib/data/workspace.dart index 5b37ffd..a726808 100644 --- a/lib/data/workspace.dart +++ b/lib/data/workspace.dart @@ -1,17 +1,27 @@ -// Workspace — the active project selection shared across pages. +// Workspace — the active project selection + connection shared +// across pages. // -// One switcher in the AppBar drives two things at once (see -// docs/architecture/projects.md in the platform repo, § Studio): -// the page filter (audit, approvals) AND the label new runs are -// stamped with. "All projects" stays reachable — the selection is -// a filter, not a jail. Sealed areas are NOT listed here; they are -// their own hub instance and arrive with the stage-3 connection -// switch. +// One switcher in the AppBar drives the whole project experience +// (docs/architecture/projects.md, § Studio). Two mechanics behind +// one control: +// +// * open/protected projects → a page filter AND the label new runs +// are stamped with, all against the shared hub. "All projects" +// stays reachable — a filter, not a jail. +// * sealed areas → a real CONNECTION SWITCH. Selecting one +// reconnects Studio to that area's own hub instance (its own +// port), with a full state reload — one window, one truth. A +// stopped area is started first (with a visible notice). The +// active area colours an identity bar; Studio's blue stays the +// app accent (the area colour is marking, not theming). +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'hub.dart'; +import 'sealed_areas.dart'; +import 'system_actions.dart'; class Workspace extends ChangeNotifier { Workspace._(); @@ -23,7 +33,21 @@ class Workspace extends ChangeNotifier { List _projects = const []; List get projects => _projects; - /// Active project slug; empty = all projects (no filter). + /// Sealed areas discovered under `~/.chain/sealed/` (own hub + /// instances; never in the shared registry). + List _sealed = const []; + List get sealedAreas => _sealed; + + /// The sealed area Studio is currently connected to, or null when + /// on the shared hub. Not persisted — Studio always launches on + /// the shared hub and the operator re-enters a sealed area + /// deliberately (a launch must never silently start a sealed hub). + SealedArea? _activeSealed; + SealedArea? get activeSealed => _activeSealed; + bool get inSealedArea => _activeSealed != null; + + /// Active project slug for the shared-hub filter; empty = all + /// projects. Meaningful only when [inSealedArea] is false. String _activeSlug = ''; String get activeSlug => _activeSlug; bool get isAll => _activeSlug.isEmpty; @@ -39,8 +63,9 @@ class Workspace extends ChangeNotifier { bool _loaded = false; - /// Restore the persisted selection and pull the registry once. - /// Safe to call repeatedly; only the first call restores. + /// Restore the persisted filter and pull the registry + sealed + /// list once. Safe to call repeatedly; only the first call + /// restores. Future ensureLoaded() async { if (!_loaded) { _loaded = true; @@ -54,39 +79,58 @@ class Workspace extends ChangeNotifier { await refresh(); } - /// Re-pull the registry from the hub. Soft-fails (keeps the - /// last list) so a hub restart doesn't blank the switcher; an - /// active slug that vanished from the registry stays selected — - /// its historical rows remain filterable, which is the honest - /// behaviour for an audit surface. + /// Re-pull the registry (from the connected hub) and the sealed + /// list (from the filesystem). Soft-fails (keeps the last lists) + /// so a hub restart doesn't blank the switcher. Future refresh() async { + var changed = false; try { final fresh = await HubService.instance.listProjects(); if (!listEquals(fresh, _projects)) { _projects = fresh; - notifyListeners(); + changed = true; } } catch (_) { // Hub unreachable — the pages already surface that state. } + try { + final sealed = await SealedAreaService.instance.list(); + if (!listEquals(sealed, _sealed)) { + _sealed = sealed; + changed = true; + } + } catch (_) { + // No sealed areas / unreadable — the normal empty case. + } + if (changed) notifyListeners(); } - /// Test hook: seed registry + selection without a hub and mark - /// the persisted state as restored so [ensureLoaded] won't + /// Test hook: seed the lists + selection without a hub/filesystem + /// and mark the state as restored so [ensureLoaded] won't /// overwrite the seed. @visibleForTesting void debugSeed({ required List projects, required String active, + List sealed = const [], + SealedArea? activeSealed, }) { _projects = projects; _activeSlug = active; + _sealed = sealed; + _activeSealed = activeSealed; _loaded = true; notifyListeners(); } - /// Switch the active workspace ('' = all projects) and persist. + /// Switch the shared-hub filter ('' = all projects) and persist. + /// If Studio is currently inside a sealed area, this first switches + /// the connection back to the shared hub (the file/label filter + /// only applies to the shared hub). Future setActive(String slug) async { + if (_activeSealed != null) { + await switchToShared(); + } if (slug == _activeSlug) return; _activeSlug = slug; notifyListeners(); @@ -97,4 +141,106 @@ class Workspace extends ChangeNotifier { // Persistence is best-effort; the in-memory switch stands. } } + + /// Result of a sealed-area switch, surfaced so the UI can show a + /// notice ("started area X") or a copyable error. + ({bool ok, bool started, String error}) _switchResult({ + bool ok = true, + bool started = false, + String error = '', + }) => + (ok: ok, started: started, error: error); + + /// Hard connection switch into a sealed area: start it if stopped + /// (with the returned `started` flag so the caller can show a + /// notice), then reconnect Studio's hub client to the area's port + /// with a full state reload. Returns ok=false + a copyable error + /// when the area could not be started or reached. + Future<({bool ok, bool started, String error})> switchToSealed( + SealedArea area, + ) async { + // Remember where the shared hub is so switchToShared can return + // there — capture it before the first sealed switch. + _sharedEndpoint ??= HubService.instance.currentEndpoint; + + var started = false; + // Re-read the current running state (the list may be stale). + final live = await _reread(area.slug) ?? area; + if (!live.running) { + final r = await SystemActions.chainProjectStart(area.slug); + if (!r.ok) { + return _switchResult( + ok: false, + error: r.stderr.isEmpty ? r.stdout : r.stderr, + ); + } + started = true; + // Give the daemon a moment to bind + write its endpoint file. + await Future.delayed(const Duration(milliseconds: 800)); + } + + // Prefer the endpoint the daemon actually wrote; fall back to + // the manifest port. + final ep = await SealedAreaService.instance.boundEndpoint(area.slug); + final endpoint = _parseEndpoint(ep) ?? + HubEndpoint(host: '127.0.0.1', port: area.port); + + await HubService.instance.reconnect(endpoint, persist: false); + final healthy = await HubService.instance.healthy(); + if (!healthy) { + // Roll back to the shared hub so Studio isn't stranded. + await switchToShared(); + return _switchResult( + ok: false, + started: started, + error: 'Sealed area "${area.name}" did not respond on ' + '${endpoint.host}:${endpoint.port} after start.', + ); + } + + _activeSealed = area; + _activeSlug = ''; // the shared-hub filter does not apply here + await refresh(); + notifyListeners(); + return _switchResult(started: started); + } + + /// Return to the shared hub from a sealed area. No-op when already + /// on the shared hub. + Future switchToShared() async { + if (_activeSealed == null) return; + _activeSealed = null; + final shared = _sharedEndpoint; + if (shared != null) { + await HubService.instance.reconnect(shared, persist: false); + } else { + // Fall back to re-discovering the installed channel. + await HubService.instance.loadPersistedEndpoint(); + } + await refresh(); + notifyListeners(); + } + + HubEndpoint? _sharedEndpoint; + + Future _reread(String slug) async { + try { + final all = await SealedAreaService.instance.list(); + for (final a in all) { + if (a.slug == slug) return a; + } + } catch (_) {} + return null; + } + + HubEndpoint? _parseEndpoint(String? raw) { + if (raw == null || raw.isEmpty) return null; + final stripped = raw.replaceFirst(RegExp(r'^\w+://'), ''); + final i = stripped.lastIndexOf(':'); + if (i <= 0) return null; + final host = stripped.substring(0, i); + final port = int.tryParse(stripped.substring(i + 1)); + if (port == null) return null; + return HubEndpoint(host: host, port: port); + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1fe3432..9ab0d92 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1684,5 +1684,16 @@ "runsPhaseFailed": "Fehlgeschlagen", "runsPhaseCancelled": "Abgebrochen", "runsPhaseUnknown": "Unbekannt", - "navRuns": "Läufe" + "navRuns": "Läufe", + "workspaceSealedHeader": "ABGESCHOTTETE BEREICHE", + "workspaceSealedRunning": "läuft", + "workspaceSealedStopped": "gestoppt", + "workspaceSealedStarting": "Starte abgeschotteten Bereich „{name}“…", + "@workspaceSealedStarting": {"placeholders": {"name": {"type": "String"}}}, + "workspaceSealedStarted": "Abgeschotteter Bereich „{name}“ gestartet.", + "@workspaceSealedStarted": {"placeholders": {"name": {"type": "String"}}}, + "workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}", + "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, + "sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette", + "sealedLeave": "Verlassen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1ed4d21..8bc20a7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1723,5 +1723,16 @@ "runsPhaseFailed": "Failed", "runsPhaseCancelled": "Cancelled", "runsPhaseUnknown": "Unknown", - "navRuns": "Runs" + "navRuns": "Runs", + "workspaceSealedHeader": "SEALED AREAS", + "workspaceSealedRunning": "running", + "workspaceSealedStopped": "stopped", + "workspaceSealedStarting": "Starting sealed area “{name}”…", + "@workspaceSealedStarting": {"placeholders": {"name": {"type": "String"}}}, + "workspaceSealedStarted": "Started sealed area “{name}”.", + "@workspaceSealedStarted": {"placeholders": {"name": {"type": "String"}}}, + "workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}", + "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, + "sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain", + "sealedLeave": "Leave" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 14bc68f..659c3d0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4998,6 +4998,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Runs'** String get navRuns; + + /// No description provided for @workspaceSealedHeader. + /// + /// In en, this message translates to: + /// **'SEALED AREAS'** + String get workspaceSealedHeader; + + /// No description provided for @workspaceSealedRunning. + /// + /// In en, this message translates to: + /// **'running'** + String get workspaceSealedRunning; + + /// No description provided for @workspaceSealedStopped. + /// + /// In en, this message translates to: + /// **'stopped'** + String get workspaceSealedStopped; + + /// No description provided for @workspaceSealedStarting. + /// + /// In en, this message translates to: + /// **'Starting sealed area “{name}”…'** + String workspaceSealedStarting(String name); + + /// No description provided for @workspaceSealedStarted. + /// + /// In en, this message translates to: + /// **'Started sealed area “{name}”.'** + String workspaceSealedStarted(String name); + + /// No description provided for @workspaceSealedSwitchFailed. + /// + /// In en, this message translates to: + /// **'Could not switch to the sealed area: {error}'** + String workspaceSealedSwitchFailed(String error); + + /// No description provided for @sealedIdentityBar. + /// + /// In en, this message translates to: + /// **'Sealed area — isolated hub, own data and audit chain'** + String get sealedIdentityBar; + + /// No description provided for @sealedLeave. + /// + /// In en, this message translates to: + /// **'Leave'** + String get sealedLeave; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5008dca..afbe907 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2933,4 +2933,35 @@ class AppLocalizationsDe extends AppLocalizations { @override String get navRuns => 'Läufe'; + + @override + String get workspaceSealedHeader => 'ABGESCHOTTETE BEREICHE'; + + @override + String get workspaceSealedRunning => 'läuft'; + + @override + String get workspaceSealedStopped => 'gestoppt'; + + @override + String workspaceSealedStarting(String name) { + return 'Starte abgeschotteten Bereich „$name“…'; + } + + @override + String workspaceSealedStarted(String name) { + return 'Abgeschotteter Bereich „$name“ gestartet.'; + } + + @override + String workspaceSealedSwitchFailed(String error) { + return 'Wechsel in den abgeschotteten Bereich fehlgeschlagen: $error'; + } + + @override + String get sealedIdentityBar => + 'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette'; + + @override + String get sealedLeave => 'Verlassen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a89cd5e..089a5cb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2936,4 +2936,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get navRuns => 'Runs'; + + @override + String get workspaceSealedHeader => 'SEALED AREAS'; + + @override + String get workspaceSealedRunning => 'running'; + + @override + String get workspaceSealedStopped => 'stopped'; + + @override + String workspaceSealedStarting(String name) { + return 'Starting sealed area “$name”…'; + } + + @override + String workspaceSealedStarted(String name) { + return 'Started sealed area “$name”.'; + } + + @override + String workspaceSealedSwitchFailed(String error) { + return 'Could not switch to the sealed area: $error'; + } + + @override + String get sealedIdentityBar => + 'Sealed area — isolated hub, own data and audit chain'; + + @override + String get sealedLeave => 'Leave'; } diff --git a/lib/main.dart b/lib/main.dart index f91d376..0ed86ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -553,6 +553,7 @@ class StudioShellState extends State { Expanded( child: Column( children: [ + const ChainSealedIdentityBar(), if (_hubUnreachable) _HubUnreachableBanner( endpoint: HubService.instance.endpointLabel, diff --git a/lib/widgets/chain_sealed_identity_bar.dart b/lib/widgets/chain_sealed_identity_bar.dart new file mode 100644 index 0000000..75f9d59 --- /dev/null +++ b/lib/widgets/chain_sealed_identity_bar.dart @@ -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), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/chain_workspace_switcher.dart b/lib/widgets/chain_workspace_switcher.dart index 3b51c69..98c0c8e 100644 --- a/lib/widgets/chain_workspace_switcher.dart +++ b/lib/widgets/chain_workspace_switcher.dart @@ -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:` = shared project, +/// `s:` = 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( 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> _items( + BuildContext context, + Workspace ws, + AppLocalizations l, + ThemeData theme, + ) { + final items = >[ + 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 _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); } diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index b9fab1d..e58eca5 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/sealed_identity_bar_test.dart b/test/sealed_identity_bar_test.dart new file mode 100644 index 0000000..3fe49e3 --- /dev/null +++ b/test/sealed_identity_bar_test.dart @@ -0,0 +1,57 @@ +// Sealed identity bar — stage-3 contract: nothing on the shared hub, +// and while connected to a sealed area a strip in the area colour +// naming it + a "Leave" affordance back to the shared hub. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/sealed_areas.dart'; +import 'package:chain_studio/data/workspace.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/widgets/chain_sealed_identity_bar.dart'; + +Widget _host() => const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: ChainSealedIdentityBar()), +); + +const _grid = SealedArea( + slug: 'grid', + name: 'Testbereich Grid', + color: '#e0a458', + port: 51100, + running: true, +); + +void main() { + testWidgets('renders nothing on the shared hub', (tester) async { + Workspace.instance.debugSeed(projects: const [], active: ''); + await tester.pumpWidget(_host()); + expect(find.byIcon(Icons.lock_outline), findsNothing); + expect(find.text('Testbereich Grid'), findsNothing); + }); + + testWidgets('names the active sealed area with a lock + leave action', ( + tester, + ) async { + Workspace.instance.debugSeed( + projects: const [], + active: '', + activeSealed: _grid, + ); + await tester.pumpWidget(_host()); + + expect(find.text('Testbereich Grid'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsOneWidget); + // A one-click way back to the shared hub. + expect(find.byIcon(Icons.logout), findsOneWidget); + + // The strip is painted in the area's accent colour (#e0a458), + // not the app theme — the "which area am I in?" signal. + final material = tester.widget( + find.ancestor(of: find.byIcon(Icons.lock_outline), matching: find.byType(Material)).first, + ); + expect(material.color, const Color(0xFFE0A458)); + }); +} diff --git a/test/workspace_switcher_test.dart b/test/workspace_switcher_test.dart index b791d6a..dcccf62 100644 --- a/test/workspace_switcher_test.dart +++ b/test/workspace_switcher_test.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/sealed_areas.dart'; import 'package:chain_studio/data/workspace.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; @@ -84,4 +85,63 @@ void main() { expect(Workspace.instance.active, isNull); expect(Workspace.instance.isAll, isFalse); }); + + testWidgets('lists sealed areas with lock + running/stopped status', ( + tester, + ) async { + Workspace.instance.debugSeed( + projects: [_general], + active: '', + sealed: const [ + SealedArea( + slug: 'grid', + name: 'Grid', + color: '#e0a458', + port: 51100, + running: true, + ), + SealedArea( + slug: 'bank', + name: 'Bank', + color: '#c25e5e', + port: 51101, + running: false, + ), + ], + ); + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.pumpAndSettle(); + + // Sealed areas appear under the sealed header with a lock icon + // and a running/stopped status word. + expect(find.text('SEALED AREAS'), findsOneWidget); + expect(find.text('Grid'), findsOneWidget); + expect(find.text('Bank'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsWidgets); + expect(find.text('running'), findsOneWidget); + expect(find.text('stopped'), findsOneWidget); + }); + + testWidgets('the pill shows the active sealed area with a lock', ( + tester, + ) async { + Workspace.instance.debugSeed( + projects: [_general], + active: '', + sealed: const [], + activeSealed: const SealedArea( + slug: 'grid', + name: 'Grid', + color: '#e0a458', + port: 51100, + running: true, + ), + ); + await tester.pumpWidget(_host()); + expect(Workspace.instance.inSealedArea, isTrue); + // The closed pill names the sealed area and carries a lock. + expect(find.text('Grid'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsWidgets); + }); }