diff --git a/CHANGELOG.md b/CHANGELOG.md index efce017..ccc5edd 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 5054b1f..6363851 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -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? 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 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,21 @@ class _FlowEditorPageState extends State } } + @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)) { + final fresh = _listFiles(); + setState(() { + _files = fresh; + }); + } + } + @override void dispose() { _controller.codeController.hoverRequest.removeListener(_onHoverChanged); @@ -324,11 +356,13 @@ class _FlowEditorPageState extends State // 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 +647,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 +1316,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 requiredCaps; /// The file's own normalized `project:` slug; empty when the @@ -1300,12 +1344,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 +1386,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 = {}; final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$'); for (final raw in text.split('\n')) { @@ -1368,7 +1410,6 @@ _FlowMeta _scanFlow(String text) { if (name.isNotEmpty) caps.add(name); } return _FlowMeta( - isExample: isExample, requiredCaps: caps.toList(), project: parseFlowProject(text), ); @@ -1424,10 +1465,14 @@ class _FileList extends StatefulWidget { /// renders, just without the one-click action. final Future Function(List)? onInstallMissing; + /// Import the bundled samples (empty-list action); null hides it. + final Future 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 +1492,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 _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 +1604,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 +1657,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 +1788,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 +1842,7 @@ class _FileListState extends State<_FileList> { ), ), ); - }, - ); - }, - ); + } } } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 39e93a5..7eef70d 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -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', diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart index 89bf739..23bd68c 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -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!, + ], ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 8308d58..3d6656e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/flow_list_samples_test.dart b/test/flow_list_samples_test.dart new file mode 100644 index 0000000..caedf2f --- /dev/null +++ b/test/flow_list_samples_test.dart @@ -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 _pumpEditor( + WidgetTester tester, { + required String flowsDir, + Set? sampleFlowNames, + Future Function()? onImportSamples, +}) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: FlowEditorPage( + flowsDir: flowsDir, + sampleFlowNames: sampleFlowNames, + onImportSamples: onImportSamples, + ), + ), + ); + await Future.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.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await Future.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); + }); +}