From 4e7abec24d6859aee3af05794de4c59a7e880ece Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 17 Jul 2026 23:49:17 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20usertest=20fixes=20=E2=80=94=20hub-?= =?UTF-8?q?truth=20builtins,=20calm=20badge=20+=20separate=20install=20act?= =?UTF-8?q?ion,=20list=20filter,=20formal=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the hardcoded builtin set {'debug.echo'}; missing-module detection trusts the host capability list (includes real hub builtins) — fixes hello's false play button - split the orange combined chip into a neutral status chip and a never-truncated install link - add a substring filter over the flow list with an honest no-match state - German strings use the formal address throughout; grammar fixes - analyzer hint says 'chain install --link' (was 'fai …') - 0.24.0 Signed-off-by: flemming-it --- CHANGELOG.md | 22 ++++ lib/src/flow_editor_page.dart | 221 +++++++++++++++++++++++----------- lib/src/l10n.dart | 34 ++++-- pubspec.yaml | 2 +- 4 files changed, 199 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab46c3..a4856da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 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 - Run tab file inputs accept a host-injected native file picker diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 9b63e6c..3040dcd 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -1252,11 +1252,6 @@ class _FlowFile { /// 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 _builtinCapabilities = {'debug.echo'}; - /// Result of scanning a single flow file: provenance + the /// capability NAMES (without `@version`) its steps reference. class _FlowMeta { @@ -1266,15 +1261,16 @@ class _FlowMeta { static const empty = _FlowMeta(isExample: false, requiredCaps: []); - /// Capabilities this flow needs that are neither installed nor - /// built in. [availableNames] is the set of installed - /// capability NAMES (the part before `@`). - List missingCaps(Set availableNames) => requiredCaps - .where( - (c) => - !availableNames.contains(c) && !_builtinCapabilities.contains(c), - ) - .toList(); + /// Capabilities this flow needs that the hub does not provide. + /// [availableNames] is the set of capability NAMES (the part + /// before `@`) from the host's live capability list — which + /// already includes the hub's builtins (e.g. system.approval), + /// so there is no client-side builtin list to drift out of + /// sync with the hub (the old hardcoded {'debug.echo'} made + /// the bundled hello flow look runnable on hubs that don't + /// have the module). + List missingCaps(Set availableNames) => + requiredCaps.where((c) => !availableNames.contains(c)).toList(); } /// Caches [_FlowMeta] per file, keyed by path + mtime. Reading @@ -1345,7 +1341,7 @@ String _formatBytes(int bytes) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } -class _FileList extends StatelessWidget { +class _FileList extends StatefulWidget { final Future> filesFuture; final String? activeName; final FlowEditorStrings strings; @@ -1377,9 +1373,20 @@ class _FileList extends StatelessWidget { 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 = ''; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = widget.strings; return Container( color: theme.colorScheme.surface, child: Column( @@ -1405,7 +1412,7 @@ class _FileList extends StatelessWidget { ), ), IconButton( - onPressed: onRefresh, + onPressed: widget.onRefresh, tooltip: strings.refresh, icon: const Icon(Icons.refresh, size: 16), visualDensity: VisualDensity.compact, @@ -1418,6 +1425,43 @@ class _FileList extends StatelessWidget { ], ), ), + Padding( + padding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.xs, + FaiSpace.md, + FaiSpace.xs, + ), + child: SizedBox( + height: 28, + child: TextField( + onChanged: (v) => setState(() => _filter = v.trim()), + style: theme.textTheme.bodySmall, + 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, + ), + ), + ), + ), + ), + ), Expanded(child: _buildBody(context, theme)), ], ), @@ -1425,8 +1469,9 @@ class _FileList extends StatelessWidget { } Widget _buildBody(BuildContext context, ThemeData theme) { + final strings = widget.strings; return FutureBuilder>( - future: filesFuture, + future: widget.filesFuture, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -1437,8 +1482,8 @@ class _FileList extends StatelessWidget { child: ChainErrorBox(error: snap.error, isError: true), ); } - final files = snap.data ?? <_FlowFile>[]; - if (files.isEmpty) { + final all = snap.data ?? <_FlowFile>[]; + if (all.isEmpty) { return Padding( padding: const EdgeInsets.all(FaiSpace.md), child: FaiEmptyState( @@ -1448,15 +1493,33 @@ class _FileList extends StatelessWidget { ), ); } + final needle = _filter.toLowerCase(); + final files = needle.isEmpty + ? all + : all + .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, + ), + ); + } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), itemCount: files.length, itemBuilder: (_, i) { final f = files[i]; - final isActive = f.name == activeName; - final missing = f.meta.missingCaps(installedNames); + final isActive = f.name == widget.activeName; + final missing = f.meta.missingCaps(widget.installedNames); return InkWell( - onTap: () => onOpen(f), + onTap: () => widget.onOpen(f), child: Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, @@ -1535,9 +1598,10 @@ class _FileList extends StatelessWidget { _MissingModulesBadge( missing: missing, strings: strings, - onInstall: onInstallMissing == null + onInstall: widget.onInstallMissing == null ? null - : () => onInstallMissing!(missing), + : () => + widget.onInstallMissing!(missing), ), ], ), @@ -1550,7 +1614,7 @@ class _FileList extends StatelessWidget { // the install badge is the actionable path then. if (missing.isEmpty) IconButton( - onPressed: () => onStart(f), + onPressed: () => widget.onStart(f), tooltip: strings.listStartTooltip, icon: Icon( Icons.play_arrow_rounded, @@ -1617,11 +1681,17 @@ 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. +/// "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 missing; final FlowEditorStrings strings; @@ -1635,50 +1705,67 @@ class _MissingModulesBadge extends StatelessWidget { @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, + 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: warn, + 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, ), ), ), - ], - ), - ); - return Tooltip( - message: strings.flowListNeedsModulesTooltip(missing.join('\n')), - child: onInstall == null - ? chip - : InkWell( - onTap: onInstall, - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: chip, - ), + ), + ], ); } } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 910ffd2..3c0c46b 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -28,13 +28,14 @@ class FlowEditorStrings { String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); String get emptyBody => _t( 'Pick one from the left, or click New flow to start a fresh one.', - 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.', + 'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", ' + 'um einen neuen anzulegen.', ); String get listEmptyTitle => _t('No saved flows', 'Keine gespeicherten Flows'); String get listEmptyBody => _t( 'Click New flow to scaffold one from a template.', - 'Klicke Neuer Flow um aus einer Vorlage zu starten.', + 'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.', ); String get discardTitle => _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); @@ -95,14 +96,15 @@ class FlowEditorStrings { String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte'); String get graphEmptyBody => _t( 'Click Add step to drop the first capability onto the canvas.', - 'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.', + 'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf ' + 'die Fläche zu setzen.', ); // Properties panel. String get propPanelTitle => _t('Step details', 'Schritt-Details'); String get propNoSelection => _t( 'Click a step on the canvas to edit it.', - 'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.', + 'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.', ); String get propStepId => _t('ID', 'ID'); String get propCapability => _t('Capability', 'Capability'); @@ -142,11 +144,12 @@ class FlowEditorStrings { String get runStart => _t('Start run', 'Lauf starten'); String get runUnsavedBanner => _t( 'You have unsaved changes — save first to run the latest version.', - 'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.', + 'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die ' + 'neueste Version auszuführen.', ); String get runNoFlow => _t( 'Open a flow from the left to run it.', - 'Öffne links einen Flow um ihn auszuführen.', + 'Öffnen Sie links einen Flow, um ihn auszuführen.', ); String get runOutputs => _t('Outputs', 'Ausgaben'); String get runChooseFile => _t('Choose file…', 'Datei wählen…'); @@ -253,15 +256,15 @@ class FlowEditorStrings { ); String analyzerUnknownCapTypo(String cap, String suggestion) => _t( 'Unknown capability "$cap". Did you mean "$suggestion"?', - 'Unbekannte Capability "$cap". Meintest du "$suggestion"?', + 'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?', ); String analyzerUnknownCapNotInStore(String cap) => _t( 'Unknown capability "$cap". ' 'Not in the store — install locally with ' - '`fai install --link ` or check the spelling.', + '`chain install --link ` or check the spelling.', 'Unbekannte Capability "$cap". ' 'Nicht im Store — lokal mit ' - '`fai install --link ` installieren oder Tippfehler prüfen.', + '`chain install --link ` installieren oder Tippfehler prüfen.', ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); @@ -280,8 +283,8 @@ class FlowEditorStrings { 'Mitgelieferter Beispiel-Flow.', ); String flowListNeedsModules(int n) => _t( - n == 1 ? 'needs 1 module' : 'needs $n modules', - n == 1 ? 'braucht 1 Modul' : 'braucht $n Module', + n == 1 ? '1 module missing' : '$n modules missing', + n == 1 ? '1 Modul fehlt' : '$n Module fehlen', ); String flowListNeedsModulesTooltip(String caps) => _t( 'This flow needs capabilities that are not installed:\n$caps\n' @@ -291,6 +294,13 @@ class FlowEditorStrings { ); String get flowListInstallMissing => _t('Install', 'Installieren'); + // 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. String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren'); String fixAddSource(String cap) => @@ -379,7 +389,7 @@ class AnalyzerStrings { static String _enUnknownCapNotInStore(String cap) => 'Unknown capability "$cap". ' 'Not in the store — install locally with ' - '`fai install --link ` or check the spelling.'; + '`chain install --link ` 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'; diff --git a/pubspec.yaml b/pubspec.yaml index b3110fd..60d1f9d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.23.0 +version: 0.24.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor From 7d6a575caef2b6ea101992793022906e6e113a7d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 00:27:24 +0200 Subject: [PATCH 2/8] fix: drop the duplicate FLOWS panel header, current CLI name in docs (0.24.1) Signed-off-by: flemming-it --- CHANGELOG.md | 7 ++ lib/src/flow_editor_page.dart | 142 ++++++++++++------------------ lib/src/flow_yaml_controller.dart | 2 +- lib/src/l10n.dart | 1 - lib/src/quick_fix.dart | 4 +- pubspec.yaml | 2 +- 6 files changed, 69 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4856da..7cfbe52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 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): diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 3040dcd..a278e78 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -282,7 +282,8 @@ class _FlowEditorPageState extends State 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; @@ -643,7 +644,8 @@ 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, @@ -843,9 +845,7 @@ 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 @@ -1127,9 +1127,7 @@ 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), @@ -1392,22 +1390,47 @@ class _FileListState extends State<_FileList> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.md, - vertical: FaiSpace.xs, - ), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor)), + // 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. + Padding( + padding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.xs, + FaiSpace.md, + FaiSpace.xs, ), child: Row( children: [ Expanded( - child: Text( - strings.listHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, + child: SizedBox( + height: 28, + child: TextField( + onChanged: (v) => setState(() => _filter = v.trim()), + style: theme.textTheme.bodySmall, + 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, + ), + ), + ), ), ), ), @@ -1425,43 +1448,6 @@ class _FileListState extends State<_FileList> { ], ), ), - Padding( - padding: const EdgeInsets.fromLTRB( - FaiSpace.md, - FaiSpace.xs, - FaiSpace.md, - FaiSpace.xs, - ), - child: SizedBox( - height: 28, - child: TextField( - onChanged: (v) => setState(() => _filter = v.trim()), - style: theme.textTheme.bodySmall, - 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, - ), - ), - ), - ), - ), - ), Expanded(child: _buildBody(context, theme)), ], ), @@ -1496,9 +1482,7 @@ class _FileListState extends State<_FileList> { final needle = _filter.toLowerCase(); final files = needle.isEmpty ? all - : all - .where((f) => f.name.toLowerCase().contains(needle)) - .toList(); + : all.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. @@ -1601,7 +1585,7 @@ class _FileListState extends State<_FileList> { onInstall: widget.onInstallMissing == null ? null : () => - widget.onInstallMissing!(missing), + widget.onInstallMissing!(missing), ), ], ), @@ -2027,8 +2011,10 @@ 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( @@ -2222,16 +2208,10 @@ 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), ), ), @@ -2280,7 +2260,10 @@ 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, @@ -2300,9 +2283,7 @@ 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), @@ -2453,11 +2434,7 @@ 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), ], @@ -2579,10 +2556,7 @@ 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)), ), ], ), diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart index 94f81bd..8582b78 100644 --- a/lib/src/flow_yaml_controller.dart +++ b/lib/src/flow_yaml_controller.dart @@ -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 `fai install` while the editor is open. + /// e.g. after `chain install` while the editor is open. void setAvailableCapabilities(List Function() provider) { analyzer = FlowAnalyzer(availableCapabilities: provider); } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 3c0c46b..953796d 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -17,7 +17,6 @@ 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'); diff --git a/lib/src/quick_fix.dart b/lib/src/quick_fix.dart index c23bc91..cd4c863 100644 --- a/lib/src/quick_fix.dart +++ b/lib/src/quick_fix.dart @@ -69,8 +69,8 @@ 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 (`fai install --link`) or a URL -/// (`fai install `), then installs and reanalyzes. +/// for a local path (`chain install --link`) or a URL +/// (`chain install `), then installs and reanalyzes. /// /// This is the recovery path for private modules: the public /// store doesn't know about `htw.digiscout/onet-lookup`, but diff --git a/pubspec.yaml b/pubspec.yaml index 60d1f9d..8380d23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.24.0 +version: 0.24.1 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor From ab97e5e834cfcba56d2e3eeda0ac038a522d167b Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 22 Jul 2026 13:42:11 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20honest=20install=20badge=20?= =?UTF-8?q?=E2=80=94=20classify=20store=20resolvability=20before=20the=20c?= =?UTF-8?q?lick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split each flow's missing capabilities into store-resolvable ones (amber chip + Install link, which now installs only those) and a quiet 'not in store' chip whose tooltip explains the three recovery paths in place: local install via chain install --link, adding the providing store, or configuring the MCP/n8n integration. The analyzer's not-in-store message names the same three paths (EN+DE). Previously the list's Install action covered every missing capability and could end in the hub's 'no store entry' error. Also replace a private-looking capability example name in a doc comment and test with a neutral placeholder. (0.25.0) Signed-off-by: flemming-it --- CHANGELOG.md | 21 +++ lib/src/flow_editor_page.dart | 116 +++------------- lib/src/l10n.dart | 34 ++++- lib/src/quick_fix.dart | 4 +- lib/src/widgets/missing_modules_badge.dart | 154 +++++++++++++++++++++ pubspec.yaml | 2 +- test/flow_analyzer_test.dart | 11 +- test/missing_modules_badge_test.dart | 103 ++++++++++++++ 8 files changed, 340 insertions(+), 105 deletions(-) create mode 100644 lib/src/widgets/missing_modules_badge.dart create mode 100644 test/missing_modules_badge_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfbe52..565cbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 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 ''" 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). + ## 0.24.1 - No ALL-CAPS "FLOWS" panel header: the page toolbar already names diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index a278e78..9ebe180 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -41,6 +41,7 @@ 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'; @@ -591,6 +592,7 @@ outputs: installedNames: _installedNames( widget.availableCapabilities, ), + storeNames: _installedNames(widget.storeCapabilities), onOpen: _openFile, onRefresh: _refreshFiles, onStart: _startFile, @@ -1347,6 +1349,12 @@ class _FileList extends StatefulWidget { /// Bare capability NAMES the host reports as installed. Used /// to compute each row's missing-module count. final Set installedNames; + + /// Bare capability NAMES a configured store can install + /// (host-filtered to installable entries). Missing caps + /// outside this set render the "not in store" state instead + /// of an install action that the hub would refuse. + final Set storeNames; final void Function(_FlowFile) onOpen; final VoidCallback onRefresh; @@ -1365,6 +1373,7 @@ class _FileList extends StatefulWidget { required this.activeName, required this.strings, required this.installedNames, + required this.storeNames, required this.onOpen, required this.onRefresh, required this.onStart, @@ -1502,6 +1511,7 @@ class _FileListState extends State<_FileList> { 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( @@ -1579,13 +1589,20 @@ class _FileListState extends State<_FileList> { if (f.meta.isExample) _ExampleBadge(strings: strings), if (missing.isNotEmpty) - _MissingModulesBadge( - missing: missing, + MissingModulesBadge( + installable: split.installable, + notInStore: split.notInStore, strings: strings, - onInstall: widget.onInstallMissing == null + // Install only what the store + // resolves — the not-in-store + // chip explains the rest. + onInstall: + widget.onInstallMissing == null || + split.installable.isEmpty ? null - : () => - widget.onInstallMissing!(missing), + : () => widget.onInstallMissing!( + split.installable, + ), ), ], ), @@ -1665,95 +1682,6 @@ 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 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 { diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 953796d..e5abf60 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -259,11 +259,14 @@ class FlowEditorStrings { ); String analyzerUnknownCapNotInStore(String cap) => _t( 'Unknown capability "$cap". ' - 'Not in the store — install locally with ' - '`chain install --link ` or check the spelling.', + 'No configured store can install it — install a local module ' + '(`chain install --link `), add the store that provides ' + 'it, or configure the integration (MCP/n8n) that supplies it.', 'Unbekannte Capability "$cap". ' - 'Nicht im Store — lokal mit ' - '`chain install --link ` installieren oder Tippfehler prüfen.', + 'Kein eingerichteter Store kann sie installieren — lokales ' + 'Modul installieren (`chain install --link `), den ' + 'passenden Store hinzufügen oder die Anbindung (MCP/n8n) ' + 'einrichten, die sie bereitstellt.', ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); @@ -292,6 +295,24 @@ class FlowEditorStrings { 'Klicken, um die fehlenden zu installieren.', ); String get flowListInstallMissing => _t('Install', 'Installieren'); + 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 `), 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 `), den passenden Store ' + 'hinzufügen (Einstellungen → Stores) oder die Anbindung ' + '(MCP/n8n) einrichten, die die Capability bereitstellt.', + ); // Flow-list filter (first iteration: plain substring match). String get listFilterHint => _t('Filter flows…', 'Flows filtern…'); @@ -387,8 +408,9 @@ class AnalyzerStrings { 'Unknown capability "$cap". Did you mean "$suggestion"?'; static String _enUnknownCapNotInStore(String cap) => 'Unknown capability "$cap". ' - 'Not in the store — install locally with ' - '`chain install --link ` or check the spelling.'; + 'No configured store can install it — install a local module ' + '(`chain install --link `), add the store that provides ' + 'it, or configure the integration (MCP/n8n) that supplies it.'; static String _enUnknownType(String kind, String value, String validList) => 'Unknown $kind type "$value". Use one of: $validList.'; static String _enInputKind() => 'input'; diff --git a/lib/src/quick_fix.dart b/lib/src/quick_fix.dart index cd4c863..7772482 100644 --- a/lib/src/quick_fix.dart +++ b/lib/src/quick_fix.dart @@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix { /// (`chain install `), then installs and reanalyzes. /// /// This is the recovery path for private modules: the public -/// store doesn't know about `htw.digiscout/onet-lookup`, but -/// the operator can point the hub at the local clone. +/// store doesn't know about `acme.internal/directory-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 diff --git a/lib/src/widgets/missing_modules_badge.dart b/lib/src/widgets/missing_modules_badge.dart new file mode 100644 index 0000000..43fba36 --- /dev/null +++ b/lib/src/widgets/missing_modules_badge.dart @@ -0,0 +1,154 @@ +// 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 therefore means +/// "nothing is store-installable", not "unknown": the honest +/// state without store data is the not-in-store explanation, and +/// the local-install / add-store / configure-integration paths +/// remain available. +({List installable, List notInStore}) splitMissingCaps( + List missing, + Set storeNames, +) { + final installable = []; + final notInStore = []; + for (final cap in missing) { + final bare = cap.split('@').first; + (storeNames.contains(bare) ? installable : notInStore).add(cap); + } + return (installable: installable, notInStore: notInStore); +} + +/// "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. +class MissingModulesBadge extends StatelessWidget { + final List installable; + final List notInStore; + final FlowEditorStrings strings; + final VoidCallback? onInstall; + const MissingModulesBadge({ + super.key, + required this.installable, + required this.notInStore, + required this.strings, + required this.onInstall, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final children = [ + 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 (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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8380d23..8308d58 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.24.1 +version: 0.25.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_analyzer_test.dart b/test/flow_analyzer_test.dart index 9b35f4f..11ab1ab 100644 --- a/test/flow_analyzer_test.dart +++ b/test/flow_analyzer_test.dart @@ -135,13 +135,20 @@ steps: name: x steps: - id: c - use: htw.private/secret@^0.1 + use: acme.internal/secret@^0.1 ''')); expect(r.issues, hasLength(1)); final fixes = a.fixesFor(r.issues.first); expect(fixes, hasLength(1)); expect(fixes.first, isA()); - expect(r.issues.first.message, contains('Not in the store')); + // 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')); }); test('did-you-mean wins over install/add-source when a near miss exists', diff --git a/test/missing_modules_badge_test.dart b/test/missing_modules_badge_test.dart new file mode 100644 index 0000000..51be03b --- /dev/null +++ b/test/missing_modules_badge_test.dart @@ -0,0 +1,103 @@ +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']); + }); + }); + + Future pumpBadge( + WidgetTester tester, { + required List installable, + required List 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(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('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); + }); + }); +} From c4a39a3779d7267a408dcfef8421368dfbd7471c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 22 Jul 2026 13:56:29 +0200 Subject: [PATCH 4/8] feat: project separation in the flow list (0.25.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter the file list by the host's active project — flows without a project: key count as 'general', display-only, the file is never rewritten. New flows are stamped with the active project's key (general and all-projects stay unstamped). Adds a toolbarTrailing slot so the host can mount its workspace switcher in the editor's single toolbar, and a flowsDir injection point so widget tests run against a temp dir instead of the operator's ~/.chain flows. Covered by pure filter-semantics tests plus hermetic widget tests for filtering, the project-empty state, and new-flow stamping. Signed-off-by: flemming-it --- CHANGELOG.md | 17 ++++ lib/src/flow_editor_page.dart | 96 ++++++++++++++++++-- lib/src/flow_project.dart | 15 ++++ lib/src/l10n.dart | 13 +++ test/flow_list_project_test.dart | 146 +++++++++++++++++++++++++++++++ test/flow_project_test.dart | 24 +++++ 6 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 test/flow_list_project_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 565cbb2..1a58940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,23 @@ Honest install badge — the flow list and analyzer only offer - **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. + ## 0.24.1 - No ALL-CAPS "FLOWS" panel header: the page toolbar already names diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 9ebe180..c227b6a 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -120,6 +120,18 @@ 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; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -133,6 +145,8 @@ class FlowEditorPage extends StatefulWidget { this.activeProject = '', this.onSwitchToFileProject, this.onPickFile, + this.toolbarTrailing, + this.flowsDir, }); @override @@ -293,8 +307,10 @@ class _FlowEditorPageState extends State // --- file ops --- + String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir(); + Future> _listFiles() async { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) return <_FlowFile>[]; final entries = await dir .list() @@ -333,7 +349,7 @@ class _FlowEditorPageState extends State } Future _openByName(String name) async { - final path = '${_defaultFlowsDir()}/$name.yaml'; + final path = '$_flowsDir/$name.yaml'; final file = File(path); if (!file.existsSync()) return; final text = await file.readAsString(); @@ -376,7 +392,7 @@ class _FlowEditorPageState extends State if (name == null) return; _controller.saving = true; try { - final file = File('${_defaultFlowsDir()}/$name.yaml'); + final file = File('$_flowsDir/$name.yaml'); await file.writeAsString( _controller.codeController.fullText, flush: true, @@ -420,11 +436,18 @@ class _FlowEditorPageState extends State 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 @@ -439,7 +462,7 @@ outputs: result: \$echo.echoed '''; try { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) await dir.create(recursive: true); final file = File('${dir.path}/$name.yaml'); if (file.existsSync()) { @@ -577,6 +600,7 @@ outputs: ? () => Navigator.of(context).maybePop() : null, onNew: _newFlow, + trailing: widget.toolbarTrailing, ), const Divider(height: 1), Expanded( @@ -593,6 +617,7 @@ outputs: widget.availableCapabilities, ), storeNames: _installedNames(widget.storeCapabilities), + activeProject: widget.activeProject, onOpen: _openFile, onRefresh: _refreshFiles, onStart: _startFile, @@ -946,12 +971,18 @@ 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 @@ -1020,6 +1051,10 @@ 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), @@ -1257,7 +1292,16 @@ const String _sampleFlowMarker = 'F∆I sample flow'; class _FlowMeta { final bool isExample; final List 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.isExample, + required this.requiredCaps, + this.project = '', + }); static const empty = _FlowMeta(isExample: false, requiredCaps: []); @@ -1321,7 +1365,11 @@ _FlowMeta _scanFlow(String text) { final name = value.split('@').first.trim(); if (name.isNotEmpty) caps.add(name); } - return _FlowMeta(isExample: isExample, requiredCaps: caps.toList()); + return _FlowMeta( + isExample: isExample, + requiredCaps: caps.toList(), + project: parseFlowProject(text), + ); } /// Reduce the host-supplied installed list (entries like @@ -1355,6 +1403,10 @@ class _FileList extends StatefulWidget { /// outside this set render the "not in store" state instead /// of an install action that the hub would refuse. final Set 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; @@ -1374,6 +1426,7 @@ class _FileList extends StatefulWidget { required this.strings, required this.installedNames, required this.storeNames, + required this.activeProject, required this.onOpen, required this.onRefresh, required this.onStart, @@ -1488,10 +1541,35 @@ class _FileListState extends State<_FileList> { ), ); } + // 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 - ? all - : all.where((f) => f.name.toLowerCase().contains(needle)).toList(); + ? 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. diff --git a/lib/src/flow_project.dart b/lib/src/flow_project.dart index 74385c8..7d1eb99 100644 --- a/lib/src/flow_project.dart +++ b/lib/src/flow_project.dart @@ -24,6 +24,21 @@ 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 diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index e5abf60..a9c7cdf 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -314,6 +314,19 @@ class FlowEditorStrings { '(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( diff --git a/test/flow_list_project_test.dart b/test/flow_list_project_test.dart new file mode 100644 index 0000000..a49c709 --- /dev/null +++ b/test/flow_list_project_test.dart @@ -0,0 +1,146 @@ +// 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 _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.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.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.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:'))); + }); +} diff --git a/test/flow_project_test.dart b/test/flow_project_test.dart index 8888abf..ad8eb6b 100644 --- a/test/flow_project_test.dart +++ b/test/flow_project_test.dart @@ -34,6 +34,30 @@ 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'); From 2535c28fce046321d5828af8d6667b65ac2dd8f0 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 22 Jul 2026 14:02:28 +0200 Subject: [PATCH 5/8] fix: never claim 'not in store' while the store state is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed/unloaded store snapshot used to be indistinguishable from a known-empty store, so every missing capability was labelled 'not in store' the moment the hub or store endpoint was unreachable — a wrong claim. storeCapabilities is now nullable (null = unknown): missing caps then get the plain missing chip with an honest tooltip, no install offer and no not-in-store claim; the analyzer message says the store cannot be checked right now (EN+DE). Split and badge covered by new unit + widget tests. Signed-off-by: flemming-it --- lib/src/flow_analyzer.dart | 26 +++++++----- lib/src/flow_editor_page.dart | 29 +++++++------ lib/src/flow_yaml_controller.dart | 4 +- lib/src/l10n.dart | 27 ++++++++++++ lib/src/widgets/missing_modules_badge.dart | 48 ++++++++++++++++++---- test/missing_modules_badge_test.dart | 31 ++++++++++++++ 6 files changed, 131 insertions(+), 34 deletions(-) diff --git a/lib/src/flow_analyzer.dart b/lib/src/flow_analyzer.dart index 05dcd6e..30ca706 100644 --- a/lib/src/flow_analyzer.dart +++ b/lib/src/flow_analyzer.dart @@ -38,13 +38,14 @@ class FlowAnalyzer extends AbstractAnalyzer { /// rebuild. final List Function() availableCapabilities; - /// 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 Function()? storeCapabilities; + /// 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? Function()? storeCapabilities; /// Quick fixes attached to the most-recent analyze() pass. /// Keyed by the same `Issue` instances that landed in @@ -111,9 +112,10 @@ 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() ?? const []; - final storeBare = storeCaps.map(_bareCap).toSet(); + final storeCaps = storeCapabilities?.call(); + final storeKnown = storeCaps != null; + final storeBare = + (storeCaps ?? const []).map(_bareCap).toSet(); YamlNode? doc; try { @@ -175,7 +177,9 @@ class FlowAnalyzer extends AbstractAnalyzer { ? strings.unknownCapInStore(useValue) : didYouMean != null ? strings.unknownCapTypo(useValue, didYouMean) - : strings.unknownCapNotInStore(useValue); + : storeKnown + ? strings.unknownCapNotInStore(useValue) + : strings.unknownCapStoreUnknown(useValue); final issue = Issue( line: issueLine, message: message, diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index c227b6a..5054b1f 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -95,12 +95,12 @@ class FlowEditorPage extends StatefulWidget { /// then call the Hub install API. final AddModuleSourceCallback? onAddModuleSource; - /// 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 storeCapabilities; + /// 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? storeCapabilities; /// Host-side native file picker for the Run tab's file inputs. /// Studio passes a real file dialog; null keeps the manual @@ -141,7 +141,7 @@ class FlowEditorPage extends StatefulWidget { this.style, this.onInstallCapability, this.onAddModuleSource, - this.storeCapabilities = const [], + this.storeCapabilities, this.activeProject = '', this.onSwitchToFileProject, this.onPickFile, @@ -616,7 +616,9 @@ outputs: installedNames: _installedNames( widget.availableCapabilities, ), - storeNames: _installedNames(widget.storeCapabilities), + storeNames: widget.storeCapabilities == null + ? null + : _installedNames(widget.storeCapabilities!), activeProject: widget.activeProject, onOpen: _openFile, onRefresh: _refreshFiles, @@ -1399,10 +1401,12 @@ class _FileList extends StatefulWidget { final Set installedNames; /// Bare capability NAMES a configured store can install - /// (host-filtered to installable entries). Missing caps - /// outside this set render the "not in store" state instead - /// of an install action that the hub would refuse. - final Set storeNames; + /// (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? storeNames; /// Active workspace project slug; empty = all projects. Files /// without a `project:` key count as `general`. @@ -1670,6 +1674,7 @@ class _FileListState extends State<_FileList> { MissingModulesBadge( installable: split.installable, notInStore: split.notInStore, + unclassified: split.unclassified, strings: strings, // Install only what the store // resolves — the not-in-store diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart index 8582b78..ff5a6ed 100644 --- a/lib/src/flow_yaml_controller.dart +++ b/lib/src/flow_yaml_controller.dart @@ -30,7 +30,7 @@ import 'wire_colors.dart'; class FlowYamlCodeController extends CodeController { FlowYamlCodeController({ List Function()? availableCapabilities, - List Function()? storeCapabilities, + List? 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 Function() available, - List Function()? store, + List? Function()? store, AnalyzerStrings? strings, }) { analyzer = FlowAnalyzer( diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index a9c7cdf..39e93a5 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -268,6 +268,16 @@ class FlowEditorStrings { '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 `).', + '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 `).', + ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); String analyzerUnknownType(String kind, String value, String validList) => @@ -295,6 +305,14 @@ 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', @@ -352,6 +370,7 @@ 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; @@ -367,6 +386,7 @@ class AnalyzerStrings { required this.unknownCapInStore, required this.unknownCapTypo, required this.unknownCapNotInStore, + required this.unknownCapStoreUnknown, required this.unknownType, required this.inputKind, required this.outputKind, @@ -385,6 +405,7 @@ class AnalyzerStrings { unknownCapInStore: s.analyzerUnknownCapInStore, unknownCapTypo: s.analyzerUnknownCapTypo, unknownCapNotInStore: s.analyzerUnknownCapNotInStore, + unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown, unknownType: s.analyzerUnknownType, inputKind: s.analyzerInputKind, outputKind: s.analyzerOutputKind, @@ -405,6 +426,7 @@ class AnalyzerStrings { : unknownCapInStore = _enUnknownCapInStore, unknownCapTypo = _enUnknownCapTypo, unknownCapNotInStore = _enUnknownCapNotInStore, + unknownCapStoreUnknown = _enUnknownCapStoreUnknown, unknownType = _enUnknownType, inputKind = _enInputKind, outputKind = _enOutputKind, @@ -424,6 +446,11 @@ class AnalyzerStrings { 'No configured store can install it — install a local module ' '(`chain install --link `), 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 `).'; static String _enUnknownType(String kind, String value, String validList) => 'Unknown $kind type "$value". Use one of: $validList.'; static String _enInputKind() => 'input'; diff --git a/lib/src/widgets/missing_modules_badge.dart b/lib/src/widgets/missing_modules_badge.dart index 43fba36..319911b 100644 --- a/lib/src/widgets/missing_modules_badge.dart +++ b/lib/src/widgets/missing_modules_badge.dart @@ -13,22 +13,39 @@ import '../tokens.dart'; /// /// [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 therefore means -/// "nothing is store-installable", not "unknown": the honest -/// state without store data is the not-in-store explanation, and -/// the local-install / add-store / configure-integration paths -/// remain available. -({List installable, List notInStore}) splitMissingCaps( +/// — 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 installable, + List notInStore, + List unclassified, +}) splitMissingCaps( List missing, - Set storeNames, + Set? storeNames, ) { + if (storeNames == null) { + // Store state unknown (snapshot not loaded / unreachable): + // claim neither "installable" nor "not in store". + return ( + installable: const [], + notInStore: const [], + unclassified: missing, + ); + } final installable = []; final notInStore = []; for (final cap in missing) { final bare = cap.split('@').first; (storeNames.contains(bare) ? installable : notInStore).add(cap); } - return (installable: installable, notInStore: notInStore); + return ( + installable: installable, + notInStore: notInStore, + unclassified: const [], + ); } /// "N modules missing" status on flows whose steps reference @@ -48,10 +65,13 @@ import '../tokens.dart'; /// 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. +/// 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 installable; final List notInStore; + final List unclassified; final FlowEditorStrings strings; final VoidCallback? onInstall; const MissingModulesBadge({ @@ -60,6 +80,7 @@ class MissingModulesBadge extends StatelessWidget { required this.notInStore, required this.strings, required this.onInstall, + this.unclassified = const [], }); @override @@ -92,6 +113,15 @@ class MissingModulesBadge extends StatelessWidget { ), ), ), + 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, diff --git a/test/missing_modules_badge_test.dart b/test/missing_modules_badge_test.dart index 51be03b..9a36236 100644 --- a/test/missing_modules_badge_test.dart +++ b/test/missing_modules_badge_test.dart @@ -25,6 +25,14 @@ void main() { 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']); }); }); @@ -86,6 +94,29 @@ void main() { }, ); + 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(find.byType(Tooltip)); + expect(tooltip.message, contains('store is not reachable')); + }); + testWidgets('mixed state renders both chips, install covers store caps', ( tester, ) async { From 8da50068f3408cf7dd859006dd71bf50496a0e49 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 22 Jul 2026 14:13:17 +0200 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20changelog=20=E2=80=94=20unknown-sto?= =?UTF-8?q?re=20hardening=20under=200.25.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flemming-it --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58940..efce017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ Project separation in the flow list: 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 From 29294a29710a5dd0462acbce1ab3d94ea002aff7 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:08:51 +0200 Subject: [PATCH 7/8] feat: sample flows follow the hub's wire flag; examples group + import action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Example chip used to come from a client-side content-marker scan that had drifted from the hub's renamed sample header — no current sample matched it. The host now passes the hub-reported sample set (FlowSummary.sample) and the editor renders truth only: no host info, no chips. Samples collapse under one 'Examples (N)' group below the operator's own flows (expanded only when nothing else exists), and an optional onImportSamples action on the empty list is the deliberate way to pull the bundled examples into a sealed area's empty hub. Guard: flow_list_samples_test pins chips-from-host-only, the grouping, and the import round trip. Signed-off-by: flemming-it --- CHANGELOG.md | 21 ++++ lib/src/flow_editor_page.dart | 180 +++++++++++++++++++++++++------ lib/src/l10n.dart | 4 + lib/src/widgets.dart | 10 ++ pubspec.yaml | 2 +- test/flow_list_samples_test.dart | 144 +++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 31 deletions(-) create mode 100644 test/flow_list_samples_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index efce017..ccc5edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.26.0 + +Sample flows follow the hub's wire flag (sealed-areas rework): + +- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of + flow names the hub reports as bundled samples + (`FlowSummary.sample`). This is now the ONLY source of the + "Example" chip; the editor's own content-marker scan is gone (it + had silently drifted from the hub's renamed sample header and + missed every current sample). Null = no host info = no chips. +- **Examples group.** Sample flows collapse under one + "Examples (N)" row below the operator's own flows, expanded on + demand — or by default when there is nothing else to show. +- **`FlowEditorPage.onImportSamples`** — optional empty-list action + ("Import example flows"); sealed areas start without samples, so + hosts wire this as the deliberate pull. The list refreshes itself + after the import. +- `FaiEmptyState` gained an optional `action` slot. +- The editor reloads its file list when the host changes `flowsDir` + or delivers the sample set after an async fetch. + ## 0.25.0 Honest install badge — the flow list and analyzer only offer diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 5054b1f..73d60ba 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -25,6 +25,7 @@ library; import 'dart:io'; import 'dart:ui'; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; @@ -132,6 +133,20 @@ class FlowEditorPage extends StatefulWidget { /// (hermetic per shared/TESTING.md). final String? flowsDir; + /// Names of the flows the HOST reports as bundled samples (the + /// hub's `FlowSummary.sample` wire flag). Null = the host has no + /// sample information (old hub / standalone use) — then no + /// example chips render at all. The hub's sample header is the + /// single source of truth; the editor no longer keeps its own + /// marker scan (it had already drifted from the hub's header). + final Set? sampleFlowNames; + + /// Import the bundled sample flows into the current hub. + /// Rendered as the action of the empty list state — sealed + /// areas start without examples, this is the deliberate pull. + /// Null hides the action. + final Future Function()? onImportSamples; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -147,6 +162,8 @@ class FlowEditorPage extends StatefulWidget { this.onPickFile, this.toolbarTrailing, this.flowsDir, + this.sampleFlowNames, + this.onImportSamples, }); @override @@ -193,6 +210,18 @@ class _FlowEditorPageState extends State } } + @override + void didUpdateWidget(covariant FlowEditorPage old) { + super.didUpdateWidget(old); + // The host may repoint the directory (connection switch) or + // deliver the hub's sample set after an async fetch — both + // change what the list must show. + if (widget.flowsDir != old.flowsDir || + !setEquals(widget.sampleFlowNames, old.sampleFlowNames)) { + setState(() => _files = _listFiles()); + } + } + @override void dispose() { _controller.codeController.hoverRequest.removeListener(_onHoverChanged); @@ -324,11 +353,13 @@ class _FlowEditorPageState extends State // by path + mtime so a refresh that didn't touch a file // doesn't re-read it, and a paint never re-reads at all. final meta = await _flowMetaCache.forFile(f, stat); + final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''); files.add( _FlowFile( - name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''), + name: name, path: f.path, sizeBytes: stat.size, + isExample: widget.sampleFlowNames?.contains(name) ?? false, meta: meta, ), ); @@ -613,6 +644,17 @@ outputs: filesFuture: _files, activeName: _controller.activeName, strings: _l, + onImportSamples: widget.onImportSamples == null + ? null + : () async { + await widget.onImportSamples!(); + if (mounted) { + final fresh = _listFiles(); + setState(() { + _files = fresh; + }); + } + }, installedNames: _installedNames( widget.availableCapabilities, ), @@ -1271,28 +1313,27 @@ class _FlowFile { final String path; final int sizeBytes; - /// Scan result for this file — whether it's a bundled example - /// and which capabilities its steps require. Computed once at - /// list-load time (see [_FlowMetaCache]). + /// True when the HOST reports this flow as a bundled sample + /// (hub wire flag) — never guessed from the file content. + final bool isExample; + + /// Scan result for this file — which capabilities its steps + /// require. Computed once at list-load time (see + /// [_FlowMetaCache]). final _FlowMeta meta; const _FlowFile({ required this.name, required this.path, required this.sizeBytes, + required this.isExample, required this.meta, }); } -/// Marker text every bundled sample flow carries in its -/// provenance comment header. A file is an example iff its raw -/// content contains this exact string. -const String _sampleFlowMarker = 'F∆I sample flow'; - -/// Result of scanning a single flow file: provenance + the -/// capability NAMES (without `@version`) its steps reference. +/// Result of scanning a single flow file: the capability NAMES +/// (without `@version`) its steps reference. class _FlowMeta { - final bool isExample; final List requiredCaps; /// The file's own normalized `project:` slug; empty when the @@ -1300,12 +1341,11 @@ class _FlowMeta { /// filter — display semantics only, the file is never rewritten). final String project; const _FlowMeta({ - required this.isExample, required this.requiredCaps, this.project = '', }); - static const empty = _FlowMeta(isExample: false, requiredCaps: []); + static const empty = _FlowMeta(requiredCaps: []); /// Capabilities this flow needs that the hub does not provide. /// [availableNames] is the set of capability NAMES (the part @@ -1343,12 +1383,11 @@ class _FlowMetaCache { } } -/// Scan raw flow YAML for its example marker and the capability -/// ids referenced by `use:` lines. A line scan is used rather -/// than a full YAML parse: it's robust against malformed flows -/// (the analyzer reports those separately) and never throws. +/// Scan raw flow YAML for the capability ids referenced by +/// `use:` lines. A line scan is used rather than a full YAML +/// parse: it's robust against malformed flows (the analyzer +/// reports those separately) and never throws. _FlowMeta _scanFlow(String text) { - final isExample = text.contains(_sampleFlowMarker); final caps = {}; final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$'); for (final raw in text.split('\n')) { @@ -1368,7 +1407,6 @@ _FlowMeta _scanFlow(String text) { if (name.isNotEmpty) caps.add(name); } return _FlowMeta( - isExample: isExample, requiredCaps: caps.toList(), project: parseFlowProject(text), ); @@ -1424,10 +1462,14 @@ class _FileList extends StatefulWidget { /// renders, just without the one-click action. final Future Function(List)? onInstallMissing; + /// Import the bundled samples (empty-list action); null hides it. + final Future Function()? onImportSamples; + const _FileList({ required this.filesFuture, required this.activeName, required this.strings, + this.onImportSamples, required this.installedNames, required this.storeNames, required this.activeProject, @@ -1447,6 +1489,23 @@ class _FileListState extends State<_FileList> { /// finding: no search over the flow list at all). String _filter = ''; + /// Whether the examples group is expanded. Null until the + /// operator toggles it — the default then follows the content + /// (expanded only when there are no own flows). + bool? _samplesExpanded; + + /// Guards the empty-state import button against double-taps. + bool _importing = false; + + Future _runImport() async { + setState(() => _importing = true); + try { + await widget.onImportSamples!(); + } finally { + if (mounted) setState(() => _importing = false); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1542,6 +1601,15 @@ class _FileListState extends State<_FileList> { icon: Icons.folder_outlined, title: strings.listEmptyTitle, hint: strings.listEmptyBody, + // Sealed areas start without examples — offer the + // deliberate pull right where the emptiness shows. + action: widget.onImportSamples == null + ? null + : OutlinedButton.icon( + onPressed: _importing ? null : _runImport, + icon: const Icon(Icons.download_outlined, size: 16), + label: Text(strings.flowListSamplesImport), + ), ), ); } @@ -1586,11 +1654,66 @@ class _FileListState extends State<_FileList> { ), ); } - return ListView.builder( + // Grouping: the operator's own flows first; bundled + // examples collapsed under one labelled group so samples + // are never mistaken for area work (they expand by + // default only when there is nothing else to show). + final own = files.where((f) => !f.isExample).toList(); + final samples = files.where((f) => f.isExample).toList(); + final samplesExpanded = _samplesExpanded ?? own.isEmpty; + return ListView( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), - itemCount: files.length, - itemBuilder: (_, i) { - final f = files[i]; + children: [ + for (final f in own) _row(theme, f), + if (samples.isNotEmpty) ...[ + InkWell( + onTap: () => + setState(() => _samplesExpanded = !samplesExpanded), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + child: Row( + children: [ + Icon( + Icons.auto_awesome_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + strings.flowListSamplesGroup(samples.length), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + samplesExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (samplesExpanded) for (final f in samples) _row(theme, f), + ], + ], + ); + }, + ); + } + + Widget _row(ThemeData theme, _FlowFile f) { + final strings = widget.strings; + { final isActive = f.name == widget.activeName; final missing = f.meta.missingCaps(widget.installedNames); final split = splitMissingCaps(missing, widget.storeNames); @@ -1662,13 +1785,13 @@ class _FileListState extends State<_FileList> { fontSize: 11, ), ), - if (f.meta.isExample || missing.isNotEmpty) ...[ + if (f.isExample || missing.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - if (f.meta.isExample) + if (f.isExample) _ExampleBadge(strings: strings), if (missing.isNotEmpty) MissingModulesBadge( @@ -1716,10 +1839,7 @@ class _FileListState extends State<_FileList> { ), ), ); - }, - ); - }, - ); + } } } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 39e93a5..7eef70d 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -294,6 +294,10 @@ class FlowEditorStrings { 'Bundled sample flow.', 'Mitgelieferter Beispiel-Flow.', ); + String flowListSamplesGroup(int n) => + _t('Examples ($n)', 'Beispiele ($n)'); + String get flowListSamplesImport => + _t('Import example flows', 'Beispiel-Flows importieren'); String flowListNeedsModules(int n) => _t( n == 1 ? '1 module missing' : '$n modules missing', n == 1 ? '1 Modul fehlt' : '$n Module fehlen', diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart index 89bf739..23bd68c 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget { final IconData icon; final String title; final String? hint; + + /// Optional action rendered under the hint (e.g. the empty flow + /// list's "import example flows" button). + final Widget? action; + const FaiEmptyState({ super.key, required this.icon, required this.title, this.hint, + this.action, }); @override @@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget { textAlign: TextAlign.center, ), ], + if (action != null) ...[ + const SizedBox(height: FaiSpace.md), + action!, + ], ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 8308d58..3d6656e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.25.0 +version: 0.26.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_list_samples_test.dart b/test/flow_list_samples_test.dart new file mode 100644 index 0000000..caedf2f --- /dev/null +++ b/test/flow_list_samples_test.dart @@ -0,0 +1,144 @@ +// Widget-level proof of the sample handling contract (persona +// review 2026-08-27 / sealed-areas rework): +// +// * The host's sample set (the hub's FlowSummary.sample wire +// flag) is the ONLY source of the "Example" chip — no host +// info, no chips. The editor's old content-marker scan had +// silently drifted from the hub's header and is gone. +// * Sample flows collapse under one "Examples (N)" group so +// they are never mistaken for the operator's own work; the +// group expands only on demand (or when nothing else exists). +// * An empty list offers "Import example flows" iff the host +// wired the action (sealed areas start empty on purpose). +// +// Hermetic — temp flows dir, never ~/.chain. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; + +Future _pumpEditor( + WidgetTester tester, { + required String flowsDir, + Set? sampleFlowNames, + Future Function()? onImportSamples, +}) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: FlowEditorPage( + flowsDir: flowsDir, + sampleFlowNames: sampleFlowNames, + onImportSamples: onImportSamples, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); +} + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('chain_editor_samples_test'); + File('${tmp.path}/my-work.yaml').writeAsStringSync( + 'name: my-work\nsteps: []\n', + ); + File('${tmp.path}/hello.yaml').writeAsStringSync( + 'name: hello\nsteps: []\n', + ); + }); + + tearDown(() { + tmp.deleteSync(recursive: true); + }); + + testWidgets('host-reported samples collapse under the examples group', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + sampleFlowNames: const {'hello'}, + ); + // Own flow visible, sample hidden behind the collapsed group. + expect(find.text('my-work'), findsOneWidget); + expect(find.text('hello'), findsNothing); + expect(find.text('Examples (1)'), findsOneWidget); + + await tester.tap(find.text('Examples (1)')); + await tester.pump(); + expect(find.text('hello'), findsOneWidget); + // The revealed sample row carries the chip. + expect(find.text('Example'), findsOneWidget); + }); + + testWidgets('no host sample info means no chips and no group', ( + tester, + ) async { + await _pumpEditor(tester, flowsDir: tmp.path, sampleFlowNames: null); + expect(find.text('my-work'), findsOneWidget); + expect(find.text('hello'), findsOneWidget); + expect(find.text('Example'), findsNothing); + expect(find.textContaining('Examples ('), findsNothing); + }); + + testWidgets('a samples-only list starts expanded', (tester) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + await _pumpEditor( + tester, + flowsDir: tmp.path, + sampleFlowNames: const {'hello'}, + ); + expect(find.text('hello'), findsOneWidget); + expect(find.text('Examples (1)'), findsOneWidget); + }); + + testWidgets('the empty list offers the import action when wired', ( + tester, + ) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + File('${tmp.path}/hello.yaml').deleteSync(); + var imported = 0; + await _pumpEditor( + tester, + flowsDir: tmp.path, + onImportSamples: () async { + imported++; + File('${tmp.path}/hello.yaml').writeAsStringSync( + 'name: hello\nsteps: []\n', + ); + }, + ); + final button = find.text('Import example flows'); + expect(button, findsOneWidget); + await tester.runAsync(() async { + await tester.tap(button); + // Let the import callback, the directory re-list (real IO) + // and the FutureBuilder's completion callback all run. + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); + expect(imported, 1); + // The imported flow appears without a manual refresh. + expect(find.text('hello'), findsOneWidget); + }); + + testWidgets('the empty list stays action-free when nothing is wired', ( + tester, + ) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + File('${tmp.path}/hello.yaml').deleteSync(); + await _pumpEditor(tester, flowsDir: tmp.path); + expect(find.text('Import example flows'), findsNothing); + }); +} From 36a83f9ae461c3df1be54195355b462f9a81f100 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:11:55 +0200 Subject: [PATCH 8/8] fix: setState callback must not return the reload future Signed-off-by: flemming-it --- lib/src/flow_editor_page.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 73d60ba..6363851 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -218,7 +218,10 @@ class _FlowEditorPageState extends State // change what the list must show. if (widget.flowsDir != old.flowsDir || !setEquals(widget.sampleFlowNames, old.sampleFlowNames)) { - setState(() => _files = _listFiles()); + final fresh = _listFiles(); + setState(() { + _files = fresh; + }); } }