feat: workspace switcher, per-project filters and run stamping (stage 1)
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
7a38cd58aa
commit
2592a23cc0
14 changed files with 554 additions and 9 deletions
|
|
@ -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<String, Uint8List> fileInputs,
|
||||
required Map<String, String> 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 = <String, editor.FlowOutputValue>{};
|
||||
for (final entry in outputs.entries) {
|
||||
|
|
|
|||
|
|
@ -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<List<ProjectRef>> 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<List<SavedFlow>> listFlows() async {
|
||||
final flows = await _client.listFlows();
|
||||
|
|
@ -818,6 +837,7 @@ class HubService {
|
|||
path: f.path,
|
||||
sizeBytes: f.sizeBytes.toInt(),
|
||||
requiredCapabilities: List<String>.from(f.requiredCapabilities),
|
||||
project: f.project,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
|
|
@ -849,12 +869,14 @@ class HubService {
|
|||
Map<String, String> textInputs = const {},
|
||||
Map<String, Uint8List> fileInputs = const {},
|
||||
Map<String, String> 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<AuditEvent> streamEvents({
|
||||
int backfill = 0,
|
||||
List<String> 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<List<AuditEvent>> recentEvents({
|
||||
int limit = 50,
|
||||
List<String> 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<List<PendingApproval>> pendingApprovals() async {
|
||||
final entries = await _client.listApprovals(statuses: ['pending']);
|
||||
Future<List<PendingApproval>> 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<List<ApprovalRecord>> listApprovalsRecords({
|
||||
List<String> 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<String> 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 = '',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
100
lib/data/workspace.dart
Normal file
100
lib/data/workspace.dart
Normal file
|
|
@ -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<ProjectRef> _projects = const [];
|
||||
List<ProjectRef> 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<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 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<void> 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<ProjectRef> projects,
|
||||
required String active,
|
||||
}) {
|
||||
_projects = projects;
|
||||
_activeSlug = active;
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Switch the active workspace ('' = all projects) and persist.
|
||||
Future<void> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StudioShell> {
|
|||
}
|
||||
} 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);
|
||||
|
|
|
|||
|
|
@ -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<ApprovalsPage>
|
|||
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<ApprovalsPage>
|
|||
],
|
||||
),
|
||||
actions: [
|
||||
const ChainWorkspaceSwitcher(),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline, size: 18),
|
||||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||||
|
|
|
|||
|
|
@ -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<AuditPage> {
|
|||
@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<AuditPage> {
|
|||
|
||||
@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<AuditPage> {
|
|||
|
||||
Future<void> _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<AuditPage> {
|
|||
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(
|
||||
|
|
|
|||
163
lib/widgets/chain_workspace_switcher.dart
Normal file
163
lib/widgets/chain_workspace_switcher.dart
Normal file
|
|
@ -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<String>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue