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.
final List<String> 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<String> 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<String>? 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 <String>[];
final storeBare = storeCaps.map(_bareCap).toSet();
final storeCaps = storeCapabilities?.call();
final storeKnown = storeCaps != null;
final storeBare =
(storeCaps ?? const <String>[]).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,

View file

@ -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<String> 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<String>? 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<String> 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<String> 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<String>? 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

View file

@ -30,7 +30,7 @@ import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({
List<String> Function()? availableCapabilities,
List<String> Function()? storeCapabilities,
List<String>? 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<String> Function() available,
List<String> Function()? store,
List<String>? Function()? store,
AnalyzerStrings? strings,
}) {
analyzer = FlowAnalyzer(

View file

@ -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 <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 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 <path>`), 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 <path>`).';
static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input';

View file

@ -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<String> installable, List<String> 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<String> installable,
List<String> notInStore,
List<String> unclassified,
}) splitMissingCaps(
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 notInStore = <String>[];
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 <String>[],
);
}
/// "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<String> installable;
final List<String> notInStore;
final List<String> 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,

View file

@ -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<Tooltip>(find.byType(Tooltip));
expect(tooltip.message, contains('store is not reachable'));
});
testWidgets('mixed state renders both chips, install covers store caps', (
tester,
) async {