feat(flows): example badges + missing-module hints in the flow list
Each row in the FLOWS sidebar now shows at a glance whether a flow is a bundled example (detected from the provenance header) and whether it can run yet: a quiet "Example" badge and, when a flow uses capabilities that aren't installed, an amber "needs N modules" chip whose tooltip lists them and which installs them in one tap via the host's existing install callback. Per-flow scan results are cached by path + mtime so files are read on list load/refresh only, never on a paint. New DE+EN l10n keys. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
5c5ec4f990
commit
0ebc319808
2 changed files with 308 additions and 14 deletions
|
|
@ -123,6 +123,11 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
late Future<List<_FlowFile>> _files;
|
||||
late final TabController _tabs;
|
||||
|
||||
/// Per-file scan cache (example marker + required caps),
|
||||
/// keyed by path + mtime. Populated during [_listFiles] so the
|
||||
/// file rows never re-read from disk on a rebuild/paint.
|
||||
final _FlowMetaCache _flowMetaCache = _FlowMetaCache();
|
||||
|
||||
/// Live hover overlay — inserted when the pointer enters an
|
||||
/// underlined issue range, removed when it leaves both the
|
||||
/// range and the tooltip card. Kept on State so we can clear
|
||||
|
|
@ -264,20 +269,23 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
.where((e) => e is File && e.path.endsWith('.yaml'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
final files =
|
||||
entries
|
||||
.map(
|
||||
(f) => _FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(
|
||||
RegExp(r'\.yaml$'),
|
||||
'',
|
||||
),
|
||||
path: f.path,
|
||||
sizeBytes: f.statSync().size,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
final files = <_FlowFile>[];
|
||||
for (final f in entries) {
|
||||
final stat = f.statSync();
|
||||
// Per-file scan (example marker + required caps) is cached
|
||||
// by path + mtime so a refresh that didn't touch a file
|
||||
// doesn't re-read it, and a paint never re-reads at all.
|
||||
final meta = await _flowMetaCache.forFile(f, stat);
|
||||
files.add(
|
||||
_FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||
path: f.path,
|
||||
sizeBytes: stat.size,
|
||||
meta: meta,
|
||||
),
|
||||
);
|
||||
}
|
||||
files.sort((a, b) => a.name.compareTo(b.name));
|
||||
return files;
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +423,40 @@ outputs:
|
|||
setState(() => _files = _listFiles());
|
||||
}
|
||||
|
||||
/// Install the capabilities a flow row flagged as missing,
|
||||
/// reusing the host's existing [FlowEditorPage.onInstallCapability]
|
||||
/// path — no new install mechanism. Installs sequentially,
|
||||
/// keeps the latest capability list, refreshes the analyzer
|
||||
/// providers, and re-scans the file list so the row's badge
|
||||
/// updates. Returns once all installs settle.
|
||||
Future<void> _installMissingCaps(List<String> caps) async {
|
||||
final handler = widget.onInstallCapability;
|
||||
if (handler == null || caps.isEmpty) return;
|
||||
List<String>? latest;
|
||||
for (final cap in caps) {
|
||||
try {
|
||||
final result = await handler(cap);
|
||||
if (result != null) latest = result;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (latest != null) {
|
||||
_controller.codeController.setCapabilityProviders(
|
||||
available: () => latest!,
|
||||
store: () => widget.storeCapabilities,
|
||||
strings: AnalyzerStrings.from(_l),
|
||||
);
|
||||
await _controller.codeController.reanalyze();
|
||||
if (!mounted) return;
|
||||
}
|
||||
await _refreshFiles();
|
||||
}
|
||||
|
||||
Future<void> _addStep() async {
|
||||
final picked = await CapabilityPicker.show(
|
||||
context,
|
||||
|
|
@ -504,8 +546,14 @@ outputs:
|
|||
filesFuture: _files,
|
||||
activeName: _controller.activeName,
|
||||
strings: _l,
|
||||
installedNames: _installedNames(
|
||||
widget.availableCapabilities,
|
||||
),
|
||||
onOpen: _openFile,
|
||||
onRefresh: _refreshFiles,
|
||||
onInstallMissing: widget.onInstallCapability == null
|
||||
? null
|
||||
: _installMissingCaps,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
|
|
@ -1044,13 +1092,107 @@ class _FlowFile {
|
|||
final String name;
|
||||
final String path;
|
||||
final int sizeBytes;
|
||||
|
||||
/// Scan result for this file — whether it's a bundled example
|
||||
/// and which capabilities its steps require. Computed once at
|
||||
/// list-load time (see [_FlowMetaCache]).
|
||||
final _FlowMeta meta;
|
||||
|
||||
const _FlowFile({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.sizeBytes,
|
||||
required this.meta,
|
||||
});
|
||||
}
|
||||
|
||||
/// Marker text every bundled sample flow carries in its
|
||||
/// provenance comment header. A file is an example iff its raw
|
||||
/// content contains this exact string.
|
||||
const String _sampleFlowMarker = 'F∆I sample flow';
|
||||
|
||||
/// Built-in capabilities the Hub always provides, even when
|
||||
/// they aren't in the host-supplied installed list. Treated as
|
||||
/// satisfied so the "needs N modules" badge never flags them.
|
||||
const Set<String> _builtinCapabilities = {'debug.echo'};
|
||||
|
||||
/// Result of scanning a single flow file: provenance + the
|
||||
/// capability NAMES (without `@version`) its steps reference.
|
||||
class _FlowMeta {
|
||||
final bool isExample;
|
||||
final List<String> requiredCaps;
|
||||
const _FlowMeta({required this.isExample, required this.requiredCaps});
|
||||
|
||||
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
||||
|
||||
/// Capabilities this flow needs that are neither installed nor
|
||||
/// built in. [availableNames] is the set of installed
|
||||
/// capability NAMES (the part before `@`).
|
||||
List<String> missingCaps(Set<String> availableNames) => requiredCaps
|
||||
.where(
|
||||
(c) =>
|
||||
!availableNames.contains(c) && !_builtinCapabilities.contains(c),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
|
||||
/// and scanning a flow's YAML happens here, only when the file
|
||||
/// is new or changed since the last list load — never on a
|
||||
/// paint.
|
||||
class _FlowMetaCache {
|
||||
final Map<String, ({DateTime mtime, _FlowMeta meta})> _byPath = {};
|
||||
|
||||
Future<_FlowMeta> forFile(File f, FileStat stat) async {
|
||||
final cached = _byPath[f.path];
|
||||
if (cached != null && cached.mtime == stat.modified) {
|
||||
return cached.meta;
|
||||
}
|
||||
_FlowMeta meta;
|
||||
try {
|
||||
final text = await f.readAsString();
|
||||
meta = _scanFlow(text);
|
||||
} catch (_) {
|
||||
meta = _FlowMeta.empty;
|
||||
}
|
||||
_byPath[f.path] = (mtime: stat.modified, meta: meta);
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan raw flow YAML for its example marker and the capability
|
||||
/// ids referenced by `use:` lines. A line scan is used rather
|
||||
/// than a full YAML parse: it's robust against malformed flows
|
||||
/// (the analyzer reports those separately) and never throws.
|
||||
_FlowMeta _scanFlow(String text) {
|
||||
final isExample = text.contains(_sampleFlowMarker);
|
||||
final caps = <String>{};
|
||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||
for (final raw in text.split('\n')) {
|
||||
// Skip comment-only lines.
|
||||
final line = raw.split('#').first;
|
||||
final m = useRe.firstMatch(line);
|
||||
if (m == null) continue;
|
||||
var value = m.group(1)!.trim();
|
||||
// Strip surrounding quotes if present.
|
||||
if (value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
// Drop the @<version-range> suffix, keep the capability name.
|
||||
final name = value.split('@').first.trim();
|
||||
if (name.isNotEmpty) caps.add(name);
|
||||
}
|
||||
return _FlowMeta(isExample: isExample, requiredCaps: caps.toList());
|
||||
}
|
||||
|
||||
/// Reduce the host-supplied installed list (entries like
|
||||
/// `text.extract@0.1.0`) to the set of bare capability NAMES so
|
||||
/// satisfaction is checked name-first, version-agnostic.
|
||||
Set<String> _installedNames(List<String> available) =>
|
||||
available.map((c) => c.split('@').first).toSet();
|
||||
|
||||
/// Compact byte-count formatter used in the flow-list rows.
|
||||
/// '12 B' / '4.3 KB' / '1.2 MB'. Avoids overflowing the
|
||||
/// narrow sidebar with raw byte counts on long flows.
|
||||
|
|
@ -1066,15 +1208,26 @@ class _FileList extends StatelessWidget {
|
|||
final Future<List<_FlowFile>> filesFuture;
|
||||
final String? activeName;
|
||||
final FlowEditorStrings strings;
|
||||
|
||||
/// Bare capability NAMES the host reports as installed. Used
|
||||
/// to compute each row's missing-module count.
|
||||
final Set<String> installedNames;
|
||||
final void Function(_FlowFile) onOpen;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
/// Install the listed missing capabilities. `null` when the
|
||||
/// host wired no install handler — the warning badge still
|
||||
/// renders, just without the one-click action.
|
||||
final Future<void> Function(List<String>)? onInstallMissing;
|
||||
|
||||
const _FileList({
|
||||
required this.filesFuture,
|
||||
required this.activeName,
|
||||
required this.strings,
|
||||
required this.installedNames,
|
||||
required this.onOpen,
|
||||
required this.onRefresh,
|
||||
required this.onInstallMissing,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -1154,6 +1307,7 @@ class _FileList extends StatelessWidget {
|
|||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
final isActive = f.name == activeName;
|
||||
final missing = f.meta.missingCaps(installedNames);
|
||||
return InkWell(
|
||||
onTap: () => onOpen(f),
|
||||
child: Container(
|
||||
|
|
@ -1222,6 +1376,25 @@ class _FileList extends StatelessWidget {
|
|||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
if (f.meta.isExample)
|
||||
_ExampleBadge(strings: strings),
|
||||
if (missing.isNotEmpty)
|
||||
_MissingModulesBadge(
|
||||
missing: missing,
|
||||
strings: strings,
|
||||
onInstall: onInstallMissing == null
|
||||
? null
|
||||
: () => onInstallMissing!(missing),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1236,6 +1409,108 @@ class _FileList extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Quiet "Example" / "Beispiel" chip on bundled sample flows.
|
||||
/// Tinted from the surface palette so it reads as metadata, not
|
||||
/// an alert — stays subtle in both light and dark themes.
|
||||
class _ExampleBadge extends StatelessWidget {
|
||||
final FlowEditorStrings strings;
|
||||
const _ExampleBadge({required this.strings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final fg = theme.colorScheme.onSurfaceVariant;
|
||||
return Tooltip(
|
||||
message: strings.flowListExampleTooltip,
|
||||
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: [
|
||||
Icon(Icons.auto_awesome_outlined, size: 10, color: fg),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
strings.flowListExampleBadge,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warning-toned "needs N modules" chip on flows whose steps
|
||||
/// reference capabilities that aren't installed. Tooltip lists
|
||||
/// the exact missing ids. When an install handler is wired, the
|
||||
/// chip is tappable and one-click installs all missing caps via
|
||||
/// the host's existing install path.
|
||||
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);
|
||||
// Amber warning tone consistent with the diagnostic strip's
|
||||
// warning colour, readable on both light + dark surfaces.
|
||||
const warn = Color(0xFFEF6C00);
|
||||
final label = '${strings.flowListNeedsModules(missing.length)}'
|
||||
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}';
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: warn.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(color: warn.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.extension_off_outlined, size: 10, color: warn),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: warn,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Tooltip(
|
||||
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
|
||||
child: onInstall == null
|
||||
? chip
|
||||
: InkWell(
|
||||
onTap: onInstall,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- empty state ---
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,25 @@ class FlowEditorStrings {
|
|||
);
|
||||
String analyzerYamlError(String detail) => _t('YAML: $detail', 'YAML: $detail');
|
||||
|
||||
// Flow-list row badges — example provenance + missing-module
|
||||
// warning. Kept short so they fit the narrow sidebar.
|
||||
String get flowListExampleBadge => _t('Example', 'Beispiel');
|
||||
String get flowListExampleTooltip => _t(
|
||||
'Bundled sample flow.',
|
||||
'Mitgelieferter Beispiel-Flow.',
|
||||
);
|
||||
String flowListNeedsModules(int n) => _t(
|
||||
n == 1 ? 'needs 1 module' : 'needs $n modules',
|
||||
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
|
||||
);
|
||||
String flowListNeedsModulesTooltip(String caps) => _t(
|
||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||
'Click to install the missing ones.',
|
||||
'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n'
|
||||
'Klicken, um die fehlenden zu installieren.',
|
||||
);
|
||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||
|
||||
// Quick-fix button labels.
|
||||
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
||||
String fixAddSource(String cap) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue