From 2592a23cc017d1dad0970a083b0494a07241def5 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 13:49:51 +0200 Subject: [PATCH] feat: workspace switcher, per-project filters and run stamping (stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-project stage 1 against the shared hub (platform design docs/architecture/projects.md, § Studio): - ChainWorkspaceSwitcher in the Audit + Approvals AppBars: lists the registry (colour dot per project, shield for protected, honesty tooltip), 'All projects' stays reachable — a filter, not a jail. Selection is persisted and shared via the Workspace notifier. - Audit page: list query AND live stream re-scoped hub-side on switch. - Approvals page: pending + history scoped; the sidebar badge counts the active workspace's pending approvals. - Flow runs are stamped with the active workspace; a flow file carrying its own project: keeps it (file wins, CLI semantics). - Data layer: listProjects/ProjectRef; project fields on AuditEvent, PendingApproval(+Record), SavedFlow; project params through HubService. l10n DE+EN. Widget tests for the switcher contract. Visual verification (light+dark screenshots) still pending — the shared desktop was in active use; code paths are covered by flutter test (26 green). Signed-off-by: flemming-it --- lib/data/flow_run_driver.dart | 21 +++ lib/data/hub.dart | 101 +++++++++++++- lib/data/workspace.dart | 100 +++++++++++++ lib/l10n/app_de.arb | 5 +- lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_de.dart | 11 ++ lib/l10n/app_localizations_en.dart | 11 ++ lib/main.dart | 7 +- lib/pages/approvals.dart | 10 ++ lib/pages/audit.dart | 23 ++- lib/widgets/chain_workspace_switcher.dart | 163 ++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + test/workspace_switcher_test.dart | 87 ++++++++++++ 14 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 lib/data/workspace.dart create mode 100644 lib/widgets/chain_workspace_switcher.dart create mode 100644 test/workspace_switcher_test.dart diff --git a/lib/data/flow_run_driver.dart b/lib/data/flow_run_driver.dart index 43e8c32..ae9e46d 100644 --- a/lib/data/flow_run_driver.dart +++ b/lib/data/flow_run_driver.dart @@ -16,6 +16,7 @@ import 'dart:typed_data'; import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart' as editor; import 'hub.dart'; +import 'workspace.dart'; class StudioFlowRunDriver implements editor.FlowRunDriver { StudioFlowRunDriver(); @@ -27,11 +28,31 @@ class StudioFlowRunDriver implements editor.FlowRunDriver { required Map fileInputs, required Map fileMimes, }) async { + // Stamp the run with the active workspace — but the file wins: + // a flow carrying its own `project:` keeps it (same semantics + // as the CLI), so the override is only sent when the flow file + // has none. Resolution order hub-side is request > file > general. + var project = Workspace.instance.activeSlug; + if (project.isNotEmpty) { + try { + final flows = await HubService.instance.listFlows(); + for (final f in flows) { + if (f.name == flowName && f.project.isNotEmpty) { + project = ''; + break; + } + } + } catch (_) { + // Lookup failed — keep the workspace label. An unreachable + // hub fails the run itself a moment later anyway. + } + } final outputs = await HubService.instance.runSavedFlow( name: flowName, textInputs: textInputs, fileInputs: fileInputs, fileMimeTypes: fileMimes, + project: project, ); final mapped = {}; for (final entry in outputs.entries) { diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a10f431..2578d81 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -808,6 +808,25 @@ class HubService { return (name: r.name, version: r.version); } + /// The shared hub's project registry (`general` first) — the + /// lightweight labels grouping flows, runs, approvals and audit + /// events. Sealed areas never appear here: they are their own + /// hub instance Studio connects to directly (stage 3). + Future> listProjects() async { + final projects = await _client.listProjects(); + return projects + .map( + (p) => ProjectRef( + slug: p.slug, + name: p.name, + color: p.color, + description: p.description, + isolation: p.isolation, + ), + ) + .toList(); + } + /// Saved flows known to the hub. Future> listFlows() async { final flows = await _client.listFlows(); @@ -818,6 +837,7 @@ class HubService { path: f.path, sizeBytes: f.sizeBytes.toInt(), requiredCapabilities: List.from(f.requiredCapabilities), + project: f.project, ), ) .toList() @@ -849,12 +869,14 @@ class HubService { Map textInputs = const {}, Map fileInputs = const {}, Map fileMimeTypes = const {}, + String project = '', }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, fileInputs: fileInputs, fileMimeTypes: fileMimeTypes, + project: project, ); return { for (final entry in r.outputs.entries) @@ -874,9 +896,10 @@ class HubService { Stream streamEvents({ int backfill = 0, List types = const [], + String project = '', }) { return _client - .streamEvents(backfill: backfill, types: types) + .streamEvents(backfill: backfill, types: types, project: project) .map( (e) => AuditEvent( eventId: e.eventId, @@ -891,6 +914,7 @@ class HubService { durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, detail: e.detail.isEmpty ? null : e.detail, + project: e.project, ), ); } @@ -898,8 +922,13 @@ class HubService { Future> recentEvents({ int limit = 50, List types = const [], + String project = '', }) async { - final events = await _client.eventLog(limit: limit, types: types); + final events = await _client.eventLog( + limit: limit, + types: types, + project: project, + ); return events .map( (e) => AuditEvent( @@ -915,13 +944,17 @@ class HubService { durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, detail: e.detail.isEmpty ? null : e.detail, + project: e.project, ), ) .toList(); } - Future> pendingApprovals() async { - final entries = await _client.listApprovals(statuses: ['pending']); + Future> pendingApprovals({String project = ''}) async { + final entries = await _client.listApprovals( + statuses: ['pending'], + project: project, + ); return entries .map( (e) => PendingApproval( @@ -934,6 +967,7 @@ class HubService { expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), + project: e.project, ), ) .toList(); @@ -946,10 +980,12 @@ class HubService { Future> listApprovalsRecords({ List statuses = const [], int limit = 200, + String project = '', }) async { final entries = await _client.listApprovals( statuses: statuses, limit: limit, + project: project, ); return entries .map( @@ -969,6 +1005,7 @@ class HubService { : DateTime.tryParse(e.decidedAt), decidedBy: e.decidedBy, reason: e.reason, + project: e.project, ), ) .toList(); @@ -1231,6 +1268,43 @@ class ModuleSummary { }); } +/// One registered project (shared-hub registry row): the stable +/// slug plus renamable presentation metadata. `isolation` is +/// `open` (pure label) or `protected` (logically separated — no +/// hard process barrier; every UI keeps that honest wording). +class ProjectRef { + final String slug; + final String name; + + /// Accent colour hex (e.g. `#8b7cf6`), empty when unset. Used + /// as a marking dot — never as the app theme. + final String color; + final String description; + final String isolation; + + const ProjectRef({ + required this.slug, + required this.name, + this.color = '', + this.description = '', + this.isolation = 'open', + }); + + bool get isProtected => isolation == 'protected'; + + @override + bool operator ==(Object other) => + other is ProjectRef && + other.slug == slug && + other.name == name && + other.color == color && + other.description == description && + other.isolation == isolation; + + @override + int get hashCode => Object.hash(slug, name, color, description, isolation); +} + class SavedFlow { final String name; final String path; @@ -1242,11 +1316,17 @@ class SavedFlow { /// resolves to an installed module). final List requiredCapabilities; + /// The flow file's own top-level `project:` slug; empty when + /// the YAML has none (a run then lands in the caller's + /// project, then `general`). + final String project; + const SavedFlow({ required this.name, required this.path, required this.sizeBytes, required this.requiredCapabilities, + this.project = '', }); } @@ -1335,6 +1415,10 @@ class AuditEvent { /// Surfaced verbatim in the audit drill-down dialog. final String? detail; + /// Project slug the run was stamped with (bound into the V4 + /// audit hash). Empty on rows pre-dating the project migration. + final String project; + const AuditEvent({ required this.eventId, required this.timestamp, @@ -1348,6 +1432,7 @@ class AuditEvent { this.durationMs, this.error, this.detail, + this.project = '', }); } @@ -1360,6 +1445,9 @@ class PendingApproval { final DateTime createdAt; final DateTime? expiresAt; + /// Project slug the requesting run was stamped with. + final String project; + const PendingApproval({ required this.id, required this.flowName, @@ -1368,6 +1456,7 @@ class PendingApproval { this.showPreview, required this.createdAt, this.expiresAt, + this.project = '', }); } @@ -1439,6 +1528,9 @@ class ApprovalRecord { /// Reject reason — empty for approve / pending / expired. final String reason; + /// Project slug the requesting run was stamped with. + final String project; + const ApprovalRecord({ required this.id, required this.flowName, @@ -1451,6 +1543,7 @@ class ApprovalRecord { required this.decidedAt, required this.decidedBy, required this.reason, + this.project = '', }); } diff --git a/lib/data/workspace.dart b/lib/data/workspace.dart new file mode 100644 index 0000000..5b37ffd --- /dev/null +++ b/lib/data/workspace.dart @@ -0,0 +1,100 @@ +// Workspace — the active project selection 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. + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'hub.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; + + /// Active project slug; empty = all projects (no filter). + 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 selection and pull the registry 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 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. + Future refresh() async { + try { + final fresh = await HubService.instance.listProjects(); + if (!listEquals(fresh, _projects)) { + _projects = fresh; + notifyListeners(); + } + } catch (_) { + // Hub unreachable — the pages already surface that state. + } + } + + /// Test hook: seed registry + selection without a hub and mark + /// the persisted state as restored so [ensureLoaded] won't + /// overwrite the seed. + @visibleForTesting + void debugSeed({ + required List projects, + required String active, + }) { + _projects = projects; + _activeSlug = active; + _loaded = true; + notifyListeners(); + } + + /// Switch the active workspace ('' = all projects) and persist. + Future setActive(String slug) async { + 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. + } + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d62b38f..860d2ff 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1664,5 +1664,8 @@ "federationTokenLabel": "Bootstrap-Token (einmalig)", "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", "federationCopied": "In die Zwischenablage kopiert", - "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten." + "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.", + "workspaceAll": "Alle Projekte", + "workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt", + "workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e781046..28ac56a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1703,5 +1703,8 @@ "federationTokenLabel": "Bootstrap token (single use)", "federationConfigLabel": "Satellite config (paste into the satellite)", "federationCopied": "Copied to clipboard", - "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect." + "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.", + "workspaceAll": "All projects", + "workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project", + "workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 74e3d5c..05a5ffc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4890,6 +4890,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'** String get federationEnrollmentHint; + + /// No description provided for @workspaceAll. + /// + /// In en, this message translates to: + /// **'All projects'** + String get workspaceAll; + + /// No description provided for @workspaceSwitcherTooltip. + /// + /// In en, this message translates to: + /// **'Workspace — filters this view and stamps new runs with the selected project'** + String get workspaceSwitcherTooltip; + + /// No description provided for @workspaceProtectedHint. + /// + /// In en, this message translates to: + /// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'** + String get workspaceProtectedHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d7ddf32..640e462 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2871,4 +2871,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get federationEnrollmentHint => 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.'; + + @override + String get workspaceAll => 'Alle Projekte'; + + @override + String get workspaceSwitcherTooltip => + 'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt'; + + @override + String get workspaceProtectedHint => + 'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 60802b4..5a8de81 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2874,4 +2874,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get federationEnrollmentHint => 'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'; + + @override + String get workspaceAll => 'All projects'; + + @override + String get workspaceSwitcherTooltip => + 'Workspace — filters this view and stamps new runs with the selected project'; + + @override + String get workspaceProtectedHint => + 'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'; } diff --git a/lib/main.dart b/lib/main.dart index 71a3cf0..b5ff834 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; import 'data/chain_log.dart'; import 'data/error_presentation.dart'; import 'data/hub.dart'; +import 'data/workspace.dart'; import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; import 'l10n/app_localizations.dart'; @@ -425,7 +426,11 @@ class StudioShellState extends State { } } catch (_) {/* best-effort */} try { - final pending = await HubService.instance.pendingApprovals(); + // The badge counts the active workspace's pending approvals + // (empty slug = all projects), matching the Approvals page. + final pending = await HubService.instance.pendingApprovals( + project: Workspace.instance.activeSlug, + ); if (!mounted) return; if (_pendingApprovals != pending.length) { setState(() => _pendingApprovals = pending.length); diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 9248df8..c98f5ed 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; @@ -63,23 +64,30 @@ class _ApprovalsPageState extends State void initState() { super.initState(); _tab = TabController(length: 2, vsync: this); + Workspace.instance.addListener(_refresh); + Workspace.instance.ensureLoaded(); _refresh(); } @override void dispose() { + Workspace.instance.removeListener(_refresh); _tab.dispose(); super.dispose(); } void _refresh() { + if (!mounted) return; + final project = Workspace.instance.activeSlug; setState(() { _pendingFuture = HubService.instance.listApprovalsRecords( statuses: const ['pending'], + project: project, ); _historyFuture = HubService.instance.listApprovalsRecords( statuses: const ['approved', 'rejected', 'expired'], limit: 200, + project: project, ); }); } @@ -256,6 +264,8 @@ class _ApprovalsPageState extends State ], ), actions: [ + const ChainWorkspaceSwitcher(), + const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 1ba2629..f9977d4 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/hub.dart'; +import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; @@ -38,6 +39,8 @@ class _AuditPageState extends State { @override void initState() { super.initState(); + Workspace.instance.addListener(_onWorkspaceChanged); + Workspace.instance.ensureLoaded(); _refresh(); _poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh()); _subscribeLive(); @@ -45,15 +48,26 @@ class _AuditPageState extends State { @override void dispose() { + Workspace.instance.removeListener(_onWorkspaceChanged); _poller?.cancel(); _nudgeDebounce?.cancel(); _eventSub?.cancel(); super.dispose(); } + /// The workspace filter is applied hub-side, so both the list + /// query and the live stream have to be re-established. + void _onWorkspaceChanged() { + if (!mounted) return; + _refresh(); + _subscribeLive(); + } + void _subscribeLive() { _eventSub?.cancel(); - _eventSub = HubService.instance.streamEvents(backfill: 0).listen( + _eventSub = HubService.instance + .streamEvents(backfill: 0, project: Workspace.instance.activeSlug) + .listen( (_) => _nudge(), // On a persistent error the 2 s poll keeps the page fresh, so we // do nothing. On a clean close (hub restart / stream end) try to @@ -108,7 +122,10 @@ class _AuditPageState extends State { Future _refresh() async { try { - final events = await HubService.instance.recentEvents(limit: 100); + final events = await HubService.instance.recentEvents( + limit: 100, + project: Workspace.instance.activeSlug, + ); if (!mounted) return; setState(() { _events = events; @@ -136,6 +153,8 @@ class _AuditPageState extends State { appBar: AppBar( title: Text(AppLocalizations.of(context)!.auditTitle), actions: [ + const ChainWorkspaceSwitcher(), + const SizedBox(width: ChainSpace.md), Padding( padding: const EdgeInsets.only(right: ChainSpace.lg), child: _FilterChips( diff --git a/lib/widgets/chain_workspace_switcher.dart b/lib/widgets/chain_workspace_switcher.dart new file mode 100644 index 0000000..3b51c69 --- /dev/null +++ b/lib/widgets/chain_workspace_switcher.dart @@ -0,0 +1,163 @@ +// ChainWorkspaceSwitcher — the AppBar workspace (project) control. +// +// 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). + +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../data/workspace.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class ChainWorkspaceSwitcher extends StatelessWidget { + const ChainWorkspaceSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Workspace.instance, + builder: (context, _) { + 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); + + 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, + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// The project's registry colour as a small marking dot. Falls +/// back to the theme's outline colour when the project has none. +class _ProjectDot extends StatelessWidget { + final ProjectRef project; + + const _ProjectDot({required this.project}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _parseHex(project.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); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 6c8ca68..b9fab1d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -19,3 +19,4 @@ export 'chain_settings_dialog.dart'; export 'chain_stores_dialog.dart'; export 'chain_status_dot.dart'; export 'chain_system_ai_editor.dart'; +export 'chain_workspace_switcher.dart'; diff --git a/test/workspace_switcher_test.dart b/test/workspace_switcher_test.dart new file mode 100644 index 0000000..b791d6a --- /dev/null +++ b/test/workspace_switcher_test.dart @@ -0,0 +1,87 @@ +// Workspace switcher — stage-1 contract: the control renders the +// active selection, lists "All projects" plus every registry +// project (colour dot, shield for protected), and switching +// updates the shared Workspace notifier. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.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'; + +Widget _host() { + return const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: Material(child: ChainWorkspaceSwitcher()), + ), + body: SizedBox(), + ), + ); +} + +const _general = ProjectRef(slug: 'general', name: 'General'); +const _clientA = ProjectRef( + slug: 'client-a', + name: 'Client A', + color: '#8b7cf6', + isolation: 'protected', +); + +void main() { + setUp(() { + // Seed the singleton without a hub: tests drive the notifier + // directly through its test hook. + Workspace.instance.debugSeed(projects: [_general, _clientA], active: ''); + }); + + testWidgets('shows "All projects" when no project is active', ( + tester, + ) async { + await tester.pumpWidget(_host()); + expect(find.text('All projects'), findsOneWidget); + }); + + testWidgets('menu lists all-projects entry plus every registry project', ( + tester, + ) async { + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.pumpAndSettle(); + + expect(find.text('All projects'), findsWidgets); + expect(find.text('General'), findsOneWidget); + expect(find.text('Client A'), findsOneWidget); + // Protected projects carry the shield marker. + expect(find.byIcon(Icons.shield_outlined), findsOneWidget); + }); + + testWidgets('selecting a project updates the workspace and the label', ( + tester, + ) async { + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Client A').last); + await tester.pumpAndSettle(); + + expect(Workspace.instance.activeSlug, 'client-a'); + expect(Workspace.instance.active?.isProtected, isTrue); + // The closed control now shows the active project (label + + // shield marker for protected). + expect(find.text('Client A'), findsOneWidget); + expect(find.byIcon(Icons.shield_outlined), findsOneWidget); + }); + + test('active falls back to null when the slug left the registry', () { + Workspace.instance.debugSeed(projects: [_general], active: 'gone'); + expect(Workspace.instance.activeSlug, 'gone'); + expect(Workspace.instance.active, isNull); + expect(Workspace.instance.isAll, isFalse); + }); +}