fix: never claim 'not in store' while the store state is unknown

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-22 14:02:28 +02:00
parent c4a39a3779
commit 2535c28fce
6 changed files with 131 additions and 34 deletions

View file

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

View file

@ -95,12 +95,12 @@ class FlowEditorPage extends StatefulWidget {
/// then call the Hub install API. /// then call the Hub install API.
final AddModuleSourceCallback? onAddModuleSource; final AddModuleSourceCallback? onAddModuleSource;
/// Capabilities the public store knows how to install. The /// Capabilities the store can actually install, or null when
/// analyzer uses this to decide whether to show "Install …" /// the store state is UNKNOWN (snapshot not loaded / store
/// (in store) or "Add source for …" (not in store) as the /// unreachable). Drives the analyzer's quick-fix choice and
/// quick-fix on an unknown `use:` line. Empty list = store /// the flow list's badge: in store → Install; known-absent →
/// silent no install button offered. /// "not in store" + recovery paths; unknown neither claim.
final List<String> storeCapabilities; final List<String>? storeCapabilities;
/// Host-side native file picker for the Run tab's file inputs. /// Host-side native file picker for the Run tab's file inputs.
/// Studio passes a real file dialog; null keeps the manual /// Studio passes a real file dialog; null keeps the manual
@ -141,7 +141,7 @@ class FlowEditorPage extends StatefulWidget {
this.style, this.style,
this.onInstallCapability, this.onInstallCapability,
this.onAddModuleSource, this.onAddModuleSource,
this.storeCapabilities = const [], this.storeCapabilities,
this.activeProject = '', this.activeProject = '',
this.onSwitchToFileProject, this.onSwitchToFileProject,
this.onPickFile, this.onPickFile,
@ -616,7 +616,9 @@ outputs:
installedNames: _installedNames( installedNames: _installedNames(
widget.availableCapabilities, widget.availableCapabilities,
), ),
storeNames: _installedNames(widget.storeCapabilities), storeNames: widget.storeCapabilities == null
? null
: _installedNames(widget.storeCapabilities!),
activeProject: widget.activeProject, activeProject: widget.activeProject,
onOpen: _openFile, onOpen: _openFile,
onRefresh: _refreshFiles, onRefresh: _refreshFiles,
@ -1399,10 +1401,12 @@ class _FileList extends StatefulWidget {
final Set<String> installedNames; final Set<String> installedNames;
/// Bare capability NAMES a configured store can install /// Bare capability NAMES a configured store can install
/// (host-filtered to installable entries). Missing caps /// (host-filtered to installable entries), or null when the
/// outside this set render the "not in store" state instead /// store state is unknown. Missing caps outside this set
/// of an install action that the hub would refuse. /// render the "not in store" state instead of an install
final Set<String> storeNames; /// action that the hub would refuse; with null neither claim
/// is made.
final Set<String>? storeNames;
/// Active workspace project slug; empty = all projects. Files /// Active workspace project slug; empty = all projects. Files
/// without a `project:` key count as `general`. /// without a `project:` key count as `general`.
@ -1670,6 +1674,7 @@ class _FileListState extends State<_FileList> {
MissingModulesBadge( MissingModulesBadge(
installable: split.installable, installable: split.installable,
notInStore: split.notInStore, notInStore: split.notInStore,
unclassified: split.unclassified,
strings: strings, strings: strings,
// Install only what the store // Install only what the store
// resolves the not-in-store // resolves the not-in-store

View file

@ -30,7 +30,7 @@ import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController { class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({ FlowYamlCodeController({
List<String> Function()? availableCapabilities, List<String> Function()? availableCapabilities,
List<String> Function()? storeCapabilities, List<String>? Function()? storeCapabilities,
AnalyzerStrings analyzerStrings = AnalyzerStrings.english, AnalyzerStrings analyzerStrings = AnalyzerStrings.english,
}) : super( }) : super(
text: '', text: '',
@ -49,7 +49,7 @@ class FlowYamlCodeController extends CodeController {
/// closure or stale strings inside an old FlowAnalyzer. /// closure or stale strings inside an old FlowAnalyzer.
void setCapabilityProviders({ void setCapabilityProviders({
required List<String> Function() available, required List<String> Function() available,
List<String> Function()? store, List<String>? Function()? store,
AnalyzerStrings? strings, AnalyzerStrings? strings,
}) { }) {
analyzer = FlowAnalyzer( analyzer = FlowAnalyzer(

View file

@ -268,6 +268,16 @@ class FlowEditorStrings {
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) ' 'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
'einrichten, die sie bereitstellt.', 'einrichten, die sie bereitstellt.',
); );
String analyzerUnknownCapStoreUnknown(String cap) => _t(
'Unknown capability "$cap". '
'The store is not reachable right now, so it may or may not '
'be installable — check the spelling, or add a local source '
'(`chain install --link <path>`).',
'Unbekannte Capability "$cap". '
'Der Store ist gerade nicht erreichbar — ob sie installierbar '
'ist, lässt sich nicht sagen. Tippfehler prüfen oder lokale '
'Quelle hinzufügen (`chain install --link <pfad>`).',
);
String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerInputKind() => _t('input', 'Eingabe');
String analyzerOutputKind() => _t('output', 'Ausgabe'); String analyzerOutputKind() => _t('output', 'Ausgabe');
String analyzerUnknownType(String kind, String value, String validList) => String analyzerUnknownType(String kind, String value, String validList) =>
@ -295,6 +305,14 @@ class FlowEditorStrings {
'Klicken, um die fehlenden zu installieren.', 'Klicken, um die fehlenden zu installieren.',
); );
String get flowListInstallMissing => _t('Install', 'Installieren'); String get flowListInstallMissing => _t('Install', 'Installieren');
String flowListNeedsModulesTooltipNoAction(String caps) => _t(
'This flow needs capabilities that are not installed:\n$caps\n'
'The store is not reachable right now — no install offer '
'until Studio can check it.',
'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n'
'Der Store ist gerade nicht erreichbar — kein Install-Angebot, '
'bis Studio das prüfen kann.',
);
String flowListNotInStore(int n) => _t( String flowListNotInStore(int n) => _t(
n == 1 ? 'not in store' : '$n not in store', n == 1 ? 'not in store' : '$n not in store',
n == 1 ? 'nicht im Store' : '$n nicht im 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) unknownCapInStore;
final String Function(String cap, String suggestion) unknownCapTypo; final String Function(String cap, String suggestion) unknownCapTypo;
final String Function(String cap) unknownCapNotInStore; final String Function(String cap) unknownCapNotInStore;
final String Function(String cap) unknownCapStoreUnknown;
final String Function(String kind, String value, String validList) final String Function(String kind, String value, String validList)
unknownType; unknownType;
final String Function() inputKind; final String Function() inputKind;
@ -367,6 +386,7 @@ class AnalyzerStrings {
required this.unknownCapInStore, required this.unknownCapInStore,
required this.unknownCapTypo, required this.unknownCapTypo,
required this.unknownCapNotInStore, required this.unknownCapNotInStore,
required this.unknownCapStoreUnknown,
required this.unknownType, required this.unknownType,
required this.inputKind, required this.inputKind,
required this.outputKind, required this.outputKind,
@ -385,6 +405,7 @@ class AnalyzerStrings {
unknownCapInStore: s.analyzerUnknownCapInStore, unknownCapInStore: s.analyzerUnknownCapInStore,
unknownCapTypo: s.analyzerUnknownCapTypo, unknownCapTypo: s.analyzerUnknownCapTypo,
unknownCapNotInStore: s.analyzerUnknownCapNotInStore, unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
unknownType: s.analyzerUnknownType, unknownType: s.analyzerUnknownType,
inputKind: s.analyzerInputKind, inputKind: s.analyzerInputKind,
outputKind: s.analyzerOutputKind, outputKind: s.analyzerOutputKind,
@ -405,6 +426,7 @@ class AnalyzerStrings {
: unknownCapInStore = _enUnknownCapInStore, : unknownCapInStore = _enUnknownCapInStore,
unknownCapTypo = _enUnknownCapTypo, unknownCapTypo = _enUnknownCapTypo,
unknownCapNotInStore = _enUnknownCapNotInStore, unknownCapNotInStore = _enUnknownCapNotInStore,
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
unknownType = _enUnknownType, unknownType = _enUnknownType,
inputKind = _enInputKind, inputKind = _enInputKind,
outputKind = _enOutputKind, outputKind = _enOutputKind,
@ -424,6 +446,11 @@ class AnalyzerStrings {
'No configured store can install it — install a local module ' 'No configured store can install it — install a local module '
'(`chain install --link <path>`), add the store that provides ' '(`chain install --link <path>`), add the store that provides '
'it, or configure the integration (MCP/n8n) that supplies it.'; 'it, or configure the integration (MCP/n8n) that supplies it.';
static String _enUnknownCapStoreUnknown(String cap) =>
'Unknown capability "$cap". '
'The store is not reachable right now, so it may or may not '
'be installable — check the spelling, or add a local source '
'(`chain install --link <path>`).';
static String _enUnknownType(String kind, String value, String validList) => static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.'; 'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input'; static String _enInputKind() => 'input';

View file

@ -13,22 +13,39 @@ import '../tokens.dart';
/// ///
/// [storeNames] is the host-supplied set of bare capability names /// [storeNames] is the host-supplied set of bare capability names
/// the store can install (already filtered to installable entries /// the store can install (already filtered to installable entries
/// published/alpha, native). An empty set therefore means /// published/alpha, native). An empty set means "nothing is
/// "nothing is store-installable", not "unknown": the honest /// store-installable" (known state); `null` means the store state
/// state without store data is the not-in-store explanation, and /// is UNKNOWN (snapshot not loaded / unreachable) then every
/// the local-install / add-store / configure-integration paths /// missing cap lands in `unclassified` and the UI claims neither
/// remain available. /// "installable" nor "not in store".
({List<String> installable, List<String> notInStore}) splitMissingCaps( ({
List<String> installable,
List<String> notInStore,
List<String> unclassified,
}) splitMissingCaps(
List<String> missing, List<String> missing,
Set<String> storeNames, Set<String>? storeNames,
) { ) {
if (storeNames == null) {
// Store state unknown (snapshot not loaded / unreachable):
// claim neither "installable" nor "not in store".
return (
installable: const <String>[],
notInStore: const <String>[],
unclassified: missing,
);
}
final installable = <String>[]; final installable = <String>[];
final notInStore = <String>[]; final notInStore = <String>[];
for (final cap in missing) { for (final cap in missing) {
final bare = cap.split('@').first; final bare = cap.split('@').first;
(storeNames.contains(bare) ? installable : notInStore).add(cap); (storeNames.contains(bare) ? installable : notInStore).add(cap);
} }
return (installable: installable, notInStore: notInStore); return (
installable: installable,
notInStore: notInStore,
unclassified: const <String>[],
);
} }
/// "N modules missing" status on flows whose steps reference /// "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 /// own quiet chip whose tooltip explains the three recovery paths
/// (local install / add store / configure integration) BEFORE any /// (local install / add store / configure integration) BEFORE any
/// click, instead of an install button that would end in the /// 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 { class MissingModulesBadge extends StatelessWidget {
final List<String> installable; final List<String> installable;
final List<String> notInStore; final List<String> notInStore;
final List<String> unclassified;
final FlowEditorStrings strings; final FlowEditorStrings strings;
final VoidCallback? onInstall; final VoidCallback? onInstall;
const MissingModulesBadge({ const MissingModulesBadge({
@ -60,6 +80,7 @@ class MissingModulesBadge extends StatelessWidget {
required this.notInStore, required this.notInStore,
required this.strings, required this.strings,
required this.onInstall, required this.onInstall,
this.unclassified = const [],
}); });
@override @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) if (notInStore.isNotEmpty)
_chip( _chip(
theme, theme,

View file

@ -25,6 +25,14 @@ void main() {
final split = splitMissingCaps(['text.extract'], {}); final split = splitMissingCaps(['text.extract'], {});
expect(split.installable, isEmpty); expect(split.installable, isEmpty);
expect(split.notInStore, ['text.extract']); 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<Tooltip>(find.byType(Tooltip));
expect(tooltip.message, contains('store is not reachable'));
});
testWidgets('mixed state renders both chips, install covers store caps', ( testWidgets('mixed state renders both chips, install covers store caps', (
tester, tester,
) async { ) async {