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 Future<List<_FlowFile>> _files;
|
||||||
late final TabController _tabs;
|
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
|
/// Live hover overlay — inserted when the pointer enters an
|
||||||
/// underlined issue range, removed when it leaves both the
|
/// underlined issue range, removed when it leaves both the
|
||||||
/// range and the tooltip card. Kept on State so we can clear
|
/// 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'))
|
.where((e) => e is File && e.path.endsWith('.yaml'))
|
||||||
.cast<File>()
|
.cast<File>()
|
||||||
.toList();
|
.toList();
|
||||||
final files =
|
final files = <_FlowFile>[];
|
||||||
entries
|
for (final f in entries) {
|
||||||
.map(
|
final stat = f.statSync();
|
||||||
(f) => _FlowFile(
|
// Per-file scan (example marker + required caps) is cached
|
||||||
name: f.uri.pathSegments.last.replaceAll(
|
// by path + mtime so a refresh that didn't touch a file
|
||||||
RegExp(r'\.yaml$'),
|
// doesn't re-read it, and a paint never re-reads at all.
|
||||||
'',
|
final meta = await _flowMetaCache.forFile(f, stat);
|
||||||
),
|
files.add(
|
||||||
path: f.path,
|
_FlowFile(
|
||||||
sizeBytes: f.statSync().size,
|
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||||
),
|
path: f.path,
|
||||||
)
|
sizeBytes: stat.size,
|
||||||
.toList()
|
meta: meta,
|
||||||
..sort((a, b) => a.name.compareTo(b.name));
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
files.sort((a, b) => a.name.compareTo(b.name));
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,6 +423,40 @@ outputs:
|
||||||
setState(() => _files = _listFiles());
|
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 {
|
Future<void> _addStep() async {
|
||||||
final picked = await CapabilityPicker.show(
|
final picked = await CapabilityPicker.show(
|
||||||
context,
|
context,
|
||||||
|
|
@ -504,8 +546,14 @@ outputs:
|
||||||
filesFuture: _files,
|
filesFuture: _files,
|
||||||
activeName: _controller.activeName,
|
activeName: _controller.activeName,
|
||||||
strings: _l,
|
strings: _l,
|
||||||
|
installedNames: _installedNames(
|
||||||
|
widget.availableCapabilities,
|
||||||
|
),
|
||||||
onOpen: _openFile,
|
onOpen: _openFile,
|
||||||
onRefresh: _refreshFiles,
|
onRefresh: _refreshFiles,
|
||||||
|
onInstallMissing: widget.onInstallCapability == null
|
||||||
|
? null
|
||||||
|
: _installMissingCaps,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
|
|
@ -1044,13 +1092,107 @@ class _FlowFile {
|
||||||
final String name;
|
final String name;
|
||||||
final String path;
|
final String path;
|
||||||
final int sizeBytes;
|
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({
|
const _FlowFile({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.sizeBytes,
|
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.
|
/// Compact byte-count formatter used in the flow-list rows.
|
||||||
/// '12 B' / '4.3 KB' / '1.2 MB'. Avoids overflowing the
|
/// '12 B' / '4.3 KB' / '1.2 MB'. Avoids overflowing the
|
||||||
/// narrow sidebar with raw byte counts on long flows.
|
/// narrow sidebar with raw byte counts on long flows.
|
||||||
|
|
@ -1066,15 +1208,26 @@ class _FileList extends StatelessWidget {
|
||||||
final Future<List<_FlowFile>> filesFuture;
|
final Future<List<_FlowFile>> filesFuture;
|
||||||
final String? activeName;
|
final String? activeName;
|
||||||
final FlowEditorStrings strings;
|
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 void Function(_FlowFile) onOpen;
|
||||||
final VoidCallback onRefresh;
|
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({
|
const _FileList({
|
||||||
required this.filesFuture,
|
required this.filesFuture,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.strings,
|
required this.strings,
|
||||||
|
required this.installedNames,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
|
required this.onInstallMissing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1154,6 +1307,7 @@ class _FileList extends StatelessWidget {
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final f = files[i];
|
final f = files[i];
|
||||||
final isActive = f.name == activeName;
|
final isActive = f.name == activeName;
|
||||||
|
final missing = f.meta.missingCaps(installedNames);
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => onOpen(f),
|
onTap: () => onOpen(f),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -1222,6 +1376,25 @@ class _FileList extends StatelessWidget {
|
||||||
fontSize: 11,
|
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 ---
|
// --- empty state ---
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,25 @@ class FlowEditorStrings {
|
||||||
);
|
);
|
||||||
String analyzerYamlError(String detail) => _t('YAML: $detail', 'YAML: $detail');
|
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.
|
// Quick-fix button labels.
|
||||||
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
||||||
String fixAddSource(String cap) =>
|
String fixAddSource(String cap) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue