Compare commits

..

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

15 changed files with 248 additions and 1165 deletions

View file

@ -1,81 +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
"Install" for capabilities a configured store can actually resolve:
- **Store-resolvability split in the flow list.** Missing
capabilities are classified BEFORE any click: store-resolvable
ones keep the amber chip + Install link (which now installs only
those), the rest render a quiet "not in store" chip whose tooltip
explains the three recovery paths (local install via
`chain install --link`, adding the providing store, configuring
the MCP/n8n integration). Previously the Install action covered
every missing capability and could end in the hub's
"no store entry for '<name>'" error.
- New host contract: `_FileList` consumes the host's
`storeCapabilities` (already exposed on `FlowEditorPage`) for the
split; hosts should pass only capabilities the store can install
(installable status, native kind).
- **Analyzer not-in-store message** now names all three recovery
paths instead of only the local-install hint (EN + DE).
Project separation in the flow list:
- **Workspace filter.** The file list filters by the host's
`activeProject`; flows without a `project:` key count as
`general` (display semantics — the file stays the truth and is
never rewritten). A project with no flows gets its own honest
empty state naming the project and the way out.
- **New flows are stamped** with the active project's `project:`
key (`general` and "all projects" stay unstamped — no key
already means general).
- **`toolbarTrailing` slot** on `FlowEditorPage`: the host can
place its workspace switcher in the editor's single toolbar.
- **`flowsDir` injection** on `FlowEditorPage`: tests (and other
hosts) point the editor at any directory instead of the
hard-wired `~/.chain/data/flows` — the new widget tests run
against a temp dir, never the operator's flows.
Disturbance hardening:
- **Unknown ≠ absent.** `storeCapabilities` is nullable now
(null = store state unknown): missing capabilities then render
the plain missing chip with an honest tooltip — no install
offer, no "not in store" claim — and the analyzer message says
the store cannot be checked right now (EN + DE). Previously an
unreachable store made every missing capability claim
"not in store".
## 0.24.1
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names
the page — the duplicate label read as a broken title hierarchy.
The refresh action moved next to the list filter.
- Doc comments name the current CLI (`chain install`).
## 0.24.0
Usertest-panel fixes (flow list + language):

View file

