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