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
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -5,6 +5,26 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## 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)
|
### Added (detached-runs monitor — T3 parity)
|
||||||
|
|
||||||
- **Runs page.** A new sidebar destination lists detached invocations
|
- **Runs page.** A new sidebar destination lists detached invocations
|
||||||
|
|
|
||||||
158
lib/data/sealed_areas.dart
Normal file
158
lib/data/sealed_areas.dart
Normal file
|
|
@ -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/<slug>/project.json — the manifest
|
||||||
|
// ~/.chain/run/sealed-<slug>.pid — running PID (if up)
|
||||||
|
// ~/.chain/run/sealed-<slug>.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-<slug>.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<SealedArea>> 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 = <SealedArea>[];
|
||||||
|
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<String, dynamic>;
|
||||||
|
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-<slug>.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<String?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -205,6 +205,17 @@ class SystemActions {
|
||||||
return _runFai(args);
|
return _runFai(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a sealed project's isolated hub instance
|
||||||
|
/// (`chain project start <slug>`). 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(
|
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
||||||
List<String> args,
|
List<String> args,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -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
|
// One switcher in the AppBar drives the whole project experience
|
||||||
// docs/architecture/projects.md in the platform repo, § Studio):
|
// (docs/architecture/projects.md, § Studio). Two mechanics behind
|
||||||
// the page filter (audit, approvals) AND the label new runs are
|
// one control:
|
||||||
// stamped with. "All projects" stays reachable — the selection is
|
//
|
||||||
// a filter, not a jail. Sealed areas are NOT listed here; they are
|
// * open/protected projects → a page filter AND the label new runs
|
||||||
// their own hub instance and arrive with the stage-3 connection
|
// are stamped with, all against the shared hub. "All projects"
|
||||||
// switch.
|
// 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:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'hub.dart';
|
import 'hub.dart';
|
||||||
|
import 'sealed_areas.dart';
|
||||||
|
import 'system_actions.dart';
|
||||||
|
|
||||||
class Workspace extends ChangeNotifier {
|
class Workspace extends ChangeNotifier {
|
||||||
Workspace._();
|
Workspace._();
|
||||||
|
|
@ -23,7 +33,21 @@ class Workspace extends ChangeNotifier {
|
||||||
List<ProjectRef> _projects = const [];
|
List<ProjectRef> _projects = const [];
|
||||||
List<ProjectRef> get projects => _projects;
|
List<ProjectRef> 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<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 _activeSlug = '';
|
||||||
String get activeSlug => _activeSlug;
|
String get activeSlug => _activeSlug;
|
||||||
bool get isAll => _activeSlug.isEmpty;
|
bool get isAll => _activeSlug.isEmpty;
|
||||||
|
|
@ -39,8 +63,9 @@ class Workspace extends ChangeNotifier {
|
||||||
|
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
|
|
||||||
/// Restore the persisted selection and pull the registry once.
|
/// Restore the persisted filter and pull the registry + sealed
|
||||||
/// Safe to call repeatedly; only the first call restores.
|
/// list once. Safe to call repeatedly; only the first call
|
||||||
|
/// restores.
|
||||||
Future<void> ensureLoaded() async {
|
Future<void> ensureLoaded() async {
|
||||||
if (!_loaded) {
|
if (!_loaded) {
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
|
|
@ -54,39 +79,58 @@ class Workspace extends ChangeNotifier {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-pull the registry from the hub. Soft-fails (keeps the
|
/// Re-pull the registry (from the connected hub) and the sealed
|
||||||
/// last list) so a hub restart doesn't blank the switcher; an
|
/// list (from the filesystem). Soft-fails (keeps the last lists)
|
||||||
/// active slug that vanished from the registry stays selected —
|
/// so a hub restart doesn't blank the switcher.
|
||||||
/// its historical rows remain filterable, which is the honest
|
|
||||||
/// behaviour for an audit surface.
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
var changed = false;
|
||||||
try {
|
try {
|
||||||
final fresh = await HubService.instance.listProjects();
|
final fresh = await HubService.instance.listProjects();
|
||||||
if (!listEquals(fresh, _projects)) {
|
if (!listEquals(fresh, _projects)) {
|
||||||
_projects = fresh;
|
_projects = fresh;
|
||||||
notifyListeners();
|
changed = true;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Hub unreachable — the pages already surface that state.
|
// 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
|
/// Test hook: seed the lists + selection without a hub/filesystem
|
||||||
/// the persisted state as restored so [ensureLoaded] won't
|
/// and mark the state as restored so [ensureLoaded] won't
|
||||||
/// overwrite the seed.
|
/// overwrite the seed.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugSeed({
|
void debugSeed({
|
||||||
required List<ProjectRef> projects,
|
required List<ProjectRef> projects,
|
||||||
required String active,
|
required String active,
|
||||||
|
List<SealedArea> sealed = const [],
|
||||||
|
SealedArea? activeSealed,
|
||||||
}) {
|
}) {
|
||||||
_projects = projects;
|
_projects = projects;
|
||||||
_activeSlug = active;
|
_activeSlug = active;
|
||||||
|
_sealed = sealed;
|
||||||
|
_activeSealed = activeSealed;
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
notifyListeners();
|
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<void> setActive(String slug) async {
|
Future<void> setActive(String slug) async {
|
||||||
|
if (_activeSealed != null) {
|
||||||
|
await switchToShared();
|
||||||
|
}
|
||||||
if (slug == _activeSlug) return;
|
if (slug == _activeSlug) return;
|
||||||
_activeSlug = slug;
|
_activeSlug = slug;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -97,4 +141,106 @@ class Workspace extends ChangeNotifier {
|
||||||
// Persistence is best-effort; the in-memory switch stands.
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1684,5 +1684,16 @@
|
||||||
"runsPhaseFailed": "Fehlgeschlagen",
|
"runsPhaseFailed": "Fehlgeschlagen",
|
||||||
"runsPhaseCancelled": "Abgebrochen",
|
"runsPhaseCancelled": "Abgebrochen",
|
||||||
"runsPhaseUnknown": "Unbekannt",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1723,5 +1723,16 @@
|
||||||
"runsPhaseFailed": "Failed",
|
"runsPhaseFailed": "Failed",
|
||||||
"runsPhaseCancelled": "Cancelled",
|
"runsPhaseCancelled": "Cancelled",
|
||||||
"runsPhaseUnknown": "Unknown",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4998,6 +4998,54 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Runs'**
|
/// **'Runs'**
|
||||||
String get navRuns;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2933,4 +2933,35 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navRuns => 'Läufe';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2936,4 +2936,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navRuns => 'Runs';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const ChainSealedIdentityBar(),
|
||||||
if (_hubUnreachable)
|
if (_hubUnreachable)
|
||||||
_HubUnreachableBanner(
|
_HubUnreachableBanner(
|
||||||
endpoint: HubService.instance.endpointLabel,
|
endpoint: HubService.instance.endpointLabel,
|
||||||
|
|
|
||||||
75
lib/widgets/chain_sealed_identity_bar.dart
Normal file
75
lib/widgets/chain_sealed_identity_bar.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,19 +3,25 @@
|
||||||
// One control, two mechanics (docs/architecture/projects.md,
|
// One control, two mechanics (docs/architecture/projects.md,
|
||||||
// § Studio): for open/protected projects the choice sets the page
|
// § Studio): for open/protected projects the choice sets the page
|
||||||
// filter AND the label new runs are stamped with. "All projects"
|
// filter AND the label new runs are stamped with. "All projects"
|
||||||
// stays reachable — a filter, not a jail. Sealed areas never
|
// stays reachable — a filter, not a jail. Sealed areas are a real
|
||||||
// appear here (own hub instance; stage-3 connection switch).
|
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
||||||
//
|
// own hub (starting it first if stopped, with a notice) and colours
|
||||||
// The project colour is a marking dot only — Studio's blue stays
|
// the identity bar. Studio's blue stays the app accent — the project
|
||||||
// the app accent (registered design deviation).
|
// colour is marking, not theming.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/sealed_areas.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/tokens.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 {
|
class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
const ChainWorkspaceSwitcher({super.key});
|
const ChainWorkspaceSwitcher({super.key});
|
||||||
|
|
||||||
|
|
@ -27,19 +33,33 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final ws = Workspace.instance;
|
final ws = Workspace.instance;
|
||||||
final active = ws.active;
|
|
||||||
final label = ws.isAll
|
final label = ws.inSealedArea
|
||||||
? l.workspaceAll
|
? ws.activeSealed!.name
|
||||||
: (active?.name ?? ws.activeSlug);
|
: (ws.isAll ? l.workspaceAll : (ws.active?.name ?? ws.activeSlug));
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: l.workspaceSwitcherTooltip,
|
message: l.workspaceSwitcherTooltip,
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
onOpened: ws.refresh,
|
onOpened: ws.refresh,
|
||||||
onSelected: ws.setActive,
|
onSelected: (v) => _onSelected(context, v),
|
||||||
itemBuilder: (context) => [
|
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(
|
PopupMenuItem(
|
||||||
value: '',
|
value: _kAll,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -52,17 +72,17 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (ws.projects.isNotEmpty) const PopupMenuDivider(),
|
];
|
||||||
for (final p in ws.projects)
|
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
|
||||||
|
for (final p in ws.projects) {
|
||||||
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: p.slug,
|
value: '$_pProject${p.slug}',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ProjectDot(project: p),
|
_ProjectDot(color: p.color),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Flexible(
|
Flexible(child: Text(p.name, overflow: TextOverflow.ellipsis)),
|
||||||
child: Text(p.name, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
if (p.isProtected) ...[
|
if (p.isProtected) ...[
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
|
|
@ -77,12 +97,139 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
child: Container(
|
}
|
||||||
padding: const EdgeInsets.symmetric(
|
if (ws.sealedAreas.isNotEmpty) {
|
||||||
horizontal: ChainSpace.md,
|
items.add(const PopupMenuDivider());
|
||||||
vertical: 5,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
|
|
@ -90,17 +237,7 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (active != null) ...[
|
leading,
|
||||||
_ProjectDot(project: active),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
] else ...[
|
|
||||||
Icon(
|
|
||||||
Icons.grid_view_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
],
|
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 160),
|
constraints: const BoxConstraints(maxWidth: 160),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -109,7 +246,7 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
style: theme.textTheme.labelMedium,
|
style: theme.textTheme.labelMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (active?.isProtected ?? false) ...[
|
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.shield_outlined,
|
Icons.shield_outlined,
|
||||||
|
|
@ -125,20 +262,16 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The project's registry colour as a small marking dot. Falls
|
/// The project's registry colour as a small marking dot. Falls back
|
||||||
/// back to the theme's outline colour when the project has none.
|
/// to the theme's outline colour when unset.
|
||||||
class _ProjectDot extends StatelessWidget {
|
class _ProjectDot extends StatelessWidget {
|
||||||
final ProjectRef project;
|
final String color;
|
||||||
|
|
||||||
const _ProjectDot({required this.project});
|
const _ProjectDot({required this.color});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -147,17 +280,18 @@ class _ProjectDot extends StatelessWidget {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _parseHex(project.color) ?? theme.colorScheme.outline,
|
color: parseAreaColor(color) ?? theme.colorScheme.outline,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Color? _parseHex(String hex) {
|
/// Parse a `#rrggbb` hex colour, or null when unparseable.
|
||||||
|
Color? parseAreaColor(String hex) {
|
||||||
final h = hex.replaceFirst('#', '');
|
final h = hex.replaceFirst('#', '');
|
||||||
if (h.length != 6) return null;
|
if (h.length != 6) return null;
|
||||||
final v = int.tryParse(h, radix: 16);
|
final v = int.tryParse(h, radix: 16);
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
return Color(0xFF000000 | v);
|
return Color(0xFF000000 | v);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@ export 'chain_settings_dialog.dart';
|
||||||
export 'chain_stores_dialog.dart';
|
export 'chain_stores_dialog.dart';
|
||||||
export 'chain_status_dot.dart';
|
export 'chain_status_dot.dart';
|
||||||
export 'chain_system_ai_editor.dart';
|
export 'chain_system_ai_editor.dart';
|
||||||
|
export 'chain_sealed_identity_bar.dart';
|
||||||
export 'chain_workspace_switcher.dart';
|
export 'chain_workspace_switcher.dart';
|
||||||
|
|
|
||||||
57
test/sealed_identity_bar_test.dart
Normal file
57
test/sealed_identity_bar_test.dart
Normal file
|
|
@ -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<Material>(
|
||||||
|
find.ancestor(of: find.byIcon(Icons.lock_outline), matching: find.byType(Material)).first,
|
||||||
|
);
|
||||||
|
expect(material.color, const Color(0xFFE0A458));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:chain_studio/data/hub.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/data/workspace.dart';
|
||||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
||||||
|
|
@ -84,4 +85,63 @@ void main() {
|
||||||
expect(Workspace.instance.active, isNull);
|
expect(Workspace.instance.active, isNull);
|
||||||
expect(Workspace.instance.isAll, isFalse);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue