Compare commits

..

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

15 changed files with 215 additions and 1251 deletions

View file

@ -1,103 +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
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):
- **Builtin-capability truth from the hub.** Removed the hardcoded
client-side builtin set (`{'debug.echo'}`): missing-module
detection now trusts the host-supplied capability list alone,
which already includes the hub's real builtins. Fixes the bundled
`hello` flow showing a play button on hubs that don't have
`debug.echo` installed.
- **Missing-modules badge redesigned.** Status ("N Module fehlen",
quiet neutral chip with an amber dot) and the install action
(own link, never truncated) are separate elements; the former
combined orange chip read as an error wall and ellipsized the
action word.
- **Flow-list filter.** Case-insensitive substring filter above the
list, with an honest "no flow matches" empty state.
- **Formal address (Sie) in all German strings** plus missing
commas/quotes in the empty-state sentences.
- Analyzer hint now names the current CLI (`chain install --link`,
was `fai install --link`).
## 0.23.0 ## 0.23.0
- Run tab file inputs accept a host-injected native file picker - Run tab file inputs accept a host-injected native file picker

View file

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

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';
@ -42,7 +41,6 @@ import 'tokens.dart';
import 'widgets.dart'; import 'widgets.dart';
import 'widgets/capability_picker.dart'; import 'widgets/capability_picker.dart';
import 'widgets/flow_canvas.dart'; import 'widgets/flow_canvas.dart';
import 'widgets/missing_modules_badge.dart';
import 'widgets/properties_panel.dart'; import 'widgets/properties_panel.dart';
import 'widgets/run_tab.dart'; import 'widgets/run_tab.dart';
@ -96,12 +94,12 @@ class FlowEditorPage extends StatefulWidget {
/// then call the Hub install API. /// then call the Hub install API.
final AddModuleSourceCallback? onAddModuleSource; final AddModuleSourceCallback? onAddModuleSource;
/// Capabilities the store can actually install, or null when /// Capabilities the public store knows how to install. The
/// the store state is UNKNOWN (snapshot not loaded / store /// analyzer uses this to decide whether to show "Install …"
/// unreachable). Drives the analyzer's quick-fix choice and /// (in store) or "Add source for …" (not in store) as the
/// the flow list's badge: in store → Install; known-absent → /// quick-fix on an unknown `use:` line. Empty list = store
/// "not in store" + recovery paths; unknown neither claim. /// silent no install button offered.
final List<String>? storeCapabilities; final List<String> storeCapabilities;
/// Host-side native file picker for the Run tab's file inputs. /// Host-side native file picker for the Run tab's file inputs.
/// Studio passes a real file dialog; null keeps the manual /// 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). /// (the mismatch note still shows the file still wins).
final void Function(String fileProject)? onSwitchToFileProject; 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({ const FlowEditorPage({
super.key, super.key,
this.initialFlowName, this.initialFlowName,
@ -156,14 +128,10 @@ class FlowEditorPage extends StatefulWidget {
this.style, this.style,
this.onInstallCapability, this.onInstallCapability,
this.onAddModuleSource, this.onAddModuleSource,
this.storeCapabilities, this.storeCapabilities = const [],
this.activeProject = '', this.activeProject = '',
this.onSwitchToFileProject, this.onSwitchToFileProject,
this.onPickFile, this.onPickFile,
this.toolbarTrailing,
this.flowsDir,
this.sampleFlowNames,
this.onImportSamples,
}); });
@override @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 @override
void dispose() { void dispose() {
_controller.codeController.hoverRequest.removeListener(_onHoverChanged); _controller.codeController.hoverRequest.removeListener(_onHoverChanged);
@ -329,8 +282,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
final commentIdx = line.indexOf('#', colonIdx + 1); final commentIdx = line.indexOf('#', colonIdx + 1);
final rhsEnd = commentIdx < 0 ? line.length : commentIdx; final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
final tail = commentIdx < 0 ? '' : line.substring(rhsEnd); final tail = commentIdx < 0 ? '' : line.substring(rhsEnd);
final newLine = final newLine = '${line.substring(0, colonIdx + 1)} ${fix.replacement}'
'${line.substring(0, colonIdx + 1)} ${fix.replacement}'
'${tail.isEmpty ? '' : ' $tail'}'; '${tail.isEmpty ? '' : ' $tail'}';
if (newLine == line) return; if (newLine == line) return;
lines[fix.line] = newLine; lines[fix.line] = newLine;
@ -339,10 +291,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// --- file ops --- // --- file ops ---
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
Future<List<_FlowFile>> _listFiles() async { Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_flowsDir); final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) return <_FlowFile>[]; if (!dir.existsSync()) return <_FlowFile>[];
final entries = await dir final entries = await dir
.list() .list()
@ -356,13 +306,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,
), ),
); );
@ -383,7 +331,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
} }
Future<void> _openByName(String name) async { Future<void> _openByName(String name) async {
final path = '$_flowsDir/$name.yaml'; final path = '${_defaultFlowsDir()}/$name.yaml';
final file = File(path); final file = File(path);
if (!file.existsSync()) return; if (!file.existsSync()) return;
final text = await file.readAsString(); final text = await file.readAsString();
@ -426,7 +374,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (name == null) return; if (name == null) return;
_controller.saving = true; _controller.saving = true;
try { try {
final file = File('$_flowsDir/$name.yaml'); final file = File('${_defaultFlowsDir()}/$name.yaml');
await file.writeAsString( await file.writeAsString(
_controller.codeController.fullText, _controller.codeController.fullText,
flush: true, flush: true,
@ -470,18 +418,11 @@ class _FlowEditorPageState extends State<FlowEditorPage>
builder: (ctx) => _NewFlowDialog(strings: _l), builder: (ctx) => _NewFlowDialog(strings: _l),
); );
if (name == null || name.isEmpty || !mounted) return; 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 = final template =
'''# ${_l.newTemplateComment(name)} '''# ${_l.newTemplateComment(name)}
name: $name name: $name
$projectLine
inputs: inputs:
text: text:
type: text type: text
@ -496,7 +437,7 @@ outputs:
result: \$echo.echoed result: \$echo.echoed
'''; ''';
try { try {
final dir = Directory(_flowsDir); final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) await dir.create(recursive: true); if (!dir.existsSync()) await dir.create(recursive: true);
final file = File('${dir.path}/$name.yaml'); final file = File('${dir.path}/$name.yaml');
if (file.existsSync()) { if (file.existsSync()) {
@ -634,7 +575,6 @@ outputs:
? () => Navigator.of(context).maybePop() ? () => Navigator.of(context).maybePop()
: null, : null,
onNew: _newFlow, onNew: _newFlow,
trailing: widget.toolbarTrailing,
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
@ -647,24 +587,9 @@ 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,
), ),
storeNames: widget.storeCapabilities == null
? null
: _installedNames(widget.storeCapabilities!),
activeProject: widget.activeProject,
onOpen: _openFile, onOpen: _openFile,
onRefresh: _refreshFiles, onRefresh: _refreshFiles,
onStart: _startFile, onStart: _startFile,
@ -718,8 +643,7 @@ outputs:
errorCount: _controller.analyzerErrorCount, errorCount: _controller.analyzerErrorCount,
onAddStep: _controller.activeName != null ? _addStep : null, onAddStep: _controller.activeName != null ? _addStep : null,
onSave: _controller.activeName != null ? _save : null, onSave: _controller.activeName != null ? _save : null,
onRun: onRun: _controller.activeName != null &&
_controller.activeName != null &&
_controller.analyzerErrorCount == 0 _controller.analyzerErrorCount == 0
? () => _tabs.animateTo(2) ? () => _tabs.animateTo(2)
: null, : null,
@ -919,7 +843,9 @@ outputs:
minLines: null, minLines: null,
maxLines: null, maxLines: null,
gutterStyle: GutterStyle( gutterStyle: GutterStyle(
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant), textStyle: mono.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
background: theme.colorScheme.surfaceContainer, background: theme.colorScheme.surfaceContainer,
showLineNumbers: true, showLineNumbers: true,
// Disable the built-in error column entirely its // Disable the built-in error column entirely its
@ -1018,18 +944,12 @@ class _Toolbar extends StatelessWidget {
final bool dirty; final bool dirty;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback onNew; 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({ const _Toolbar({
required this.strings, required this.strings,
required this.activeName, required this.activeName,
required this.dirty, required this.dirty,
required this.onBack, required this.onBack,
required this.onNew, required this.onNew,
this.trailing,
}); });
@override @override
@ -1098,10 +1018,6 @@ class _Toolbar extends StatelessWidget {
), ),
], ],
const Spacer(), const Spacer(),
if (trailing != null) ...[
trailing!,
const SizedBox(width: FaiSpace.md),
],
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: onNew, onPressed: onNew,
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@ -1211,7 +1127,9 @@ class _TabActionStrip extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Icon( : Icon(
errorCount > 0 ? Icons.block : Icons.play_arrow, errorCount > 0
? Icons.block
: Icons.play_arrow,
size: 18, size: 18,
), ),
label: Text(strings.run), label: Text(strings.run),
@ -1316,50 +1234,47 @@ 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';
/// Built-in capabilities the Hub always provides, even when
/// they aren't in the host-supplied installed list. Treated as
/// satisfied so the "needs N modules" badge never flags them.
const Set<String> _builtinCapabilities = {'debug.echo'};
/// 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;
const _FlowMeta({required this.isExample, required this.requiredCaps});
/// The file's own normalized `project:` slug; empty when the static const empty = _FlowMeta(isExample: false, requiredCaps: []);
/// 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: []); /// Capabilities this flow needs that are neither installed nor
/// built in. [availableNames] is the set of installed
/// Capabilities this flow needs that the hub does not provide. /// capability NAMES (the part before `@`).
/// [availableNames] is the set of capability NAMES (the part List<String> missingCaps(Set<String> availableNames) => requiredCaps
/// before `@`) from the host's live capability list — which .where(
/// already includes the hub's builtins (e.g. system.approval), (c) =>
/// so there is no client-side builtin list to drift out of !availableNames.contains(c) && !_builtinCapabilities.contains(c),
/// sync with the hub (the old hardcoded {'debug.echo'} made )
/// the bundled hello flow look runnable on hubs that don't .toList();
/// have the module).
List<String> missingCaps(Set<String> availableNames) =>
requiredCaps.where((c) => !availableNames.contains(c)).toList();
} }
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading /// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
@ -1386,11 +1301,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')) {
@ -1409,10 +1325,7 @@ _FlowMeta _scanFlow(String text) {
final name = value.split('@').first.trim(); final name = value.split('@').first.trim();
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),
);
} }
/// Reduce the host-supplied installed list (entries like /// Reduce the host-supplied installed list (entries like
@ -1432,7 +1345,7 @@ String _formatBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} }
class _FileList extends StatefulWidget { class _FileList extends StatelessWidget {
final Future<List<_FlowFile>> filesFuture; final Future<List<_FlowFile>> filesFuture;
final String? activeName; final String? activeName;
final FlowEditorStrings strings; final FlowEditorStrings strings;
@ -1440,18 +1353,6 @@ class _FileList extends StatefulWidget {
/// Bare capability NAMES the host reports as installed. Used /// Bare capability NAMES the host reports as installed. Used
/// to compute each row's missing-module count. /// to compute each row's missing-module count.
final Set<String> installedNames; 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 void Function(_FlowFile) onOpen;
final VoidCallback onRefresh; final VoidCallback onRefresh;
@ -1465,105 +1366,46 @@ 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.activeProject,
required this.onOpen, required this.onOpen,
required this.onRefresh, required this.onRefresh,
required this.onStart, required this.onStart,
required this.onInstallMissing, required this.onInstallMissing,
}); });
@override
State<_FileList> createState() => _FileListState();
}
class _FileListState extends State<_FileList> {
/// Case-insensitive substring filter over flow names. First
/// iteration of "find a flow fast" (usertest power-user
/// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final strings = widget.strings;
return Container( return Container(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// No ALL-CAPS panel header: the page toolbar already says Container(
// "Flows" the duplicate label read as a broken title padding: const EdgeInsets.symmetric(
// hierarchy (usertest art-director finding). The refresh horizontal: FaiSpace.md,
// action sits next to the filter instead. vertical: FaiSpace.xs,
Padding( ),
padding: const EdgeInsets.fromLTRB( decoration: BoxDecoration(
FaiSpace.md, border: Border(bottom: BorderSide(color: theme.dividerColor)),
FaiSpace.xs,
FaiSpace.md,
FaiSpace.xs,
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: SizedBox( child: Text(
height: 28, strings.listHeader,
child: TextField( style: theme.textTheme.labelSmall?.copyWith(
onChanged: (v) => setState(() => _filter = v.trim()), color: theme.colorScheme.onSurfaceVariant,
style: theme.textTheme.bodySmall, letterSpacing: 0.6,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
prefixIcon: const Icon(Icons.search, size: 14),
prefixIconConstraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
hintText: strings.listFilterHint,
hintStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
borderSide: BorderSide(
color: theme.colorScheme.outlineVariant,
),
),
),
), ),
), ),
), ),
IconButton( IconButton(
onPressed: widget.onRefresh, onPressed: onRefresh,
tooltip: strings.refresh, tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -1583,9 +1425,8 @@ class _FileListState extends State<_FileList> {
} }
Widget _buildBody(BuildContext context, ThemeData theme) { Widget _buildBody(BuildContext context, ThemeData theme) {
final strings = widget.strings;
return FutureBuilder<List<_FlowFile>>( return FutureBuilder<List<_FlowFile>>(
future: widget.filesFuture, future: filesFuture,
builder: (context, snap) { builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -1596,132 +1437,26 @@ class _FileListState extends State<_FileList> {
child: ChainErrorBox(error: snap.error, isError: true), child: ChainErrorBox(error: snap.error, isError: true),
); );
} }
final all = snap.data ?? <_FlowFile>[]; final files = snap.data ?? <_FlowFile>[];
if (all.isEmpty) { if (files.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(FaiSpace.md), padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState( child: FaiEmptyState(
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),
),
), ),
); );
} }
// Workspace filter first: files without a `project:` key return ListView.builder(
// 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
.where((f) => f.name.toLowerCase().contains(needle))
.toList();
if (files.isEmpty) {
// Flows exist, the filter just matches none say that
// instead of pretending the directory is empty.
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
icon: Icons.search_off_outlined,
title: strings.listFilterNoMatch(_filter),
hint: null,
),
);
}
// 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), 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( final isActive = f.name == activeName;
onTap: () => final missing = f.meta.missingCaps(installedNames);
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);
return InkWell( return InkWell(
onTap: () => widget.onOpen(f), onTap: () => onOpen(f),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md, horizontal: FaiSpace.md,
@ -1788,30 +1523,21 @@ 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(
installable: split.installable, missing: missing,
notInStore: split.notInStore,
unclassified: split.unclassified,
strings: strings, strings: strings,
// Install only what the store onInstall: onInstallMissing == null
// resolves the not-in-store
// chip explains the rest.
onInstall:
widget.onInstallMissing == null ||
split.installable.isEmpty
? null ? null
: () => widget.onInstallMissing!( : () => onInstallMissing!(missing),
split.installable,
),
), ),
], ],
), ),
@ -1824,7 +1550,7 @@ class _FileListState extends State<_FileList> {
// the install badge is the actionable path then. // the install badge is the actionable path then.
if (missing.isEmpty) if (missing.isEmpty)
IconButton( IconButton(
onPressed: () => widget.onStart(f), onPressed: () => onStart(f),
tooltip: strings.listStartTooltip, tooltip: strings.listStartTooltip,
icon: Icon( icon: Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
@ -1842,7 +1568,10 @@ class _FileListState extends State<_FileList> {
), ),
), ),
); );
} },
);
},
);
} }
} }
@ -1888,6 +1617,72 @@ class _ExampleBadge extends StatelessWidget {
} }
} }
/// Warning-toned "needs N modules" chip on flows whose steps
/// reference capabilities that aren't installed. Tooltip lists
/// the exact missing ids. When an install handler is wired, the
/// chip is tappable and one-click installs all missing caps via
/// the host's existing install path.
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);
// Amber warning tone consistent with the diagnostic strip's
// warning colour, readable on both light + dark surfaces.
const warn = Color(0xFFEF6C00);
final label = '${strings.flowListNeedsModules(missing.length)}'
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}';
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: warn.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: warn.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.extension_off_outlined, size: 10, color: warn),
const SizedBox(width: 4),
// Flexible + ellipsis: the chip sits in the narrow flow
// list, where the full label otherwise overflows by a few
// pixels (the tooltip still carries the complete text).
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: warn,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
],
),
);
return Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
child: onInstall == null
? chip
: InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: chip,
),
);
}
}
// --- empty state --- // --- empty state ---
class _EmptyState extends StatelessWidget { class _EmptyState extends StatelessWidget {
@ -2145,10 +1940,8 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
InkWell( InkWell(
onTap: () => setState(() => _expanded = !_expanded), onTap: () => setState(() => _expanded = !_expanded),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding:
horizontal: 12, const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
vertical: 6,
),
child: Row( child: Row(
children: [ children: [
Container( Container(
@ -2342,10 +2135,16 @@ class _IssueRow extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.auto_fix_high, size: 13), : 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( style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: const Size(0, 24), minimumSize: const Size(0, 24),
), ),
), ),
@ -2394,10 +2193,7 @@ class _IssueHoverCard extends StatelessWidget {
// to viewport so the card never spills off-screen on a // to viewport so the card never spills off-screen on a
// narrow window. // narrow window.
const cardWidth = 380.0; const cardWidth = 380.0;
final maxLeft = (media.size.width - cardWidth - 12).clamp( final maxLeft = (media.size.width - cardWidth - 12).clamp(8.0, double.infinity);
8.0,
double.infinity,
);
final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft); final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft);
final dy = (request.globalPosition.dy + 18).clamp( final dy = (request.globalPosition.dy + 18).clamp(
8.0, 8.0,
@ -2417,7 +2213,9 @@ class _IssueHoverCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(
color: theme.colorScheme.outlineVariant,
),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x33000000), color: Color(0x33000000),
@ -2568,7 +2366,11 @@ class _FixDialogState extends State<_FixDialog> {
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ 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), const SizedBox(width: 8),
Text(strings.diagnosticFixDialogTitle), Text(strings.diagnosticFixDialogTitle),
], ],
@ -2690,7 +2492,10 @@ class _FixDialogRow extends StatelessWidget {
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () => onApplyFix(fix), onPressed: () => onApplyFix(fix),
icon: const Icon(Icons.auto_fix_high, size: 13), 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 ''; 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, /// Mirror of the hub's `normalize_project_slug`: lowercase,
/// collapse spaces/underscores/dashes to a single `-`, drop /// collapse spaces/underscores/dashes to a single `-`, drop
/// everything else, trim trailing dashes. Empty in empty out /// everything else, trim trailing dashes. Empty in empty out

View file

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

View file

@ -17,6 +17,7 @@ class FlowEditorStrings {
String get save => _t('Save', 'Speichern'); String get save => _t('Save', 'Speichern');
String get run => _t('Run', 'Ausführen'); String get run => _t('Run', 'Ausführen');
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden'); String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
String get listHeader => _t('FLOWS', 'FLOWS');
String get listStartTooltip => String get listStartTooltip =>
_t('Open on the Run tab', 'Im Starten-Tab öffnen'); _t('Open on the Run tab', 'Im Starten-Tab öffnen');
String get copy => _t('Copy', 'Kopieren'); String get copy => _t('Copy', 'Kopieren');
@ -27,14 +28,13 @@ class FlowEditorStrings {
String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
String get emptyBody => _t( String get emptyBody => _t(
'Pick one from the left, or click New flow to start a fresh one.', 'Pick one from the left, or click New flow to start a fresh one.',
'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", ' 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.',
'um einen neuen anzulegen.',
); );
String get listEmptyTitle => String get listEmptyTitle =>
_t('No saved flows', 'Keine gespeicherten Flows'); _t('No saved flows', 'Keine gespeicherten Flows');
String get listEmptyBody => _t( String get listEmptyBody => _t(
'Click New flow to scaffold one from a template.', 'Click New flow to scaffold one from a template.',
'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.', 'Klicke Neuer Flow um aus einer Vorlage zu starten.',
); );
String get discardTitle => String get discardTitle =>
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
@ -95,15 +95,14 @@ class FlowEditorStrings {
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte'); String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
String get graphEmptyBody => _t( String get graphEmptyBody => _t(
'Click Add step to drop the first capability onto the canvas.', 'Click Add step to drop the first capability onto the canvas.',
'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf ' 'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.',
'die Fläche zu setzen.',
); );
// Properties panel. // Properties panel.
String get propPanelTitle => _t('Step details', 'Schritt-Details'); String get propPanelTitle => _t('Step details', 'Schritt-Details');
String get propNoSelection => _t( String get propNoSelection => _t(
'Click a step on the canvas to edit it.', 'Click a step on the canvas to edit it.',
'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.', 'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
); );
String get propStepId => _t('ID', 'ID'); String get propStepId => _t('ID', 'ID');
String get propCapability => _t('Capability', 'Capability'); String get propCapability => _t('Capability', 'Capability');
@ -143,12 +142,11 @@ class FlowEditorStrings {
String get runStart => _t('Start run', 'Lauf starten'); String get runStart => _t('Start run', 'Lauf starten');
String get runUnsavedBanner => _t( String get runUnsavedBanner => _t(
'You have unsaved changes — save first to run the latest version.', 'You have unsaved changes — save first to run the latest version.',
'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die ' 'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.',
'neueste Version auszuführen.',
); );
String get runNoFlow => _t( String get runNoFlow => _t(
'Open a flow from the left to run it.', 'Open a flow from the left to run it.',
'Öffnen Sie links einen Flow, um ihn auszuführen.', 'Öffne links einen Flow um ihn auszuführen.',
); );
String get runOutputs => _t('Outputs', 'Ausgaben'); String get runOutputs => _t('Outputs', 'Ausgaben');
String get runChooseFile => _t('Choose file…', 'Datei wählen…'); String get runChooseFile => _t('Choose file…', 'Datei wählen…');
@ -255,28 +253,15 @@ class FlowEditorStrings {
); );
String analyzerUnknownCapTypo(String cap, String suggestion) => _t( String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
'Unknown capability "$cap". Did you mean "$suggestion"?', 'Unknown capability "$cap". Did you mean "$suggestion"?',
'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?', 'Unbekannte Capability "$cap". Meintest du "$suggestion"?',
); );
String analyzerUnknownCapNotInStore(String cap) => _t( String analyzerUnknownCapNotInStore(String cap) => _t(
'Unknown capability "$cap". ' 'Unknown capability "$cap". '
'No configured store can install it — install a local module ' 'Not in the store — install locally with '
'(`chain install --link <path>`), add the store that provides ' '`fai install --link <path>` or check the spelling.',
'it, or configure the integration (MCP/n8n) that supplies it.',
'Unbekannte Capability "$cap". ' 'Unbekannte Capability "$cap". '
'Kein eingerichteter Store kann sie installieren — lokales ' 'Nicht im Store — lokal mit '
'Modul installieren (`chain install --link <pfad>`), den ' '`fai install --link <pfad>` installieren oder Tippfehler prüfen.',
'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>`).',
); );
String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerInputKind() => _t('input', 'Eingabe');
String analyzerOutputKind() => _t('output', 'Ausgabe'); String analyzerOutputKind() => _t('output', 'Ausgabe');
@ -294,13 +279,9 @@ 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 ? 'needs 1 module' : 'needs $n modules',
n == 1 ? '1 Modul fehlt' : '$n Module fehlen', n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
); );
String flowListNeedsModulesTooltip(String caps) => _t( String flowListNeedsModulesTooltip(String caps) => _t(
'This flow needs capabilities that are not installed:\n$caps\n' 'This flow needs capabilities that are not installed:\n$caps\n'
@ -309,52 +290,6 @@ class FlowEditorStrings {
'Klicken, um die fehlenden zu installieren.', 'Klicken, um die fehlenden zu installieren.',
); );
String get flowListInstallMissing => _t('Install', '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…');
String listFilterNoMatch(String query) => _t(
'No flow matches "$query".',
'Kein Flow passt zu „$query".',
);
// Quick-fix button labels. // Quick-fix button labels.
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren'); String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
@ -374,7 +309,6 @@ class AnalyzerStrings {
final String Function(String cap) unknownCapInStore; final String Function(String cap) unknownCapInStore;
final String Function(String cap, String suggestion) unknownCapTypo; final String Function(String cap, String suggestion) unknownCapTypo;
final String Function(String cap) unknownCapNotInStore; final String Function(String cap) unknownCapNotInStore;
final String Function(String cap) unknownCapStoreUnknown;
final String Function(String kind, String value, String validList) final String Function(String kind, String value, String validList)
unknownType; unknownType;
final String Function() inputKind; final String Function() inputKind;
@ -390,7 +324,6 @@ class AnalyzerStrings {
required this.unknownCapInStore, required this.unknownCapInStore,
required this.unknownCapTypo, required this.unknownCapTypo,
required this.unknownCapNotInStore, required this.unknownCapNotInStore,
required this.unknownCapStoreUnknown,
required this.unknownType, required this.unknownType,
required this.inputKind, required this.inputKind,
required this.outputKind, required this.outputKind,
@ -409,7 +342,6 @@ class AnalyzerStrings {
unknownCapInStore: s.analyzerUnknownCapInStore, unknownCapInStore: s.analyzerUnknownCapInStore,
unknownCapTypo: s.analyzerUnknownCapTypo, unknownCapTypo: s.analyzerUnknownCapTypo,
unknownCapNotInStore: s.analyzerUnknownCapNotInStore, unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
unknownType: s.analyzerUnknownType, unknownType: s.analyzerUnknownType,
inputKind: s.analyzerInputKind, inputKind: s.analyzerInputKind,
outputKind: s.analyzerOutputKind, outputKind: s.analyzerOutputKind,
@ -430,7 +362,6 @@ class AnalyzerStrings {
: unknownCapInStore = _enUnknownCapInStore, : unknownCapInStore = _enUnknownCapInStore,
unknownCapTypo = _enUnknownCapTypo, unknownCapTypo = _enUnknownCapTypo,
unknownCapNotInStore = _enUnknownCapNotInStore, unknownCapNotInStore = _enUnknownCapNotInStore,
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
unknownType = _enUnknownType, unknownType = _enUnknownType,
inputKind = _enInputKind, inputKind = _enInputKind,
outputKind = _enOutputKind, outputKind = _enOutputKind,
@ -447,14 +378,8 @@ class AnalyzerStrings {
'Unknown capability "$cap". Did you mean "$suggestion"?'; 'Unknown capability "$cap". Did you mean "$suggestion"?';
static String _enUnknownCapNotInStore(String cap) => static String _enUnknownCapNotInStore(String cap) =>
'Unknown capability "$cap". ' 'Unknown capability "$cap". '
'No configured store can install it — install a local module ' 'Not in the store — install locally with '
'(`chain install --link <path>`), add the store that provides ' '`fai install --link <path>` or check the spelling.';
'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>`).';
static String _enUnknownType(String kind, String value, String validList) => static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.'; 'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input'; 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 /// Ask the host to register a new module source for an unknown
/// capability used when the capability isn't in the public /// capability used when the capability isn't in the public
/// store. The host's handler typically prompts the operator /// store. The host's handler typically prompts the operator
/// for a local path (`chain install --link`) or a URL /// for a local path (`fai install --link`) or a URL
/// (`chain install <url>`), then installs and reanalyzes. /// (`fai install <url>`), then installs and reanalyzes.
/// ///
/// This is the recovery path for private modules: the public /// This is the recovery path for private modules: the public
/// store doesn't know about `acme.internal/directory-lookup`, /// store doesn't know about `htw.digiscout/onet-lookup`, but
/// but the operator can point the hub at the local clone. /// the operator can point the hub at the local clone.
@immutable @immutable
class AddModuleSourceFix extends QuickFix { class AddModuleSourceFix extends QuickFix {
/// The capability the operator wrote the host uses it to /// The capability the operator wrote the host uses it to

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,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 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.23.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

@ -135,20 +135,13 @@ steps:
name: x name: x
steps: steps:
- id: c - id: c
use: acme.internal/secret@^0.1 use: htw.private/secret@^0.1
''')); '''));
expect(r.issues, hasLength(1)); expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first); final fixes = a.fixesFor(r.issues.first);
expect(fixes, hasLength(1)); expect(fixes, hasLength(1));
expect(fixes.first, isA<AddModuleSourceFix>()); expect(fixes.first, isA<AddModuleSourceFix>());
// The message must explain all three recovery paths in place: expect(r.issues.first.message, contains('Not in the store'));
// 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'));
}); });
test('did-you-mean wins over install/add-source when a near miss exists', 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', () { group('normalizeProjectSlug', () {
test('collapses separators and trims trailing dashes', () { test('collapses separators and trims trailing dashes', () {
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha'); 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);
});
});
}