Compare commits

..

No commits in common. "main" and "v0.25.0" have entirely different histories.

6 changed files with 31 additions and 333 deletions

View file

@ -1,26 +1,5 @@
# Changelog # 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 ## 0.25.0
Honest install badge — the flow list and analyzer only offer Honest install badge — the flow list and analyzer only offer

View file

@ -25,7 +25,6 @@ library;
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart' show setEquals;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart';
@ -133,20 +132,6 @@ class FlowEditorPage extends StatefulWidget {
/// (hermetic per shared/TESTING.md). /// (hermetic per shared/TESTING.md).
final String? flowsDir; 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({ const FlowEditorPage({
super.key, super.key,
this.initialFlowName, this.initialFlowName,
@ -162,8 +147,6 @@ class FlowEditorPage extends StatefulWidget {
this.onPickFile, this.onPickFile,
this.toolbarTrailing, this.toolbarTrailing,
this.flowsDir, this.flowsDir,
this.sampleFlowNames,
this.onImportSamples,
}); });
@override @override
@ -210,21 +193,6 @@ 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)) {
final fresh = _listFiles();
setState(() {
_files = fresh;
});
}
}
@override @override
void dispose() { void dispose() {
_controller.codeController.hoverRequest.removeListener(_onHoverChanged); _controller.codeController.hoverRequest.removeListener(_onHoverChanged);
@ -356,13 +324,11 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// by path + mtime so a refresh that didn't touch a file // 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. // doesn't re-read it, and a paint never re-reads at all.
final meta = await _flowMetaCache.forFile(f, stat); final meta = await _flowMetaCache.forFile(f, stat);
final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), '');
files.add( files.add(
_FlowFile( _FlowFile(
name: name, name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
path: f.path, path: f.path,
sizeBytes: stat.size, sizeBytes: stat.size,
isExample: widget.sampleFlowNames?.contains(name) ?? false,
meta: meta, meta: meta,
), ),
); );
@ -647,17 +613,6 @@ outputs:
filesFuture: _files, filesFuture: _files,
activeName: _controller.activeName, activeName: _controller.activeName,
strings: _l, strings: _l,
onImportSamples: widget.onImportSamples == null
? null
: () async {
await widget.onImportSamples!();
if (mounted) {
final fresh = _listFiles();
setState(() {
_files = fresh;
});
}
},
installedNames: _installedNames( installedNames: _installedNames(
widget.availableCapabilities, widget.availableCapabilities,
), ),
@ -1316,27 +1271,28 @@ class _FlowFile {
final String path; final String path;
final int sizeBytes; final int sizeBytes;
/// True when the HOST reports this flow as a bundled sample /// Scan result for this file whether it's a bundled example
/// (hub wire flag) never guessed from the file content. /// and which capabilities its steps require. Computed once at
final bool isExample; /// list-load time (see [_FlowMetaCache]).
/// Scan result for this file which capabilities its steps
/// require. Computed once at list-load time (see
/// [_FlowMetaCache]).
final _FlowMeta meta; final _FlowMeta meta;
const _FlowFile({ const _FlowFile({
required this.name, required this.name,
required this.path, required this.path,
required this.sizeBytes, required this.sizeBytes,
required this.isExample,
required this.meta, required this.meta,
}); });
} }
/// Result of scanning a single flow file: the capability NAMES /// Marker text every bundled sample flow carries in its
/// (without `@version`) its steps reference. /// 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 { class _FlowMeta {
final bool isExample;
final List<String> requiredCaps; final List<String> requiredCaps;
/// The file's own normalized `project:` slug; empty when the /// 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). /// filter display semantics only, the file is never rewritten).
final String project; final String project;
const _FlowMeta({ const _FlowMeta({
required this.isExample,
required this.requiredCaps, required this.requiredCaps,
this.project = '', this.project = '',
}); });
static const empty = _FlowMeta(requiredCaps: []); static const empty = _FlowMeta(isExample: false, requiredCaps: []);
/// Capabilities this flow needs that the hub does not provide. /// Capabilities this flow needs that the hub does not provide.
/// [availableNames] is the set of capability NAMES (the part /// [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 /// Scan raw flow YAML for its example marker and the capability
/// `use:` lines. A line scan is used rather than a full YAML /// ids referenced by `use:` lines. A line scan is used rather
/// parse: it's robust against malformed flows (the analyzer /// than a full YAML parse: it's robust against malformed flows
/// reports those separately) and never throws. /// (the analyzer reports those separately) and never throws.
_FlowMeta _scanFlow(String text) { _FlowMeta _scanFlow(String text) {
final isExample = text.contains(_sampleFlowMarker);
final caps = <String>{}; final caps = <String>{};
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$'); final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
for (final raw in text.split('\n')) { for (final raw in text.split('\n')) {
@ -1410,6 +1368,7 @@ _FlowMeta _scanFlow(String text) {
if (name.isNotEmpty) caps.add(name); if (name.isNotEmpty) caps.add(name);
} }
return _FlowMeta( return _FlowMeta(
isExample: isExample,
requiredCaps: caps.toList(), requiredCaps: caps.toList(),
project: parseFlowProject(text), project: parseFlowProject(text),
); );
@ -1465,14 +1424,10 @@ class _FileList extends StatefulWidget {
/// renders, just without the one-click action. /// renders, just without the one-click action.
final Future<void> Function(List<String>)? onInstallMissing; final Future<void> Function(List<String>)? onInstallMissing;
/// Import the bundled samples (empty-list action); null hides it.
final Future<void> Function()? onImportSamples;
const _FileList({ const _FileList({
required this.filesFuture, required this.filesFuture,
required this.activeName, required this.activeName,
required this.strings, required this.strings,
this.onImportSamples,
required this.installedNames, required this.installedNames,
required this.storeNames, required this.storeNames,
required this.activeProject, required this.activeProject,
@ -1492,23 +1447,6 @@ class _FileListState extends State<_FileList> {
/// finding: no search over the flow list at all). /// finding: no search over the flow list at all).
String _filter = ''; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -1604,15 +1542,6 @@ class _FileListState extends State<_FileList> {
icon: Icons.folder_outlined, icon: Icons.folder_outlined,
title: strings.listEmptyTitle, title: strings.listEmptyTitle,
hint: strings.listEmptyBody, 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 return ListView.builder(
// 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), padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
children: [ itemCount: files.length,
for (final f in own) _row(theme, f), itemBuilder: (_, i) {
if (samples.isNotEmpty) ...[ final f = files[i];
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 isActive = f.name == widget.activeName;
final missing = f.meta.missingCaps(widget.installedNames); final missing = f.meta.missingCaps(widget.installedNames);
final split = splitMissingCaps(missing, widget.storeNames); final split = splitMissingCaps(missing, widget.storeNames);
@ -1788,13 +1662,13 @@ class _FileListState extends State<_FileList> {
fontSize: 11, fontSize: 11,
), ),
), ),
if (f.isExample || missing.isNotEmpty) ...[ if (f.meta.isExample || missing.isNotEmpty) ...[
const SizedBox(height: FaiSpace.xs), const SizedBox(height: FaiSpace.xs),
Wrap( Wrap(
spacing: FaiSpace.xs, spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs, runSpacing: FaiSpace.xs,
children: [ children: [
if (f.isExample) if (f.meta.isExample)
_ExampleBadge(strings: strings), _ExampleBadge(strings: strings),
if (missing.isNotEmpty) if (missing.isNotEmpty)
MissingModulesBadge( MissingModulesBadge(
@ -1842,7 +1716,10 @@ class _FileListState extends State<_FileList> {
), ),
), ),
); );
} },
);
},
);
} }
} }

View file

@ -294,10 +294,6 @@ class FlowEditorStrings {
'Bundled sample flow.', 'Bundled sample flow.',
'Mitgelieferter Beispiel-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( String flowListNeedsModules(int n) => _t(
n == 1 ? '1 module missing' : '$n modules missing', n == 1 ? '1 module missing' : '$n modules missing',
n == 1 ? '1 Modul fehlt' : '$n Module fehlen', n == 1 ? '1 Modul fehlt' : '$n Module fehlen',

View file

@ -12,17 +12,11 @@ class FaiEmptyState extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String? hint; 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({ const FaiEmptyState({
super.key, super.key,
required this.icon, required this.icon,
required this.title, required this.title,
this.hint, this.hint,
this.action,
}); });
@override @override
@ -52,10 +46,6 @@ class FaiEmptyState extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
if (action != null) ...[
const SizedBox(height: FaiSpace.md),
action!,
],
], ],
), ),
), ),

View file

@ -1,6 +1,6 @@
name: chain_studio_flow_editor name: chain_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.26.0 version: 0.25.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor

View file

@ -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<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);
});
}