feat: honest install badge — classify store resolvability before the click

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-22 13:42:11 +02:00
parent 7d6a575cae
commit ab97e5e834
8 changed files with 340 additions and 105 deletions

View file

@ -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<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;
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<String> 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 {