@ -38,14 +38,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// rebuild.
final List<String> Function() availableCapabilities;
/// Returns the names of capabilities the store can actually
/// install, or null when the store state is UNKNOWN (snapshot
/// not loaded / store unreachable). Drives whether an
/// unknown-cap issue carries an Install button (in store), the
/// "not in store" recovery message (known, absent) or the
/// neutral store-unknown wording (null) the analyzer never
/// claims "no store provides it" without a loaded snapshot.
final List<String>? Function()? storeCapabilities;
/// Returns the names of capabilities the public store knows
/// how to install. Used to decide whether an unknown-cap
/// issue should carry an Install button clicking the
/// button on a capability the hub can't actually fetch would
/// just fail. Null = "no store available" install offered
/// for every unknown cap (legacy behaviour).
final List<String> Function()? storeCapabilities;
/// Quick fixes attached to the most-recent analyze() pass.
/// Keyed by the same `Issue` instances that landed in
@ -112,10 +111,9 @@ class FlowAnalyzer extends AbstractAnalyzer {
// as Did-you-mean candidates so the suggestion can preserve
// the version constraint when the user already typed one.
final installedFull = caps.toSet();
final storeCaps = storeCapabilities?.call();
final storeKnown = storeCaps != null;
final storeBare =
(storeCaps ?? const <String>[]).map(_bareCap).toSet();
final storeCaps =
storeCapabilities?.call() ?? const <String>[];
final storeBare = storeCaps.map(_bareCap).toSet();
YamlNode? doc;
try {
@ -177,9 +175,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
? strings.unknownCapInStore(useValue)
: didYouMean != null
? strings.unknownCapTypo(useValue, didYouMean)
: storeKnown
? strings.unknownCapNotInStore(useValue)
: strings.unknownCapStoreUnknown(useValue);
: strings.unknownCapNotInStore(useValue);
final issue = Issue(
line: issueLine,
message: message,

View file

@ -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';
@ -42,7 +41,6 @@ import 'tokens.dart';
import 'widgets.dart';
import 'widgets/capability_picker.dart';
import 'widgets/flow_canvas.dart';
import 'widgets/missing_modules_badge.dart';
import 'widgets/properties_panel.dart';
import 'widgets/run_tab.dart';
@ -96,12 +94,12 @@ class FlowEditorPage extends StatefulWidget {
/// then call the Hub install API.
final AddModuleSourceCallback? onAddModuleSource;
/// Capabilities the store can actually install, or null when
/// the store state is UNKNOWN (snapshot not loaded / store
/// unreachable). Drives the analyzer's quick-fix choice and
/// the flow list's badge: in store → Install; known-absent →
/// "not in store" + recovery paths; unknown neither claim.
final List<String>? storeCapabilities;
/// Capabilities the public store knows how to install. The
/// analyzer uses this to decide whether to show "Install …"
/// (in store) or "Add source for …" (not in store) as the
/// quick-fix on an unknown `use:` line. Empty list = store
/// silent no install button offered.
final List<String> storeCapabilities;
/// Host-side native file picker for the Run tab's file inputs.
/// Studio passes a real file dialog; null keeps the manual
@ -121,32 +119,6 @@ class FlowEditorPage extends StatefulWidget {
/// (the mismatch note still shows the file still wins).
final void Function(String fileProject)? onSwitchToFileProject;
/// Host-injected widget rendered in the editor toolbar, before
/// the New-flow button. Studio places its workspace (project)
/// switcher here the editor keeps a single toolbar and stays
/// host-agnostic.
final Widget? toolbarTrailing;
/// Directory the editor lists/saves flow files in. `null` uses
/// the hub's default (`~/.chain/data/flows`). Tests inject a
/// temp dir so they never touch the operator's real flows
/// (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,
@ -156,14 +128,10 @@ class FlowEditorPage extends StatefulWidget {
this.style,
this.onInstallCapability,
this.onAddModuleSource,
this.storeCapabilities,
this.storeCapabilities = const [],
this.activeProject = '',
this.onSwitchToFileProject,
this.onPickFile,
this.toolbarTrailing,
this.flowsDir,
this.sampleFlowNames,
this.onImportSamples,
});
@override
@ -210,21 +178,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
void dispose() {
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
@ -329,8 +282,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
final commentIdx = line.indexOf('#', colonIdx + 1);
final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
final tail = commentIdx < 0 ? '' : line.substring(rhsEnd);
final newLine =
'${line.substring(0, colonIdx + 1)} ${fix.replacement}'
final newLine = '${line.substring(0, colonIdx + 1)} ${fix.replacement}'
'${tail.isEmpty ? '' : ' $tail'}';
if (newLine == line) return;
lines[fix.line] = newLine;
@ -339,10 +291,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// --- file ops ---
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_flowsDir);
final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) return <_FlowFile>[];
final entries = await dir
.list()
@ -356,13 +306,11 @@ 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: name,
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
path: f.path,
sizeBytes: stat.size,
isExample: widget.sampleFlowNames?.contains(name) ?? false,
meta: meta,
),
);
@ -383,7 +331,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
}
Future<void> _openByName(String name) async {
final path = '$_flowsDir/$name.yaml';
final path = '${_defaultFlowsDir()}/$name.yaml';
final file = File(path);
if (!file.existsSync()) return;
final text = await file.readAsString();
@ -426,7 +374,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (name == null) return;
_controller.saving = true;
try {
final file = File('$_flowsDir/$name.yaml');
final file = File('${_defaultFlowsDir()}/$name.yaml');
await file.writeAsString(
_controller.codeController.fullText,
flush: true,
@ -470,18 +418,11 @@ class _FlowEditorPageState extends State<FlowEditorPage>
builder: (ctx) => _NewFlowDialog(strings: _l),
);
if (name == null || name.isEmpty || !mounted) return;
// Stamp the active workspace project into the new file so the
// flow stays visible under the filter it was created in. The
// file wins from here on; `general` (and "all projects") stay
// unstamped no key already means general.
final project = widget.activeProject;
final projectLine =
project.isEmpty || project == 'general' ? '' : 'project: $project\n';
final template =
'''# ${_l.newTemplateComment(name)}
name: $name
$projectLine
inputs:
text:
type: text
@ -496,7 +437,7 @@ outputs:
result: \$echo.echoed
''';
try {
final dir = Directory(_flowsDir);
final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File('${dir.path}/$name.yaml');
if (file.existsSync()) {
@ -634,7 +575,6 @@ outputs:
? () => Navigator.of(context).maybePop()
: null,
onNew: _newFlow,
trailing: widget.toolbarTrailing,
),
const Divider(height: 1),
Expanded(
@ -647,24 +587,9 @@ 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,
),
storeNames: widget.storeCapabilities == null
? null
: _installedNames(widget.storeCapabilities!),
activeProject: widget.activeProject,
onOpen: _openFile,
onRefresh: _refreshFiles,
onStart: _startFile,
@ -718,8 +643,7 @@ outputs:
errorCount: _controller.analyzerErrorCount,
onAddStep: _controller.activeName != null ? _addStep : null,
onSave: _controller.activeName != null ? _save : null,
onRun:
_controller.activeName != null &&
onRun: _controller.activeName != null &&
_controller.analyzerErrorCount == 0
? () => _tabs.animateTo(2)
: null,
@ -919,7 +843,9 @@ outputs:
minLines: null,
maxLines: null,
gutterStyle: GutterStyle(
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
textStyle: mono.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
background: theme.colorScheme.surfaceContainer,
showLineNumbers: true,
// Disable the built-in error column entirely its
@ -1018,18 +944,12 @@ class _Toolbar extends StatelessWidget {
final bool dirty;
final VoidCallback? onBack;
final VoidCallback onNew;
/// Host-injected widget rendered before the New-flow button
/// Studio places its workspace (project) switcher here so the
/// editor keeps a single toolbar and stays host-agnostic.
final Widget? trailing;
const _Toolbar({
required this.strings,
required this.activeName,
required this.dirty,
required this.onBack,
required this.onNew,
this.trailing,
});
@override
@ -1098,10 +1018,6 @@ class _Toolbar extends StatelessWidget {
),
],
const Spacer(),
if (trailing != null) ...[
trailing!,
const SizedBox(width: FaiSpace.md),
],
FilledButton.tonalIcon(
onPressed: onNew,
icon: const Icon(Icons.add, size: 16),
@ -1211,7 +1127,9 @@ class _TabActionStrip extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
errorCount > 0 ? Icons.block : Icons.play_arrow,
errorCount > 0
? Icons.block
: Icons.play_arrow,
size: 18,
),
label: Text(strings.run),
@ -1316,39 +1234,32 @@ 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<String> requiredCaps;
const _FlowMeta({required this.isExample, required this.requiredCaps});
/// The file's own normalized `project:` slug; empty when the
/// YAML declares none (which counts as `general` for the list
/// filter display semantics only, the file is never rewritten).
final String project;
const _FlowMeta({
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 +1297,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 = <String>{};
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
for (final raw in text.split('\n')) {
@ -1409,10 +1321,7 @@ _FlowMeta _scanFlow(String text) {
final name = value.split('@').first.trim();
if (name.isNotEmpty) caps.add(name);
}
return _FlowMeta(
requiredCaps: caps.toList(),
project: parseFlowProject(text),
);
return _FlowMeta(isExample: isExample, requiredCaps: caps.toList());
}
/// Reduce the host-supplied installed list (entries like
@ -1440,18 +1349,6 @@ class _FileList extends StatefulWidget {
/// Bare capability NAMES the host reports as installed. Used
/// to compute each row's missing-module count.
final Set<String> installedNames;
/// Bare capability NAMES a configured store can install
/// (host-filtered to installable entries), or null when the
/// store state is unknown. Missing caps outside this set
/// render the "not in store" state instead of an install
/// action that the hub would refuse; with null neither claim
/// is made.
final Set<String>? storeNames;
/// Active workspace project slug; empty = all projects. Files
/// without a `project:` key count as `general`.
final String activeProject;
final void Function(_FlowFile) onOpen;
final VoidCallback onRefresh;
@ -1465,17 +1362,11 @@ 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,
required this.onOpen,
required this.onRefresh,
required this.onStart,
@ -1492,23 +1383,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<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);
@ -1518,10 +1392,39 @@ class _FileListState extends State<_FileList> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// No ALL-CAPS panel header: the page toolbar already says
// "Flows" the duplicate label read as a broken title
// hierarchy (usertest art-director finding). The refresh
// action sits next to the filter instead.
Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.xs,
),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
Expanded(
child: Text(
strings.listHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
IconButton(
onPressed: widget.onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.md,
@ -1529,9 +1432,6 @@ class _FileListState extends State<_FileList> {
FaiSpace.md,
FaiSpace.xs,
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 28,
child: TextField(
@ -1562,20 +1462,6 @@ class _FileListState extends State<_FileList> {
),
),
),
IconButton(
onPressed: widget.onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
),
],
),
),
Expanded(child: _buildBody(context, theme)),
],
),
@ -1604,45 +1490,13 @@ 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),
),
),
);
}
// Workspace filter first: files without a `project:` key
// count as `general` (display semantics the file is the
// truth and never rewritten).
final inProject = all
.where(
(f) => flowVisibleInProject(
f.meta.project,
widget.activeProject,
),
)
.toList();
if (inProject.isEmpty) {
// Flows exist, just none in this project say that and
// point at the way out (switch to all projects).
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
icon: Icons.folder_off_outlined,
title: strings.listProjectEmpty(widget.activeProject),
hint: strings.listProjectEmptyHint,
),
);
}
final needle = _filter.toLowerCase();
final files = needle.isEmpty
? inProject
: inProject
? all
: all
.where((f) => f.name.toLowerCase().contains(needle))
.toList();
if (files.isEmpty) {
@ -1657,69 +1511,13 @@ 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);
return InkWell(
onTap: () => widget.onOpen(f),
child: Container(
@ -1788,30 +1586,22 @@ 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(
installable: split.installable,
notInStore: split.notInStore,
unclassified: split.unclassified,
_MissingModulesBadge(
missing: missing,
strings: strings,
// Install only what the store
// resolves the not-in-store
// chip explains the rest.
onInstall:
widget.onInstallMissing == null ||
split.installable.isEmpty
onInstall: widget.onInstallMissing == null
? null
: () => widget.onInstallMissing!(
split.installable,
),
: () =>
widget.onInstallMissing!(missing),
),
],
),
@ -1842,7 +1632,10 @@ class _FileListState extends State<_FileList> {
),
),
);
}
},
);
},
);
}
}
@ -1888,6 +1681,95 @@ class _ExampleBadge extends StatelessWidget {
}
}
/// "N modules missing" status on flows whose steps reference
/// capabilities the hub doesn't provide. Status and action are
/// SEPARATE elements (usertest: the combined orange chip made
/// the whole list read like an error wall and ellipsized the
/// action word):
/// - status = quiet neutral chip with a small amber dot a
/// note, not an alarm; warning orange stays reserved for real
/// failures.
/// - action = its own "Install" link that is never truncated;
/// both live in the row's Wrap, so tight widths wrap to a
/// second line instead of cutting text mid-word.
class _MissingModulesBadge extends StatelessWidget {
final List<String> missing;
final FlowEditorStrings strings;
final VoidCallback? onInstall;
const _MissingModulesBadge({
required this.missing,
required this.strings,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const dot = Color(0xFFEF6C00);
final fg = theme.colorScheme.onSurfaceVariant;
final chip = Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: dot,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
strings.flowListNeedsModules(missing.length),
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
if (onInstall == null) return chip;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
chip,
InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Text(
strings.flowListInstallMissing,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
),
],
);
}
}
// --- empty state ---
class _EmptyState extends StatelessWidget {
@ -2145,10 +2027,8 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Container(
@ -2342,10 +2222,16 @@ class _IssueRow extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high, size: 13),
label: Text(fix.label, style: _monoTextStyle(size: 11)),
label: Text(
fix.label,
style: _monoTextStyle(size: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: const Size(0, 24),
),
),
@ -2394,10 +2280,7 @@ class _IssueHoverCard extends StatelessWidget {
// to viewport so the card never spills off-screen on a
// narrow window.
const cardWidth = 380.0;
final maxLeft = (media.size.width - cardWidth - 12).clamp(
8.0,
double.infinity,
);
final maxLeft = (media.size.width - cardWidth - 12).clamp(8.0, double.infinity);
final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft);
final dy = (request.globalPosition.dy + 18).clamp(
8.0,
@ -2417,7 +2300,9 @@ class _IssueHoverCard extends StatelessWidget {
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
@ -2568,7 +2453,11 @@ class _FixDialogState extends State<_FixDialog> {
return AlertDialog(
title: Row(
children: [
Icon(Icons.auto_fix_high, size: 20, color: theme.colorScheme.primary),
Icon(
Icons.auto_fix_high,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(strings.diagnosticFixDialogTitle),
],
@ -2690,7 +2579,10 @@ class _FixDialogRow extends StatelessWidget {
FilledButton.tonalIcon(
onPressed: () => onApplyFix(fix),
icon: const Icon(Icons.auto_fix_high, size: 13),
label: Text(fix.label, style: _monoTextStyle(size: 11)),
label: Text(
fix.label,
style: _monoTextStyle(size: 11),
),
),
],
),

View file

@ -24,21 +24,6 @@ String parseFlowProject(String yaml) {
return '';
}
/// The project a flow file effectively belongs to: its own
/// `project:` slug, or `general` when the file declares none.
/// Display/filter semantics only the FILE stays the truth and
/// is never rewritten to make this explicit.
String effectiveFlowProject(String fileProject) =>
fileProject.isEmpty ? 'general' : fileProject;
/// Whether a flow file belongs in the list under the active
/// workspace filter. Empty [activeProject] = "all projects";
/// otherwise the file's effective project (no key = `general`)
/// must match.
bool flowVisibleInProject(String fileProject, String activeProject) =>
activeProject.isEmpty ||
effectiveFlowProject(fileProject) == activeProject;
/// Mirror of the hub's `normalize_project_slug`: lowercase,
/// collapse spaces/underscores/dashes to a single `-`, drop
/// everything else, trim trailing dashes. Empty in empty out

View file

@ -30,7 +30,7 @@ import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({
List<String> Function()? availableCapabilities,
List<String>? Function()? storeCapabilities,
List<String> Function()? storeCapabilities,
AnalyzerStrings analyzerStrings = AnalyzerStrings.english,
}) : super(
text: '',
@ -49,7 +49,7 @@ class FlowYamlCodeController extends CodeController {
/// closure or stale strings inside an old FlowAnalyzer.
void setCapabilityProviders({
required List<String> Function() available,
List<String>? Function()? store,
List<String> Function()? store,
AnalyzerStrings? strings,
}) {
analyzer = FlowAnalyzer(
@ -108,7 +108,7 @@ class FlowYamlCodeController extends CodeController {
/// Replace the analyzer's capability provider. Called by the
/// editor host when the installed-capability list changes
/// e.g. after `chain install` while the editor is open.
/// e.g. after `fai install` while the editor is open.
void setAvailableCapabilities(List<String> Function() provider) {
analyzer = FlowAnalyzer(availableCapabilities: provider);
}

View file

@ -17,6 +17,7 @@ class FlowEditorStrings {
String get save => _t('Save', 'Speichern');
String get run => _t('Run', 'Ausführen');
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
String get listHeader => _t('FLOWS', 'FLOWS');
String get listStartTooltip =>
_t('Open on the Run tab', 'Im Starten-Tab öffnen');
String get copy => _t('Copy', 'Kopieren');
@ -259,24 +260,11 @@ class FlowEditorStrings {
);
String analyzerUnknownCapNotInStore(String cap) => _t(
'Unknown capability "$cap". '
'No configured store can install it — install a local module '
'(`chain install --link <path>`), add the store that provides '
'it, or configure the integration (MCP/n8n) that supplies it.',
'Not in the store — install locally with '
'`chain install --link <path>` or check the spelling.',
'Unbekannte Capability "$cap". '
'Kein eingerichteter Store kann sie installieren — lokales '
'Modul installieren (`chain install --link <pfad>`), den '
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
'einrichten, die sie bereitstellt.',
);
String analyzerUnknownCapStoreUnknown(String cap) => _t(
'Unknown capability "$cap". '
'The store is not reachable right now, so it may or may not '
'be installable — check the spelling, or add a local source '
'(`chain install --link <path>`).',
'Unbekannte Capability "$cap". '
'Der Store ist gerade nicht erreichbar — ob sie installierbar '
'ist, lässt sich nicht sagen. Tippfehler prüfen oder lokale '
'Quelle hinzufügen (`chain install --link <pfad>`).',
'Nicht im Store — lokal mit '
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
);
String analyzerInputKind() => _t('input', 'Eingabe');
String analyzerOutputKind() => _t('output', 'Ausgabe');
@ -294,10 +282,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',
@ -309,45 +293,6 @@ class FlowEditorStrings {
'Klicken, um die fehlenden zu installieren.',
);
String get flowListInstallMissing => _t('Install', 'Installieren');
String flowListNeedsModulesTooltipNoAction(String caps) => _t(
'This flow needs capabilities that are not installed:\n$caps\n'
'The store is not reachable right now — no install offer '
'until Studio can check it.',
'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n'
'Der Store ist gerade nicht erreichbar — kein Install-Angebot, '
'bis Studio das prüfen kann.',
);
String flowListNotInStore(int n) => _t(
n == 1 ? 'not in store' : '$n not in store',
n == 1 ? 'nicht im Store' : '$n nicht im Store',
);
String flowListNotInStoreTooltip(String caps) => _t(
'This flow needs capabilities no configured store can install:\n'
'$caps\n'
'Ways to get them: install a local module '
'(`chain install --link <path>`), add the store that provides '
'them (Settings → Stores), or configure the integration '
'(MCP/n8n) that supplies the capability.',
'Dieser Flow braucht Capabilities, die kein eingerichteter Store '
'installieren kann:\n$caps\n'
'Wege: lokales Modul installieren '
'(`chain install --link <pfad>`), den passenden Store '
'hinzufügen (Einstellungen → Stores) oder die Anbindung '
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
);
// Project-scoped empty state: flows exist, none in the active
// workspace project.
String listProjectEmpty(String project) => _t(
'No flows in project "$project".',
'Keine Flows im Projekt „$project".',
);
String get listProjectEmptyHint => _t(
'New flow puts one here, or switch the project selector to '
'"All projects".',
'„Neuer Flow" legt hier einen an — oder wechseln Sie die '
'Projekt-Auswahl auf „Alle Projekte".',
);
// Flow-list filter (first iteration: plain substring match).
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
@ -374,7 +319,6 @@ class AnalyzerStrings {
final String Function(String cap) unknownCapInStore;
final String Function(String cap, String suggestion) unknownCapTypo;
final String Function(String cap) unknownCapNotInStore;
final String Function(String cap) unknownCapStoreUnknown;
final String Function(String kind, String value, String validList)
unknownType;
final String Function() inputKind;
@ -390,7 +334,6 @@ class AnalyzerStrings {
required this.unknownCapInStore,
required this.unknownCapTypo,
required this.unknownCapNotInStore,
required this.unknownCapStoreUnknown,
required this.unknownType,
required this.inputKind,
required this.outputKind,
@ -409,7 +352,6 @@ class AnalyzerStrings {
unknownCapInStore: s.analyzerUnknownCapInStore,
unknownCapTypo: s.analyzerUnknownCapTypo,
unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
unknownType: s.analyzerUnknownType,
inputKind: s.analyzerInputKind,
outputKind: s.analyzerOutputKind,
@ -430,7 +372,6 @@ class AnalyzerStrings {
: unknownCapInStore = _enUnknownCapInStore,
unknownCapTypo = _enUnknownCapTypo,
unknownCapNotInStore = _enUnknownCapNotInStore,
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
unknownType = _enUnknownType,
inputKind = _enInputKind,
outputKind = _enOutputKind,
@ -447,14 +388,8 @@ class AnalyzerStrings {
'Unknown capability "$cap". Did you mean "$suggestion"?';
static String _enUnknownCapNotInStore(String cap) =>
'Unknown capability "$cap". '
'No configured store can install it — install a local module '
'(`chain install --link <path>`), add the store that provides '
'it, or configure the integration (MCP/n8n) that supplies it.';
static String _enUnknownCapStoreUnknown(String cap) =>
'Unknown capability "$cap". '
'The store is not reachable right now, so it may or may not '
'be installable — check the spelling, or add a local source '
'(`chain install --link <path>`).';
'Not in the store — install locally with '
'`chain install --link <path>` or check the spelling.';
static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input';

View file

@ -69,12 +69,12 @@ class InstallCapabilityFix extends QuickFix {
/// Ask the host to register a new module source for an unknown
/// capability used when the capability isn't in the public
/// store. The host's handler typically prompts the operator
/// for a local path (`chain install --link`) or a URL
/// (`chain install <url>`), then installs and reanalyzes.
/// for a local path (`fai install --link`) or a URL
/// (`fai install <url>`), then installs and reanalyzes.
///
/// This is the recovery path for private modules: the public
/// store doesn't know about `acme.internal/directory-lookup`,
/// but the operator can point the hub at the local clone.
/// store doesn't know about `htw.digiscout/onet-lookup`, but
/// the operator can point the hub at the local clone.
@immutable
class AddModuleSourceFix extends QuickFix {
/// The capability the operator wrote the host uses it to

View file

@ -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!,
],
],
),
),

View file

@ -1,184 +0,0 @@
// Flow-list badge for flows whose steps reference capabilities
// the hub doesn't provide. Split out of flow_editor_page.dart so
// the resolvability states (installable from a store vs. not in
// any store) stay unit-testable.
import 'package:flutter/material.dart';
import '../l10n.dart';
import '../tokens.dart';
/// Split a flow's missing capabilities into the ones a configured
/// store can actually install and the ones no store resolves.
///
/// [storeNames] is the host-supplied set of bare capability names
/// the store can install (already filtered to installable entries
/// published/alpha, native). An empty set means "nothing is
/// store-installable" (known state); `null` means the store state
/// is UNKNOWN (snapshot not loaded / unreachable) then every
/// missing cap lands in `unclassified` and the UI claims neither
/// "installable" nor "not in store".
({
List<String> installable,
List<String> notInStore,
List<String> unclassified,
}) splitMissingCaps(
List<String> missing,
Set<String>? storeNames,
) {
if (storeNames == null) {
// Store state unknown (snapshot not loaded / unreachable):
// claim neither "installable" nor "not in store".
return (
installable: const <String>[],
notInStore: const <String>[],
unclassified: missing,
);
}
final installable = <String>[];
final notInStore = <String>[];
for (final cap in missing) {
final bare = cap.split('@').first;
(storeNames.contains(bare) ? installable : notInStore).add(cap);
}
return (
installable: installable,
notInStore: notInStore,
unclassified: const <String>[],
);
}
/// "N modules missing" status on flows whose steps reference
/// capabilities the hub doesn't provide. Status and action are
/// SEPARATE elements (usertest: the combined orange chip made
/// the whole list read like an error wall and ellipsized the
/// action word):
/// - status = quiet neutral chip with a small amber dot a
/// note, not an alarm; warning orange stays reserved for real
/// failures.
/// - action = its own "Install" link that is never truncated;
/// both live in the row's Wrap, so tight widths wrap to a
/// second line instead of cutting text mid-word.
///
/// The install action only covers [installable] capabilities a
/// configured store resolves. [notInStore] capabilities get their
/// own quiet chip whose tooltip explains the three recovery paths
/// (local install / add store / configure integration) BEFORE any
/// click, instead of an install button that would end in the
/// hub's "no store entry" error. [unclassified] capabilities
/// (store state unknown) get the missing chip with neither an
/// install offer nor a not-in-store claim.
class MissingModulesBadge extends StatelessWidget {
final List<String> installable;
final List<String> notInStore;
final List<String> unclassified;
final FlowEditorStrings strings;
final VoidCallback? onInstall;
const MissingModulesBadge({
super.key,
required this.installable,
required this.notInStore,
required this.strings,
required this.onInstall,
this.unclassified = const [],
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final children = <Widget>[
if (installable.isNotEmpty)
_chip(
theme,
dot: const Color(0xFFEF6C00),
label: strings.flowListNeedsModules(installable.length),
tooltip: strings.flowListNeedsModulesTooltip(
installable.join('\n'),
),
),
if (installable.isNotEmpty && onInstall != null)
InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Text(
strings.flowListInstallMissing,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
),
if (unclassified.isNotEmpty)
_chip(
theme,
dot: const Color(0xFFEF6C00),
label: strings.flowListNeedsModules(unclassified.length),
tooltip: strings.flowListNeedsModulesTooltipNoAction(
unclassified.join('\n'),
),
),
if (notInStore.isNotEmpty)
_chip(
theme,
// Muted dot: this state has no one-click fix, so it
// must not borrow the actionable chip's amber.
dot: theme.colorScheme.outline,
label: strings.flowListNotInStore(notInStore.length),
tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')),
),
];
if (children.length == 1) return children.single;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: children,
);
}
Widget _chip(
ThemeData theme, {
required Color dot,
required String label,
required String tooltip,
}) {
final fg = theme.colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
}
}

View file

@ -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.24.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor

View file

@ -135,20 +135,13 @@ steps:
name: x
steps:
- id: c
use: acme.internal/secret@^0.1
use: htw.private/secret@^0.1
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes, hasLength(1));
expect(fixes.first, isA<AddModuleSourceFix>());
// The message must explain all three recovery paths in place:
// local install, adding a store, configuring an integration.
expect(
r.issues.first.message,
contains('No configured store can install it'),
);
expect(r.issues.first.message, contains('chain install --link'));
expect(r.issues.first.message, contains('integration'));
expect(r.issues.first.message, contains('Not in the store'));
});
test('did-you-mean wins over install/add-source when a near miss exists',

View file

@ -1,146 +0,0 @@
// Widget-level proof of the flow list's project separation:
// switching the active workspace filters the list, and a new flow
// is stamped with the active project's key. Hermetic — the editor
// gets a temp flows dir injected and never touches ~/.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,
required String activeProject,
}) async {
// Build + let the real file IO of the listing complete inside
// runAsync (the fake-async test zone never drives real IO).
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: FlowEditorPage(
flowsDir: flowsDir,
activeProject: activeProject,
),
),
);
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_flows_test');
File('${tmp.path}/alpha-report.yaml').writeAsStringSync(
'name: alpha-report\nproject: client-a\nsteps: []\n',
);
File('${tmp.path}/plain.yaml').writeAsStringSync(
'name: plain\nsteps: []\n',
);
});
tearDown(() {
tmp.deleteSync(recursive: true);
});
testWidgets('active project filters the flow list', (tester) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
activeProject: 'client-a',
);
expect(find.text('alpha-report'), findsOneWidget);
expect(find.text('plain'), findsNothing);
});
testWidgets('general shows keyless flows only', (tester) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
activeProject: 'general',
);
expect(find.text('plain'), findsOneWidget);
expect(find.text('alpha-report'), findsNothing);
});
testWidgets('all projects shows everything', (tester) async {
await _pumpEditor(tester, flowsDir: tmp.path, activeProject: '');
expect(find.text('plain'), findsOneWidget);
expect(find.text('alpha-report'), findsOneWidget);
});
testWidgets('empty project state names the project and the way out', (
tester,
) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
activeProject: 'client-b',
);
expect(find.textContaining('client-b'), findsOneWidget);
expect(find.text('alpha-report'), findsNothing);
expect(find.text('plain'), findsNothing);
});
testWidgets('new flow is stamped with the active project key', (
tester,
) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
activeProject: 'client-a',
);
await tester.tap(find.text('New flow'));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(AlertDialog),
matching: find.byType(TextField),
),
'fresh-flow',
);
await tester.tap(find.text('Create'));
await tester.runAsync(() => Future<void>.delayed(
const Duration(milliseconds: 50),
));
await tester.pumpAndSettle();
final created = File('${tmp.path}/fresh-flow.yaml');
expect(created.existsSync(), isTrue);
expect(created.readAsStringSync(), contains('project: client-a'));
});
testWidgets('new flow under general stays unstamped (no key = general)', (
tester,
) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
activeProject: 'general',
);
await tester.tap(find.text('New flow'));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(AlertDialog),
matching: find.byType(TextField),
),
'general-flow',
);
await tester.tap(find.text('Create'));
await tester.runAsync(() => Future<void>.delayed(
const Duration(milliseconds: 50),
));
await tester.pumpAndSettle();
final created = File('${tmp.path}/general-flow.yaml');
expect(created.existsSync(), isTrue);
expect(created.readAsStringSync(), isNot(contains('project:')));
});
}

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

