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 {