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

@ -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.

View file

@ -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

View file

@ -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(