Compare commits
2 commits
8da50068f3
...
36a83f9ae4
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a83f9ae4 | |||
| 29294a2971 |
6 changed files with 333 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,21 @@ 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
|
||||
void dispose() {
|
||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||
|
|
@ -324,11 +356,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 +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<String> 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 = <String>{};
|
||||
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<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 +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<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 +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> {
|
|||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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