// 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 _projects = const []; List get projects => _projects; /// 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; /// 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 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 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 projects, required String active, List 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 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.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); } }