diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc5edd..efce017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,5 @@ # 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 6363851..5054b1f 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -25,7 +25,6 @@ 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'; @@ -133,20 +132,6 @@ 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, @@ -162,8 +147,6 @@ class FlowEditorPage extends StatefulWidget { this.onPickFile, this.toolbarTrailing, this.flowsDir, - this.sampleFlowNames, - this.onImportSamples, }); @override @@ -210,21 +193,6 @@ 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); @@ -356,13 +324,11 @@ 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: name, + name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''), path: f.path, sizeBytes: stat.size, - isExample: widget.sampleFlowNames?.contains(name) ?? false, meta: meta, ), ); @@ -647,17 +613,6 @@ 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, ), @@ -1316,27 +1271,28 @@ class _FlowFile { final String path; final int sizeBytes; - /// 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]). + /// 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]). final _FlowMeta meta; const _FlowFile({ required this.name, required this.path, required this.sizeBytes, - required this.isExample, required this.meta, }); } -/// Result of scanning a single flow file: the capability NAMES -/// (without `@version`) its steps reference. +/// 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. class _FlowMeta { + final bool isExample; final List requiredCaps; /// The file's own normalized `project:` slug; empty when the @@ -1344,11 +1300,12 @@ 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(requiredCaps: []); + static const empty = _FlowMeta(isExample: false, requiredCaps: []); /// Capabilities this flow needs that the hub does not provide. /// [availableNames] is the set of capability NAMES (the part @@ -1386,11 +1343,12 @@ class _FlowMetaCache { } } -/// 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. +/// 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. _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')) { @@ -1410,6 +1368,7 @@ _FlowMeta _scanFlow(String text) { if (name.isNotEmpty) caps.add(name); } return _FlowMeta( + isExample: isExample, requiredCaps: caps.toList(), project: parseFlowProject(text), ); @@ -1465,14 +1424,10 @@ 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, @@ -1492,23 +1447,6 @@ 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); @@ -1604,15 +1542,6 @@ 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), - ), ), ); } @@ -1657,66 +1586,11 @@ class _FileListState extends State<_FileList> { ), ); } - // 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( + return ListView.builder( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), - 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; - { + itemCount: files.length, + itemBuilder: (_, i) { + final f = files[i]; final isActive = f.name == widget.activeName; final missing = f.meta.missingCaps(widget.installedNames); final split = splitMissingCaps(missing, widget.storeNames); @@ -1788,13 +1662,13 @@ class _FileListState extends State<_FileList> { fontSize: 11, ), ), - if (f.isExample || missing.isNotEmpty) ...[ + if (f.meta.isExample || missing.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - if (f.isExample) + if (f.meta.isExample) _ExampleBadge(strings: strings), if (missing.isNotEmpty) MissingModulesBadge( @@ -1842,7 +1716,10 @@ class _FileListState extends State<_FileList> { ), ), ); - } + }, + ); + }, + ); } } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 7eef70d..39e93a5 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -294,10 +294,6 @@ 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 23bd68c..89bf739 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -12,17 +12,11 @@ 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 @@ -52,10 +46,6 @@ 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 3d6656e..8308d58 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.26.0 +version: 0.25.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 deleted file mode 100644 index caedf2f..0000000 --- a/test/flow_list_samples_test.dart +++ /dev/null @@ -1,144 +0,0 @@ -// 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); - }); -}