feat: sample flows follow the hub's wire flag; examples group + import action
The Example chip used to come from a client-side content-marker scan that had drifted from the hub's renamed sample header — no current sample matched it. The host now passes the hub-reported sample set (FlowSummary.sample) and the editor renders truth only: no host info, no chips. Samples collapse under one 'Examples (N)' group below the operator's own flows (expanded only when nothing else exists), and an optional onImportSamples action on the empty list is the deliberate way to pull the bundled examples into a sealed area's empty hub. Guard: flow_list_samples_test pins chips-from-host-only, the grouping, and the import round trip. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
8da50068f3
commit
f9ef31fc46
6 changed files with 330 additions and 31 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## 0.26.0
|
||||
|
||||
Sample flows follow the hub's wire flag (sealed-areas rework):
|
||||
|
||||
- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of
|
||||
flow names the hub reports as bundled samples
|
||||
(`FlowSummary.sample`). This is now the ONLY source of the
|
||||
"Example" chip; the editor's own content-marker scan is gone (it
|
||||
had silently drifted from the hub's renamed sample header and
|
||||
missed every current sample). Null = no host info = no chips.
|
||||
- **Examples group.** Sample flows collapse under one
|
||||
"Examples (N)" row below the operator's own flows, expanded on
|
||||
demand — or by default when there is nothing else to show.
|
||||
- **`FlowEditorPage.onImportSamples`** — optional empty-list action
|
||||
("Import example flows"); sealed areas start without samples, so
|
||||
hosts wire this as the deliberate pull. The list refreshes itself
|
||||
after the import.
|
||||
- `FaiEmptyState` gained an optional `action` slot.
|
||||
- The editor reloads its file list when the host changes `flowsDir`
|
||||
or delivers the sample set after an async fetch.
|
||||
|
||||
## 0.25.0
|
||||
|
||||
Honest install badge — the flow list and analyzer only offer
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ library;
|
|||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
|
|
@ -132,6 +133,20 @@ class FlowEditorPage extends StatefulWidget {
|
|||
/// (hermetic per shared/TESTING.md).
|
||||
final String? flowsDir;
|
||||
|
||||
/// Names of the flows the HOST reports as bundled samples (the
|
||||
/// hub's `FlowSummary.sample` wire flag). Null = the host has no
|
||||
/// sample information (old hub / standalone use) — then no
|
||||
/// example chips render at all. The hub's sample header is the
|
||||
/// single source of truth; the editor no longer keeps its own
|
||||
/// marker scan (it had already drifted from the hub's header).
|
||||
final Set<String>? sampleFlowNames;
|
||||
|
||||
/// Import the bundled sample flows into the current hub.
|
||||
/// Rendered as the action of the empty list state — sealed
|
||||
/// areas start without examples, this is the deliberate pull.
|
||||
/// Null hides the action.
|
||||
final Future<void> Function()? onImportSamples;
|
||||
|
||||
const FlowEditorPage({
|
||||
super.key,
|
||||
this.initialFlowName,
|
||||
|
|
@ -147,6 +162,8 @@ class FlowEditorPage extends StatefulWidget {
|
|||
this.onPickFile,
|
||||
this.toolbarTrailing,
|
||||
this.flowsDir,
|
||||
this.sampleFlowNames,
|
||||
this.onImportSamples,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -193,6 +210,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowEditorPage old) {
|
||||
super.didUpdateWidget(old);
|
||||
// The host may repoint the directory (connection switch) or
|
||||
// deliver the hub's sample set after an async fetch — both
|
||||
// change what the list must show.
|
||||
if (widget.flowsDir != old.flowsDir ||
|
||||
!setEquals(widget.sampleFlowNames, old.sampleFlowNames)) {
|
||||
setState(() => _files = _listFiles());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||
|
|
@ -324,11 +353,13 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
// by path + mtime so a refresh that didn't touch a file
|
||||
// doesn't re-read it, and a paint never re-reads at all.
|
||||
final meta = await _flowMetaCache.forFile(f, stat);
|
||||
final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), '');
|
||||
files.add(
|
||||
_FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||
name: name,
|
||||
path: f.path,
|
||||
sizeBytes: stat.size,
|
||||
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
||||
meta: meta,
|
||||
),
|
||||
);
|
||||
|
|
@ -613,6 +644,17 @@ outputs:
|
|||
filesFuture: _files,
|
||||
activeName: _controller.activeName,
|
||||
strings: _l,
|
||||
onImportSamples: widget.onImportSamples == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onImportSamples!();
|
||||
if (mounted) {
|
||||
final fresh = _listFiles();
|
||||
setState(() {
|
||||
_files = fresh;
|
||||
});
|
||||
}
|
||||
},
|
||||
installedNames: _installedNames(
|
||||
widget.availableCapabilities,
|
||||
),
|
||||
|
|
@ -1271,28 +1313,27 @@ class _FlowFile {
|
|||
final String path;
|
||||
final int sizeBytes;
|
||||
|
||||
/// Scan result for this file — whether it's a bundled example
|
||||
/// and which capabilities its steps require. Computed once at
|
||||
/// list-load time (see [_FlowMetaCache]).
|
||||
/// True when the HOST reports this flow as a bundled sample
|
||||
/// (hub wire flag) — never guessed from the file content.
|
||||
final bool isExample;
|
||||
|
||||
/// Scan result for this file — which capabilities its steps
|
||||
/// require. Computed once at list-load time (see
|
||||
/// [_FlowMetaCache]).
|
||||
final _FlowMeta meta;
|
||||
|
||||
const _FlowFile({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.sizeBytes,
|
||||
required this.isExample,
|
||||
required this.meta,
|
||||
});
|
||||
}
|
||||
|
||||
/// Marker text every bundled sample flow carries in its
|
||||
/// provenance comment header. A file is an example iff its raw
|
||||
/// content contains this exact string.
|
||||
const String _sampleFlowMarker = 'F∆I sample flow';
|
||||
|
||||
/// Result of scanning a single flow file: provenance + the
|
||||
/// capability NAMES (without `@version`) its steps reference.
|
||||
/// Result of scanning a single flow file: the capability NAMES
|
||||
/// (without `@version`) its steps reference.
|
||||
class _FlowMeta {
|
||||
final bool isExample;
|
||||
final List<String> requiredCaps;
|
||||
|
||||
/// The file's own normalized `project:` slug; empty when the
|
||||
|
|
@ -1300,12 +1341,11 @@ class _FlowMeta {
|
|||
/// 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(requiredCaps: []);
|
||||
|
||||
/// Capabilities this flow needs that the hub does not provide.
|
||||
/// [availableNames] is the set of capability NAMES (the part
|
||||
|
|
@ -1343,12 +1383,11 @@ class _FlowMetaCache {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scan raw flow YAML for its example marker and the capability
|
||||
/// ids referenced by `use:` lines. A line scan is used rather
|
||||
/// than a full YAML parse: it's robust against malformed flows
|
||||
/// (the analyzer reports those separately) and never throws.
|
||||
/// Scan raw flow YAML for the capability ids referenced by
|
||||
/// `use:` lines. A line scan is used rather than a full YAML
|
||||
/// parse: it's robust against malformed flows (the analyzer
|
||||
/// reports those separately) and never throws.
|
||||
_FlowMeta _scanFlow(String text) {
|
||||
final isExample = text.contains(_sampleFlowMarker);
|
||||
final caps = <String>{};
|
||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||
for (final raw in text.split('\n')) {
|
||||
|
|
@ -1368,7 +1407,6 @@ _FlowMeta _scanFlow(String text) {
|
|||
if (name.isNotEmpty) caps.add(name);
|
||||
}
|
||||
return _FlowMeta(
|
||||
isExample: isExample,
|
||||
requiredCaps: caps.toList(),
|
||||
project: parseFlowProject(text),
|
||||
);
|
||||
|
|
@ -1424,10 +1462,14 @@ class _FileList extends StatefulWidget {
|
|||
/// renders, just without the one-click action.
|
||||
final Future<void> Function(List<String>)? onInstallMissing;
|
||||
|
||||
/// Import the bundled samples (empty-list action); null hides it.
|
||||
final Future<void> Function()? onImportSamples;
|
||||
|
||||
const _FileList({
|
||||
required this.filesFuture,
|
||||
required this.activeName,
|
||||
required this.strings,
|
||||
this.onImportSamples,
|
||||
required this.installedNames,
|
||||
required this.storeNames,
|
||||
required this.activeProject,
|
||||
|
|
@ -1447,6 +1489,23 @@ class _FileListState extends State<_FileList> {
|
|||
/// finding: no search over the flow list at all).
|
||||
String _filter = '';
|
||||
|
||||
/// Whether the examples group is expanded. Null until the
|
||||
/// operator toggles it — the default then follows the content
|
||||
/// (expanded only when there are no own flows).
|
||||
bool? _samplesExpanded;
|
||||
|
||||
/// Guards the empty-state import button against double-taps.
|
||||
bool _importing = false;
|
||||
|
||||
Future<void> _runImport() async {
|
||||
setState(() => _importing = true);
|
||||
try {
|
||||
await widget.onImportSamples!();
|
||||
} finally {
|
||||
if (mounted) setState(() => _importing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -1542,6 +1601,15 @@ class _FileListState extends State<_FileList> {
|
|||
icon: Icons.folder_outlined,
|
||||
title: strings.listEmptyTitle,
|
||||
hint: strings.listEmptyBody,
|
||||
// Sealed areas start without examples — offer the
|
||||
// deliberate pull right where the emptiness shows.
|
||||
action: widget.onImportSamples == null
|
||||
? null
|
||||
: OutlinedButton.icon(
|
||||
onPressed: _importing ? null : _runImport,
|
||||
icon: const Icon(Icons.download_outlined, size: 16),
|
||||
label: Text(strings.flowListSamplesImport),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1586,11 +1654,66 @@ class _FileListState extends State<_FileList> {
|
|||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
// Grouping: the operator's own flows first; bundled
|
||||
// examples collapsed under one labelled group so samples
|
||||
// are never mistaken for area work (they expand by
|
||||
// default only when there is nothing else to show).
|
||||
final own = files.where((f) => !f.isExample).toList();
|
||||
final samples = files.where((f) => f.isExample).toList();
|
||||
final samplesExpanded = _samplesExpanded ?? own.isEmpty;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
children: [
|
||||
for (final f in own) _row(theme, f),
|
||||
if (samples.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _samplesExpanded = !samplesExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.flowListSamplesGroup(samples.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
samplesExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (samplesExpanded) for (final f in samples) _row(theme, f),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(ThemeData theme, _FlowFile f) {
|
||||
final strings = widget.strings;
|
||||
{
|
||||
final isActive = f.name == widget.activeName;
|
||||
final missing = f.meta.missingCaps(widget.installedNames);
|
||||
final split = splitMissingCaps(missing, widget.storeNames);
|
||||
|
|
@ -1662,13 +1785,13 @@ class _FileListState extends State<_FileList> {
|
|||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
||||
if (f.isExample || missing.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
if (f.meta.isExample)
|
||||
if (f.isExample)
|
||||
_ExampleBadge(strings: strings),
|
||||
if (missing.isNotEmpty)
|
||||
MissingModulesBadge(
|
||||
|
|
@ -1716,10 +1839,7 @@ class _FileListState extends State<_FileList> {
|
|||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -294,6 +294,10 @@ class FlowEditorStrings {
|
|||
'Bundled sample flow.',
|
||||
'Mitgelieferter Beispiel-Flow.',
|
||||
);
|
||||
String flowListSamplesGroup(int n) =>
|
||||
_t('Examples ($n)', 'Beispiele ($n)');
|
||||
String get flowListSamplesImport =>
|
||||
_t('Import example flows', 'Beispiel-Flows importieren');
|
||||
String flowListNeedsModules(int n) => _t(
|
||||
n == 1 ? '1 module missing' : '$n modules missing',
|
||||
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget {
|
|||
final IconData icon;
|
||||
final String title;
|
||||
final String? hint;
|
||||
|
||||
/// Optional action rendered under the hint (e.g. the empty flow
|
||||
/// list's "import example flows" button).
|
||||
final Widget? action;
|
||||
|
||||
const FaiEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: chain_studio_flow_editor
|
||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||
version: 0.25.0
|
||||
version: 0.26.0
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
144
test/flow_list_samples_test.dart
Normal file
144
test/flow_list_samples_test.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Widget-level proof of the sample handling contract (persona
|
||||
// review 2026-08-27 / sealed-areas rework):
|
||||
//
|
||||
// * The host's sample set (the hub's FlowSummary.sample wire
|
||||
// flag) is the ONLY source of the "Example" chip — no host
|
||||
// info, no chips. The editor's old content-marker scan had
|
||||
// silently drifted from the hub's header and is gone.
|
||||
// * Sample flows collapse under one "Examples (N)" group so
|
||||
// they are never mistaken for the operator's own work; the
|
||||
// group expands only on demand (or when nothing else exists).
|
||||
// * An empty list offers "Import example flows" iff the host
|
||||
// wired the action (sealed areas start empty on purpose).
|
||||
//
|
||||
// Hermetic — temp flows dir, never ~/.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,
|
||||
Set<String>? sampleFlowNames,
|
||||
Future<void> Function()? onImportSamples,
|
||||
}) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: FlowEditorPage(
|
||||
flowsDir: flowsDir,
|
||||
sampleFlowNames: sampleFlowNames,
|
||||
onImportSamples: onImportSamples,
|
||||
),
|
||||
),
|
||||
);
|
||||
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_samples_test');
|
||||
File('${tmp.path}/my-work.yaml').writeAsStringSync(
|
||||
'name: my-work\nsteps: []\n',
|
||||
);
|
||||
File('${tmp.path}/hello.yaml').writeAsStringSync(
|
||||
'name: hello\nsteps: []\n',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('host-reported samples collapse under the examples group', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpEditor(
|
||||
tester,
|
||||
flowsDir: tmp.path,
|
||||
sampleFlowNames: const {'hello'},
|
||||
);
|
||||
// Own flow visible, sample hidden behind the collapsed group.
|
||||
expect(find.text('my-work'), findsOneWidget);
|
||||
expect(find.text('hello'), findsNothing);
|
||||
expect(find.text('Examples (1)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Examples (1)'));
|
||||
await tester.pump();
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
// The revealed sample row carries the chip.
|
||||
expect(find.text('Example'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no host sample info means no chips and no group', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpEditor(tester, flowsDir: tmp.path, sampleFlowNames: null);
|
||||
expect(find.text('my-work'), findsOneWidget);
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
expect(find.text('Example'), findsNothing);
|
||||
expect(find.textContaining('Examples ('), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a samples-only list starts expanded', (tester) async {
|
||||
File('${tmp.path}/my-work.yaml').deleteSync();
|
||||
await _pumpEditor(
|
||||
tester,
|
||||
flowsDir: tmp.path,
|
||||
sampleFlowNames: const {'hello'},
|
||||
);
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
expect(find.text('Examples (1)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the empty list offers the import action when wired', (
|
||||
tester,
|
||||
) async {
|
||||
File('${tmp.path}/my-work.yaml').deleteSync();
|
||||
File('${tmp.path}/hello.yaml').deleteSync();
|
||||
var imported = 0;
|
||||
await _pumpEditor(
|
||||
tester,
|
||||
flowsDir: tmp.path,
|
||||
onImportSamples: () async {
|
||||
imported++;
|
||||
File('${tmp.path}/hello.yaml').writeAsStringSync(
|
||||
'name: hello\nsteps: []\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
final button = find.text('Import example flows');
|
||||
expect(button, findsOneWidget);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(button);
|
||||
// Let the import callback, the directory re-list (real IO)
|
||||
// and the FutureBuilder's completion callback all run.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(imported, 1);
|
||||
// The imported flow appears without a manual refresh.
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the empty list stays action-free when nothing is wired', (
|
||||
tester,
|
||||
) async {
|
||||
File('${tmp.path}/my-work.yaml').deleteSync();
|
||||
File('${tmp.path}/hello.yaml').deleteSync();
|
||||
await _pumpEditor(tester, flowsDir: tmp.path);
|
||||
expect(find.text('Import example flows'), findsNothing);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue