feat: project separation in the flow list (0.25.0)

Filter the file list by the host's active project — flows without
a project: key count as 'general', display-only, the file is never
rewritten. New flows are stamped with the active project's key
(general and all-projects stay unstamped). Adds a toolbarTrailing
slot so the host can mount its workspace switcher in the editor's
single toolbar, and a flowsDir injection point so widget tests run
against a temp dir instead of the operator's ~/.chain flows.
Covered by pure filter-semantics tests plus hermetic widget tests
for filtering, the project-empty state, and new-flow stamping.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-22 13:56:29 +02:00
parent ab97e5e834
commit c4a39a3779
6 changed files with 302 additions and 9 deletions

View file

@ -21,6 +21,23 @@ Honest install badge — the flow list and analyzer only offer
- **Analyzer not-in-store message** now names all three recovery - **Analyzer not-in-store message** now names all three recovery
paths instead of only the local-install hint (EN + DE). 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 ## 0.24.1
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names - No ALL-CAPS "FLOWS" panel header: the page toolbar already names

View file

@ -120,6 +120,18 @@ class FlowEditorPage extends StatefulWidget {
/// (the mismatch note still shows the file still wins). /// (the mismatch note still shows the file still wins).
final void Function(String fileProject)? onSwitchToFileProject; 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({ const FlowEditorPage({
super.key, super.key,
this.initialFlowName, this.initialFlowName,
@ -133,6 +145,8 @@ class FlowEditorPage extends StatefulWidget {
this.activeProject = '', this.activeProject = '',
this.onSwitchToFileProject, this.onSwitchToFileProject,
this.onPickFile, this.onPickFile,
this.toolbarTrailing,
this.flowsDir,
}); });
@override @override
@ -293,8 +307,10 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// --- file ops --- // --- file ops ---
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
Future<List<_FlowFile>> _listFiles() async { Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_defaultFlowsDir()); final dir = Directory(_flowsDir);
if (!dir.existsSync()) return <_FlowFile>[]; if (!dir.existsSync()) return <_FlowFile>[];
final entries = await dir final entries = await dir
.list() .list()
@ -333,7 +349,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
} }
Future<void> _openByName(String name) async { Future<void> _openByName(String name) async {
final path = '${_defaultFlowsDir()}/$name.yaml'; final path = '$_flowsDir/$name.yaml';
final file = File(path); final file = File(path);
if (!file.existsSync()) return; if (!file.existsSync()) return;
final text = await file.readAsString(); final text = await file.readAsString();
@ -376,7 +392,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (name == null) return; if (name == null) return;
_controller.saving = true; _controller.saving = true;
try { try {
final file = File('${_defaultFlowsDir()}/$name.yaml'); final file = File('$_flowsDir/$name.yaml');
await file.writeAsString( await file.writeAsString(
_controller.codeController.fullText, _controller.codeController.fullText,
flush: true, flush: true,
@ -420,11 +436,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
builder: (ctx) => _NewFlowDialog(strings: _l), builder: (ctx) => _NewFlowDialog(strings: _l),
); );
if (name == null || name.isEmpty || !mounted) return; 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 = final template =
'''# ${_l.newTemplateComment(name)} '''# ${_l.newTemplateComment(name)}
name: $name name: $name
$projectLine
inputs: inputs:
text: text:
type: text type: text
@ -439,7 +462,7 @@ outputs:
result: \$echo.echoed result: \$echo.echoed
'''; ''';
try { try {
final dir = Directory(_defaultFlowsDir()); final dir = Directory(_flowsDir);
if (!dir.existsSync()) await dir.create(recursive: true); if (!dir.existsSync()) await dir.create(recursive: true);
final file = File('${dir.path}/$name.yaml'); final file = File('${dir.path}/$name.yaml');
if (file.existsSync()) { if (file.existsSync()) {
@ -577,6 +600,7 @@ outputs:
? () => Navigator.of(context).maybePop() ? () => Navigator.of(context).maybePop()
: null, : null,
onNew: _newFlow, onNew: _newFlow,
trailing: widget.toolbarTrailing,
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
@ -593,6 +617,7 @@ outputs:
widget.availableCapabilities, widget.availableCapabilities,
), ),
storeNames: _installedNames(widget.storeCapabilities), storeNames: _installedNames(widget.storeCapabilities),
activeProject: widget.activeProject,
onOpen: _openFile, onOpen: _openFile,
onRefresh: _refreshFiles, onRefresh: _refreshFiles,
onStart: _startFile, onStart: _startFile,
@ -946,12 +971,18 @@ class _Toolbar extends StatelessWidget {
final bool dirty; final bool dirty;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback onNew; 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({ const _Toolbar({
required this.strings, required this.strings,
required this.activeName, required this.activeName,
required this.dirty, required this.dirty,
required this.onBack, required this.onBack,
required this.onNew, required this.onNew,
this.trailing,
}); });
@override @override
@ -1020,6 +1051,10 @@ class _Toolbar extends StatelessWidget {
), ),
], ],
const Spacer(), const Spacer(),
if (trailing != null) ...[
trailing!,
const SizedBox(width: FaiSpace.md),
],
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: onNew, onPressed: onNew,
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@ -1257,7 +1292,16 @@ const String _sampleFlowMarker = 'F∆I sample flow';
class _FlowMeta { class _FlowMeta {
final bool isExample; final bool isExample;
final List<String> requiredCaps; final List<String> 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: []); static const empty = _FlowMeta(isExample: false, requiredCaps: []);
@ -1321,7 +1365,11 @@ _FlowMeta _scanFlow(String text) {
final name = value.split('@').first.trim(); final name = value.split('@').first.trim();
if (name.isNotEmpty) caps.add(name); 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 /// 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 /// outside this set render the "not in store" state instead
/// of an install action that the hub would refuse. /// of an install action that the hub would refuse.
final Set<String> storeNames; final Set<String> 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 void Function(_FlowFile) onOpen;
final VoidCallback onRefresh; final VoidCallback onRefresh;
@ -1374,6 +1426,7 @@ class _FileList extends StatefulWidget {
required this.strings, required this.strings,
required this.installedNames, required this.installedNames,
required this.storeNames, required this.storeNames,
required this.activeProject,
required this.onOpen, required this.onOpen,
required this.onRefresh, required this.onRefresh,
required this.onStart, 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 needle = _filter.toLowerCase();
final files = needle.isEmpty final files = needle.isEmpty
? all ? inProject
: all.where((f) => f.name.toLowerCase().contains(needle)).toList(); : inProject
.where((f) => f.name.toLowerCase().contains(needle))
.toList();
if (files.isEmpty) { if (files.isEmpty) {
// Flows exist, the filter just matches none say that // Flows exist, the filter just matches none say that
// instead of pretending the directory is empty. // instead of pretending the directory is empty.

View file

@ -24,6 +24,21 @@ String parseFlowProject(String yaml) {
return ''; 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, /// Mirror of the hub's `normalize_project_slug`: lowercase,
/// collapse spaces/underscores/dashes to a single `-`, drop /// collapse spaces/underscores/dashes to a single `-`, drop
/// everything else, trim trailing dashes. Empty in empty out /// everything else, trim trailing dashes. Empty in empty out

View file

@ -314,6 +314,19 @@ class FlowEditorStrings {
'(MCP/n8n) einrichten, die die Capability bereitstellt.', '(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). // Flow-list filter (first iteration: plain substring match).
String get listFilterHint => _t('Filter flows…', 'Flows filtern…'); String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
String listFilterNoMatch(String query) => _t( String listFilterNoMatch(String query) => _t(

View file

@ -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<void> _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<void>.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<void>.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<void>.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:')));
});
}

View file

@ -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', () { group('normalizeProjectSlug', () {
test('collapses separators and trims trailing dashes', () { test('collapses separators and trims trailing dashes', () {
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha'); expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');