diff --git a/CHANGELOG.md b/CHANGELOG.md index 565cbb2..1a58940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,23 @@ Honest install badge — the flow list and analyzer only offer - **Analyzer not-in-store message** now names all three recovery paths instead of only the local-install hint (EN + DE). +Project separation in the flow list: + +- **Workspace filter.** The file list filters by the host's + `activeProject`; flows without a `project:` key count as + `general` (display semantics — the file stays the truth and is + never rewritten). A project with no flows gets its own honest + empty state naming the project and the way out. +- **New flows are stamped** with the active project's `project:` + key (`general` and "all projects" stay unstamped — no key + already means general). +- **`toolbarTrailing` slot** on `FlowEditorPage`: the host can + place its workspace switcher in the editor's single toolbar. +- **`flowsDir` injection** on `FlowEditorPage`: tests (and other + hosts) point the editor at any directory instead of the + hard-wired `~/.chain/data/flows` — the new widget tests run + against a temp dir, never the operator's flows. + ## 0.24.1 - No ALL-CAPS "FLOWS" panel header: the page toolbar already names diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 9ebe180..c227b6a 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -120,6 +120,18 @@ class FlowEditorPage extends StatefulWidget { /// (the mismatch note still shows — the file still wins). final void Function(String fileProject)? onSwitchToFileProject; + /// Host-injected widget rendered in the editor toolbar, before + /// the New-flow button. Studio places its workspace (project) + /// switcher here — the editor keeps a single toolbar and stays + /// host-agnostic. + final Widget? toolbarTrailing; + + /// Directory the editor lists/saves flow files in. `null` uses + /// the hub's default (`~/.chain/data/flows`). Tests inject a + /// temp dir so they never touch the operator's real flows + /// (hermetic per shared/TESTING.md). + final String? flowsDir; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -133,6 +145,8 @@ class FlowEditorPage extends StatefulWidget { this.activeProject = '', this.onSwitchToFileProject, this.onPickFile, + this.toolbarTrailing, + this.flowsDir, }); @override @@ -293,8 +307,10 @@ class _FlowEditorPageState extends State // --- file ops --- + String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir(); + Future> _listFiles() async { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) return <_FlowFile>[]; final entries = await dir .list() @@ -333,7 +349,7 @@ class _FlowEditorPageState extends State } Future _openByName(String name) async { - final path = '${_defaultFlowsDir()}/$name.yaml'; + final path = '$_flowsDir/$name.yaml'; final file = File(path); if (!file.existsSync()) return; final text = await file.readAsString(); @@ -376,7 +392,7 @@ class _FlowEditorPageState extends State if (name == null) return; _controller.saving = true; try { - final file = File('${_defaultFlowsDir()}/$name.yaml'); + final file = File('$_flowsDir/$name.yaml'); await file.writeAsString( _controller.codeController.fullText, flush: true, @@ -420,11 +436,18 @@ class _FlowEditorPageState extends State builder: (ctx) => _NewFlowDialog(strings: _l), ); if (name == null || name.isEmpty || !mounted) return; + // Stamp the active workspace project into the new file so the + // flow stays visible under the filter it was created in. The + // file wins from here on; `general` (and "all projects") stay + // unstamped — no key already means general. + final project = widget.activeProject; + final projectLine = + project.isEmpty || project == 'general' ? '' : 'project: $project\n'; final template = '''# ${_l.newTemplateComment(name)} name: $name - +$projectLine inputs: text: type: text @@ -439,7 +462,7 @@ outputs: result: \$echo.echoed '''; try { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) await dir.create(recursive: true); final file = File('${dir.path}/$name.yaml'); if (file.existsSync()) { @@ -577,6 +600,7 @@ outputs: ? () => Navigator.of(context).maybePop() : null, onNew: _newFlow, + trailing: widget.toolbarTrailing, ), const Divider(height: 1), Expanded( @@ -593,6 +617,7 @@ outputs: widget.availableCapabilities, ), storeNames: _installedNames(widget.storeCapabilities), + activeProject: widget.activeProject, onOpen: _openFile, onRefresh: _refreshFiles, onStart: _startFile, @@ -946,12 +971,18 @@ class _Toolbar extends StatelessWidget { final bool dirty; final VoidCallback? onBack; final VoidCallback onNew; + + /// Host-injected widget rendered before the New-flow button — + /// Studio places its workspace (project) switcher here so the + /// editor keeps a single toolbar and stays host-agnostic. + final Widget? trailing; const _Toolbar({ required this.strings, required this.activeName, required this.dirty, required this.onBack, required this.onNew, + this.trailing, }); @override @@ -1020,6 +1051,10 @@ class _Toolbar extends StatelessWidget { ), ], const Spacer(), + if (trailing != null) ...[ + trailing!, + const SizedBox(width: FaiSpace.md), + ], FilledButton.tonalIcon( onPressed: onNew, icon: const Icon(Icons.add, size: 16), @@ -1257,7 +1292,16 @@ const String _sampleFlowMarker = 'F∆I sample flow'; class _FlowMeta { final bool isExample; final List requiredCaps; - const _FlowMeta({required this.isExample, required this.requiredCaps}); + + /// The file's own normalized `project:` slug; empty when the + /// YAML declares none (which counts as `general` for the list + /// filter — display semantics only, the file is never rewritten). + final String project; + const _FlowMeta({ + required this.isExample, + required this.requiredCaps, + this.project = '', + }); static const empty = _FlowMeta(isExample: false, requiredCaps: []); @@ -1321,7 +1365,11 @@ _FlowMeta _scanFlow(String text) { final name = value.split('@').first.trim(); if (name.isNotEmpty) caps.add(name); } - return _FlowMeta(isExample: isExample, requiredCaps: caps.toList()); + return _FlowMeta( + isExample: isExample, + requiredCaps: caps.toList(), + project: parseFlowProject(text), + ); } /// Reduce the host-supplied installed list (entries like @@ -1355,6 +1403,10 @@ class _FileList extends StatefulWidget { /// outside this set render the "not in store" state instead /// of an install action that the hub would refuse. final Set storeNames; + + /// Active workspace project slug; empty = all projects. Files + /// without a `project:` key count as `general`. + final String activeProject; final void Function(_FlowFile) onOpen; final VoidCallback onRefresh; @@ -1374,6 +1426,7 @@ class _FileList extends StatefulWidget { required this.strings, required this.installedNames, required this.storeNames, + required this.activeProject, required this.onOpen, required this.onRefresh, required this.onStart, @@ -1488,10 +1541,35 @@ class _FileListState extends State<_FileList> { ), ); } + // Workspace filter first: files without a `project:` key + // count as `general` (display semantics — the file is the + // truth and never rewritten). + final inProject = all + .where( + (f) => flowVisibleInProject( + f.meta.project, + widget.activeProject, + ), + ) + .toList(); + if (inProject.isEmpty) { + // Flows exist, just none in this project — say that and + // point at the way out (switch to all projects). + return Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: FaiEmptyState( + icon: Icons.folder_off_outlined, + title: strings.listProjectEmpty(widget.activeProject), + hint: strings.listProjectEmptyHint, + ), + ); + } final needle = _filter.toLowerCase(); final files = needle.isEmpty - ? all - : all.where((f) => f.name.toLowerCase().contains(needle)).toList(); + ? inProject + : inProject + .where((f) => f.name.toLowerCase().contains(needle)) + .toList(); if (files.isEmpty) { // Flows exist, the filter just matches none — say that // instead of pretending the directory is empty. diff --git a/lib/src/flow_project.dart b/lib/src/flow_project.dart index 74385c8..7d1eb99 100644 --- a/lib/src/flow_project.dart +++ b/lib/src/flow_project.dart @@ -24,6 +24,21 @@ String parseFlowProject(String yaml) { return ''; } +/// The project a flow file effectively belongs to: its own +/// `project:` slug, or `general` when the file declares none. +/// Display/filter semantics only — the FILE stays the truth and +/// is never rewritten to make this explicit. +String effectiveFlowProject(String fileProject) => + fileProject.isEmpty ? 'general' : fileProject; + +/// Whether a flow file belongs in the list under the active +/// workspace filter. Empty [activeProject] = "all projects"; +/// otherwise the file's effective project (no key = `general`) +/// must match. +bool flowVisibleInProject(String fileProject, String activeProject) => + activeProject.isEmpty || + effectiveFlowProject(fileProject) == activeProject; + /// 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 diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index e5abf60..a9c7cdf 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -314,6 +314,19 @@ class FlowEditorStrings { '(MCP/n8n) einrichten, die die Capability bereitstellt.', ); + // Project-scoped empty state: flows exist, none in the active + // workspace project. + String listProjectEmpty(String project) => _t( + 'No flows in project "$project".', + 'Keine Flows im Projekt „$project".', + ); + String get listProjectEmptyHint => _t( + 'New flow puts one here, or switch the project selector to ' + '"All projects".', + '„Neuer Flow" legt hier einen an — oder wechseln Sie die ' + 'Projekt-Auswahl auf „Alle Projekte".', + ); + // Flow-list filter (first iteration: plain substring match). String get listFilterHint => _t('Filter flows…', 'Flows filtern…'); String listFilterNoMatch(String query) => _t( diff --git a/test/flow_list_project_test.dart b/test/flow_list_project_test.dart new file mode 100644 index 0000000..a49c709 --- /dev/null +++ b/test/flow_list_project_test.dart @@ -0,0 +1,146 @@ +// Widget-level proof of the flow list's project separation: +// switching the active workspace filters the list, and a new flow +// is stamped with the active project's key. Hermetic — the editor +// gets a temp flows dir injected and never touches ~/.chain. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; + +Future _pumpEditor( + WidgetTester tester, { + required String flowsDir, + required String activeProject, +}) async { + // Build + let the real file IO of the listing complete inside + // runAsync (the fake-async test zone never drives real IO). + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: FlowEditorPage( + flowsDir: flowsDir, + activeProject: activeProject, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); +} + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('chain_editor_flows_test'); + File('${tmp.path}/alpha-report.yaml').writeAsStringSync( + 'name: alpha-report\nproject: client-a\nsteps: []\n', + ); + File('${tmp.path}/plain.yaml').writeAsStringSync( + 'name: plain\nsteps: []\n', + ); + }); + + tearDown(() { + tmp.deleteSync(recursive: true); + }); + + testWidgets('active project filters the flow list', (tester) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-a', + ); + expect(find.text('alpha-report'), findsOneWidget); + expect(find.text('plain'), findsNothing); + }); + + testWidgets('general shows keyless flows only', (tester) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'general', + ); + expect(find.text('plain'), findsOneWidget); + expect(find.text('alpha-report'), findsNothing); + }); + + testWidgets('all projects shows everything', (tester) async { + await _pumpEditor(tester, flowsDir: tmp.path, activeProject: ''); + expect(find.text('plain'), findsOneWidget); + expect(find.text('alpha-report'), findsOneWidget); + }); + + testWidgets('empty project state names the project and the way out', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-b', + ); + expect(find.textContaining('client-b'), findsOneWidget); + expect(find.text('alpha-report'), findsNothing); + expect(find.text('plain'), findsNothing); + }); + + testWidgets('new flow is stamped with the active project key', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-a', + ); + await tester.tap(find.text('New flow')); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'fresh-flow', + ); + await tester.tap(find.text('Create')); + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 50), + )); + await tester.pumpAndSettle(); + + final created = File('${tmp.path}/fresh-flow.yaml'); + expect(created.existsSync(), isTrue); + expect(created.readAsStringSync(), contains('project: client-a')); + }); + + testWidgets('new flow under general stays unstamped (no key = general)', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'general', + ); + await tester.tap(find.text('New flow')); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'general-flow', + ); + await tester.tap(find.text('Create')); + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 50), + )); + await tester.pumpAndSettle(); + + final created = File('${tmp.path}/general-flow.yaml'); + expect(created.existsSync(), isTrue); + expect(created.readAsStringSync(), isNot(contains('project:'))); + }); +} diff --git a/test/flow_project_test.dart b/test/flow_project_test.dart index 8888abf..ad8eb6b 100644 --- a/test/flow_project_test.dart +++ b/test/flow_project_test.dart @@ -34,6 +34,30 @@ void main() { }); }); + group('project filter semantics', () { + test('no project key counts as general (display only)', () { + expect(effectiveFlowProject(''), 'general'); + expect(effectiveFlowProject('client-a'), 'client-a'); + }); + + test('empty active workspace shows every flow', () { + expect(flowVisibleInProject('', ''), isTrue); + expect(flowVisibleInProject('client-a', ''), isTrue); + }); + + test('general shows keyless flows and explicit general ones', () { + expect(flowVisibleInProject('', 'general'), isTrue); + expect(flowVisibleInProject('general', 'general'), isTrue); + expect(flowVisibleInProject('client-a', 'general'), isFalse); + }); + + test('a project shows only its own flows', () { + expect(flowVisibleInProject('client-a', 'client-a'), isTrue); + expect(flowVisibleInProject('', 'client-a'), isFalse); + expect(flowVisibleInProject('client-b', 'client-a'), isFalse); + }); + }); + group('normalizeProjectSlug', () { test('collapses separators and trims trailing dashes', () { expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');