From 9e73036d0e9507d4ec0e711af85a489f00b14d17 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 14:20:15 +0200 Subject: [PATCH] feat: project chip + file-wins guard (multi-project stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the open flow declares its own top-level project:, show it as a chip in the tab action strip. On mismatch with the host's active workspace, an amber file-wins pill offers a one-click Switch to — the file always wins for the run; switching only aligns the workspace view. New optional FlowEditorPage.activeProject + onSwitchToFileProject. Parse + normalize (mirrors the hub's normalize_project_slug) lives in flow_project.dart, unit-tested. flutter analyze clean, 57 tests green. Signed-off-by: flemming-it --- CHANGELOG.md | 14 ++++ lib/src/flow_editor_page.dart | 119 ++++++++++++++++++++++++++++++++++ lib/src/flow_project.dart | 50 ++++++++++++++ lib/src/l10n.dart | 22 +++++++ pubspec.yaml | 2 +- test/flow_project_test.dart | 47 ++++++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 lib/src/flow_project.dart create mode 100644 test/flow_project_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a68713d..140b62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `chain_studio_flow_editor` recorded here. Studio bumps its `pubspec.yaml` git ref to a specific release of this package on every editor change. +## 0.22.0 — 2026-07-12 + +### Added + +- **Project chip (multi-project stage ②).** When the open flow file + declares its own top-level `project:`, the editor shows it as a chip + in the tab action strip. If the file's project differs from the host's + active workspace, the chip becomes an amber "file wins" pill with a + one-click *Switch to * action — the file always wins for the + run; switching only aligns the workspace view. New optional + `FlowEditorPage.activeProject` + `onSwitchToFileProject`; the parse + + normalize logic (mirrors the hub's `normalize_project_slug`) lives in + `flow_project.dart` and is unit-tested. + ## 0.21.2 — 2026-07-11 ### Added diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index a80feaf..176c681 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -31,6 +31,7 @@ import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'editor_controller.dart'; import 'editor_style.dart'; +import 'flow_project.dart'; import 'flow_yaml_controller.dart'; import 'l10n.dart'; import 'model/flow_graph.dart'; @@ -100,6 +101,19 @@ class FlowEditorPage extends StatefulWidget { /// silent — no install button offered. final List storeCapabilities; + /// The host's active workspace/project slug (empty = "all + /// projects", no active workspace). Used only to flag a + /// mismatch when the open flow file declares a *different* + /// top-level `project:` — the file always wins for the run; + /// the mismatch banner just offers to align the workspace view. + final String activeProject; + + /// Invoked when the operator accepts the "switch to the file's + /// project" action on a mismatch. The host switches its active + /// workspace to the given slug. `null` hides the switch action + /// (the mismatch note still shows — the file still wins). + final void Function(String fileProject)? onSwitchToFileProject; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -110,6 +124,8 @@ class FlowEditorPage extends StatefulWidget { this.onInstallCapability, this.onAddModuleSource, this.storeCapabilities = const [], + this.activeProject = '', + this.onSwitchToFileProject, }); @override @@ -170,6 +186,14 @@ class _FlowEditorPageState extends State if (mounted) setState(() {}); } + /// The open flow's own top-level `project:` slug, or empty when + /// the YAML declares none. Parsed live from the buffer so the + /// chip tracks edits. + String _fileProject() { + if (_controller.activeName == null) return ''; + return parseFlowProject(_controller.codeController.fullText); + } + void _onHoverChanged() { final req = _controller.codeController.hoverRequest.value; if (req == null) { @@ -617,6 +641,9 @@ outputs: _controller.analyzerErrorCount == 0 ? () => _tabs.animateTo(2) : null, + fileProject: _fileProject(), + activeProject: widget.activeProject, + onSwitchToFileProject: widget.onSwitchToFileProject, ), Expanded( child: TabBarView( @@ -1014,6 +1041,13 @@ class _TabActionStrip extends StatelessWidget { final VoidCallback? onAddStep; final VoidCallback? onSave; final VoidCallback? onRun; + + /// The open file's own `project:` (empty when it declares none). + final String fileProject; + + /// The host's active workspace slug (empty = all projects). + final String activeProject; + final void Function(String fileProject)? onSwitchToFileProject; const _TabActionStrip({ required this.strings, required this.tabs, @@ -1023,6 +1057,9 @@ class _TabActionStrip extends StatelessWidget { required this.onAddStep, required this.onSave, required this.onRun, + required this.fileProject, + required this.activeProject, + required this.onSwitchToFileProject, }); @override @@ -1093,12 +1130,94 @@ class _TabActionStrip extends StatelessWidget { ), ], const Spacer(), + ..._projectChip(context, theme), ], ), ); }, ); } + + /// The right-aligned project affordance. Nothing when the file + /// declares no `project:`. A neutral chip when the file's project + /// matches (or there is no active workspace). A mismatch pill with + /// a one-click switch when the file's project differs from the + /// active workspace — the file always wins for the run; switching + /// only aligns the workspace view. + List _projectChip(BuildContext context, ThemeData theme) { + if (fileProject.isEmpty) return const []; + final mismatch = activeProject.isNotEmpty && activeProject != fileProject; + if (!mismatch) { + return [ + Tooltip( + message: strings.projectChipTooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + strings.projectChip(fileProject), + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + ), + ]; + } + // Mismatch: file wins. Amber pill + optional switch action. + final amber = theme.colorScheme.brightness == Brightness.dark + ? const Color(0xFFE0A458) + : const Color(0xFFB4791F); + return [ + Tooltip( + message: strings.projectMismatch(fileProject), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: amber.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 13, color: amber), + const SizedBox(width: 6), + Text( + strings.projectChip(fileProject), + style: theme.textTheme.labelSmall?.copyWith(color: amber), + ), + if (onSwitchToFileProject != null) ...[ + const SizedBox(width: 8), + InkWell( + onTap: () => onSwitchToFileProject!(fileProject), + child: Text( + strings.projectMismatchSwitch(fileProject), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ), + ]; + } } // --- file list --- diff --git a/lib/src/flow_project.dart b/lib/src/flow_project.dart new file mode 100644 index 0000000..74385c8 --- /dev/null +++ b/lib/src/flow_project.dart @@ -0,0 +1,50 @@ +/// Parsing + normalization for a flow file's own top-level +/// `project:` key. Kept separate from the page so the "file wins" +/// semantics are unit-testable without pumping the whole editor. +/// +/// Mirrors the hub's `resolve_project` (file's `project:` is the +/// run's project unless the caller overrides it) and +/// `normalize_project_slug`, so the chip the editor shows compares +/// equal to what the hub actually stamps. +library; + +/// The flow's own top-level `project:` slug (normalized), or empty +/// when the YAML declares none. Only a *non-indented* `project:` +/// line counts — a nested `project:` inside a step's `with:` block +/// is ignored. +String parseFlowProject(String yaml) { + for (final raw in yaml.split('\n')) { + if (raw.startsWith(' ') || raw.startsWith('\t')) continue; + final m = RegExp(r'^project:\s*(.+?)\s*$').firstMatch(raw); + if (m != null) { + final value = m.group(1)!.replaceAll(RegExp('''^["']|["']\$'''), ''); + return normalizeProjectSlug(value); + } + } + return ''; +} + +/// Mirror of the hub's `normalize_project_slug`: lowercase, +/// collapse spaces/underscores/dashes to a single `-`, drop +/// everything else, trim trailing dashes. Empty in → empty out +/// (the editor treats "no project" and "blank project" alike). +String normalizeProjectSlug(String raw) { + final buf = StringBuffer(); + var prevDash = false; + for (final ch in raw.trim().toLowerCase().split('')) { + if (RegExp(r'[a-z0-9]').hasMatch(ch)) { + buf.write(ch); + prevDash = false; + } else if ((ch == ' ' || ch == '_' || ch == '-') && + !prevDash && + buf.isNotEmpty) { + buf.write('-'); + prevDash = true; + } + } + var out = buf.toString(); + while (out.endsWith('-')) { + out = out.substring(0, out.length - 1); + } + return out; +} diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index f0cbf26..910ffd2 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -44,6 +44,28 @@ class FlowEditorStrings { ); String get discardKeep => _t('Keep editing', 'Weiterbearbeiten'); String get discardThrow => _t('Throw away', 'Verwerfen'); + + /// Chip shown when the open flow file declares its own + /// top-level `project:`. The file always wins over the active + /// workspace — this label states which project the run lands in. + String projectChip(String project) => + _t('Project: $project', 'Projekt: $project'); + String get projectChipTooltip => _t( + 'This flow file declares its own project — the run lands there, ' + 'regardless of the active workspace (the file wins).', + 'Diese Flow-Datei deklariert ihr eigenes Projekt — der Lauf landet ' + 'dort, unabhängig vom aktiven Arbeitsbereich (die Datei gewinnt).', + ); + + /// Mismatch banner: the file's project differs from the active + /// workspace. Offers a one-click switch (the file still wins for + /// the run; switching just aligns the workspace view). + String projectMismatch(String fileProject) => _t( + 'This flow belongs to "$fileProject", not the active workspace.', + 'Dieser Flow gehört zu „$fileProject", nicht zum aktiven Arbeitsbereich.', + ); + String projectMismatchSwitch(String fileProject) => + _t('Switch to $fileProject', 'Zu $fileProject wechseln'); String get newDialogTitle => _t('New flow', 'Neuer Flow'); String get newDialogLabel => _t('Flow name', 'Flow-Name'); String get newDialogHelper => _t( diff --git a/pubspec.yaml b/pubspec.yaml index 7f31b0d..c52f572 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.21.2 +version: 0.22.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_project_test.dart b/test/flow_project_test.dart new file mode 100644 index 0000000..8888abf --- /dev/null +++ b/test/flow_project_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/src/flow_project.dart'; + +void main() { + group('parseFlowProject', () { + test('reads a bare top-level project key', () { + const yaml = 'name: demo\nproject: client-a\nsteps: []\n'; + expect(parseFlowProject(yaml), 'client-a'); + }); + + test('strips quotes and normalizes casing/spaces', () { + expect(parseFlowProject('project: "Client A"'), 'client-a'); + // Non-[a-z0-9] that isn't a separator is dropped without a + // dash — matches the hub's normalize_project_slug. + expect(parseFlowProject("project: 'Recl∆Im'"), 'reclim'); + }); + + test('returns empty when no top-level project is declared', () { + const yaml = 'name: demo\ninputs:\n topic: text\nsteps: []\n'; + expect(parseFlowProject(yaml), ''); + }); + + test('ignores an indented project inside a step with-block', () { + const yaml = + 'name: demo\nsteps:\n - id: s\n use: x@^0\n with:\n project: not-this\n'; + expect(parseFlowProject(yaml), ''); + }); + + test('a top-level project still wins over a later nested one', () { + const yaml = + 'project: top\nsteps:\n - id: s\n with:\n project: nested\n'; + expect(parseFlowProject(yaml), 'top'); + }); + }); + + group('normalizeProjectSlug', () { + test('collapses separators and trims trailing dashes', () { + expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha'); + expect(normalizeProjectSlug('a - b - c'), 'a-b-c'); + }); + + test('empty stays empty', () { + expect(normalizeProjectSlug(' '), ''); + }); + }); +}