View file

@ -34,30 +34,6 @@ void main() {
});
});
group('project filter semantics', () {
test('no project key counts as general (display only)', () {
expect(effectiveFlowProject(''), 'general');
expect(effectiveFlowProject('client-a'), 'client-a');
});
test('empty active workspace shows every flow', () {
expect(flowVisibleInProject('', ''), isTrue);
expect(flowVisibleInProject('client-a', ''), isTrue);
});
test('general shows keyless flows and explicit general ones', () {
expect(flowVisibleInProject('', 'general'), isTrue);
expect(flowVisibleInProject('general', 'general'), isTrue);
expect(flowVisibleInProject('client-a', 'general'), isFalse);
});
test('a project shows only its own flows', () {
expect(flowVisibleInProject('client-a', 'client-a'), isTrue);
expect(flowVisibleInProject('', 'client-a'), isFalse);
expect(flowVisibleInProject('client-b', 'client-a'), isFalse);
});
});
group('normalizeProjectSlug', () {
test('collapses separators and trims trailing dashes', () {
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');

View file

@ -1,134 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio_flow_editor/src/l10n.dart';
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
void main() {
group('splitMissingCaps', () {
test('separates store-resolvable from not-in-store capabilities', () {
final split = splitMissingCaps(
['text.extract', 'example-provider/tool.summarize'],
{'text.extract', 'debug.echo'},
);
expect(split.installable, ['text.extract']);
expect(split.notInStore, ['example-provider/tool.summarize']);
});
test('matches on the bare name when the flow pins a version', () {
final split = splitMissingCaps(['text.extract@^0'], {'text.extract'});
expect(split.installable, ['text.extract@^0']);
expect(split.notInStore, isEmpty);
});
test('empty store set classifies everything as not-in-store', () {
final split = splitMissingCaps(['text.extract'], {});
expect(split.installable, isEmpty);
expect(split.notInStore, ['text.extract']);
expect(split.unclassified, isEmpty);
});
test('null store set (state unknown) classifies nothing', () {
final split = splitMissingCaps(['text.extract'], null);
expect(split.installable, isEmpty);
expect(split.notInStore, isEmpty);
expect(split.unclassified, ['text.extract']);
});
});
Future<void> pumpBadge(
WidgetTester tester, {
required List<String> installable,
required List<String> notInStore,
VoidCallback? onInstall,
}) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MissingModulesBadge(
installable: installable,
notInStore: notInStore,
strings: const FlowEditorStrings(FlowEditorLocale.en),
onInstall: onInstall,
),
),
),
);
}
group('MissingModulesBadge', () {
testWidgets('store-resolvable capability offers the install action', (
tester,
) async {
var installed = false;
await pumpBadge(
tester,
installable: ['text.extract'],
notInStore: [],
onInstall: () => installed = true,
);
expect(find.text('1 module missing'), findsOneWidget);
expect(find.text('Install'), findsOneWidget);
expect(find.textContaining('not in store'), findsNothing);
await tester.tap(find.text('Install'));
expect(installed, isTrue);
});
testWidgets(
'not-in-store capability shows the classified state, no install',
(tester) async {
await pumpBadge(
tester,
installable: [],
notInStore: ['example-provider/tool.summarize'],
onInstall: () => fail('no install action for not-in-store caps'),
);
expect(find.text('not in store'), findsOneWidget);
expect(find.text('Install'), findsNothing);
// The recovery paths are explained in place, before any click.
final tooltip = tester.widget<Tooltip>(find.byType(Tooltip));
expect(tooltip.message, contains('example-provider/tool.summarize'));
expect(tooltip.message, contains('chain install --link'));
expect(tooltip.message, contains('Settings → Stores'));
expect(tooltip.message, contains('integration'));
},
);
testWidgets('unknown store state offers neither install nor claims', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MissingModulesBadge(
installable: const [],
notInStore: const [],
unclassified: const ['text.extract'],
strings: const FlowEditorStrings(FlowEditorLocale.en),
onInstall: () => fail('no install offer while store unknown'),
),
),
),
);
expect(find.text('1 module missing'), findsOneWidget);
expect(find.text('Install'), findsNothing);
expect(find.textContaining('not in store'), findsNothing);
final tooltip = tester.widget<Tooltip>(find.byType(Tooltip));
expect(tooltip.message, contains('store is not reachable'));
});
testWidgets('mixed state renders both chips, install covers store caps', (
tester,
) async {
await pumpBadge(
tester,
installable: ['text.extract'],
notInStore: ['example-provider/tool.summarize'],
onInstall: () {},
);
expect(find.text('1 module missing'), findsOneWidget);
expect(find.text('Install'), findsOneWidget);
expect(find.text('not in store'), findsOneWidget);
});
});
}