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:
parent
ab97e5e834
commit
c4a39a3779
6 changed files with 302 additions and 9 deletions
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
|
|
@ -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<FlowEditorPage>
|
|||
|
||||
// --- file ops ---
|
||||
|
||||
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
||||
|
||||
Future<List<_FlowFile>> _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<FlowEditorPage>
|
|||
}
|
||||
|
||||
Future<void> _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<FlowEditorPage>
|
|||
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<FlowEditorPage>
|
|||
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<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: []);
|
||||
|
||||
|
|
@ -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<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 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
146
test/flow_list_project_test.dart
Normal file
146
test/flow_list_project_test.dart
Normal 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:')));
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue