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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue