diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 400235c..6e1167f 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -49,7 +49,7 @@ String _defaultFlowsDir() { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return '$home/.fai/data/flows'; + return '$home/.chain/data/flows'; } class FlowEditorPage extends StatefulWidget { @@ -123,6 +123,11 @@ class _FlowEditorPageState extends State late Future> _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 .where((e) => e is File && e.path.endsWith('.yaml')) .cast() .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 _installMissingCaps(List caps) async { + final handler = widget.onInstallCapability; + if (handler == null || caps.isEmpty) return; + List? 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 _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 _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 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 missingCaps(Set 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 _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 = {}; + 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 @ 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 _installedNames(List 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> 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 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 Function(List)? 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 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 { diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 0424b0f..a549432 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -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) => diff --git a/lib/src/model/layout_store.dart b/lib/src/model/layout_store.dart index a9067e5..a80964d 100644 --- a/lib/src/model/layout_store.dart +++ b/lib/src/model/layout_store.dart @@ -18,7 +18,7 @@ // // Storage layout: // -// ~/.fai/data/flows/.layout/.json +// ~/.chain/data/flows/.layout/.json // // One file per flow. JSON shape: // @@ -93,7 +93,7 @@ class NodePosition { /// graph view loads / saves them at well-defined moments /// (open file, drag end), not in the render loop. class LayoutStore { - /// Directory `~/.fai/data/flows/.layout/`, created on first + /// Directory `~/.chain/data/flows/.layout/`, created on first /// write. Hidden so it doesn't show up in `fai admin /// flows list` style enumerations. static String defaultDir() { @@ -101,7 +101,7 @@ class LayoutStore { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return '$home/.fai/data/flows/.layout'; + return '$home/.chain/data/flows/.layout'; } /// Load the layout for [flowName]. Returns an empty layout diff --git a/lib/src/widgets/run_tab.dart b/lib/src/widgets/run_tab.dart index 69f65ba..5fa692d 100644 --- a/lib/src/widgets/run_tab.dart +++ b/lib/src/widgets/run_tab.dart @@ -15,7 +15,7 @@ // 4. The flow's outputs once the run resolves. // // The tab requires the file to be saved on disk — the hub's -// runSavedFlow reads from `~/.fai/data/flows/.yaml`, +// runSavedFlow reads from `~/.chain/data/flows/.yaml`, // not from the in-memory buffer. The dirty banner reminds // the operator to save before running so they don't run a // stale version by surprise.