chain-studio/lib/data/workspace.dart
flemming-it b7468dc7ec
Some checks failed
Security / Security check (push) Failing after 1s
feat: sealed-area connection switch + identity bar (multi-project stage 3)
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>
2026-07-12 17:31:39 +02:00

246 lines
8.3 KiB
Dart

// Workspace — the active project selection + connection shared
// across pages.
//
// 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._();
static final Workspace instance = Workspace._();
static const _prefsKey = 'workspace.active';
/// Registry projects (`general` first, as ordered by the hub).
List<ProjectRef> _projects = const [];
List<ProjectRef> get projects => _projects;
/// Sealed areas discovered under `~/.chain/sealed/` (own hub
/// instances; never in the shared registry).
List<SealedArea> _sealed = const [];
List<SealedArea> 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;
/// The active project's registry entry, or null for "all".
ProjectRef? get active {
if (_activeSlug.isEmpty) return null;
for (final p in _projects) {
if (p.slug == _activeSlug) return p;
}
return null;
}
bool _loaded = false;
/// Restore the persisted filter and pull the registry + sealed
/// list once. Safe to call repeatedly; only the first call
/// restores.
Future<void> ensureLoaded() async {
if (!_loaded) {
_loaded = true;
try {
final prefs = await SharedPreferences.getInstance();
_activeSlug = prefs.getString(_prefsKey) ?? '';
} catch (_) {
// No persisted selection — start on "all projects".
}
}
await refresh();
}
/// 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<void> refresh() async {
var changed = false;
try {
final fresh = await HubService.instance.listProjects();
if (!listEquals(fresh, _projects)) {
_projects = fresh;
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 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<ProjectRef> projects,
required String active,
List<SealedArea> sealed = const [],
SealedArea? activeSealed,
}) {
_projects = projects;
_activeSlug = active;
_sealed = sealed;
_activeSealed = activeSealed;
_loaded = true;
notifyListeners();
}
/// 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<void> setActive(String slug) async {
if (_activeSealed != null) {
await switchToShared();
}
if (slug == _activeSlug) return;
_activeSlug = slug;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, slug);
} catch (_) {
// 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<void>.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<void> 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<SealedArea?> _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);
}
}