Compare commits

...

2 commits

Author SHA1 Message Date
a0a5038ad7 fix: read flows from ~/.chain after config-dir rename
The platform's operator config dir moved ~/.fai -> ~/.chain; the flow
editor read saved flows + layouts from the old path, so it found nothing
after the rename. Update the hardcoded ~/.fai/data/flows paths to
~/.chain.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 09:40:22 +02:00
0ebc319808 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>
2026-06-12 00:17:04 +02:00
4 changed files with 313 additions and 19 deletions

View file

@ -49,7 +49,7 @@ String _defaultFlowsDir() {
Platform.environment['HOME'] ?? Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ?? Platform.environment['USERPROFILE'] ??
'.'; '.';
return '$home/.fai/data/flows'; return '$home/.chain/data/flows';
} }
class FlowEditorPage extends StatefulWidget { class FlowEditorPage extends StatefulWidget {
@ -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 {

View file

@ -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) =>

View file

@ -18,7 +18,7 @@
// //
// Storage layout: // Storage layout:
// //
// ~/.fai/data/flows/.layout/<flow-name>.json // ~/.chain/data/flows/.layout/<flow-name>.json
// //
// One file per flow. JSON shape: // One file per flow. JSON shape:
// //
@ -93,7 +93,7 @@ class NodePosition {
/// graph view loads / saves them at well-defined moments /// graph view loads / saves them at well-defined moments
/// (open file, drag end), not in the render loop. /// (open file, drag end), not in the render loop.
class LayoutStore { 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 /// write. Hidden so it doesn't show up in `fai admin
/// flows list` style enumerations. /// flows list` style enumerations.
static String defaultDir() { static String defaultDir() {
@ -101,7 +101,7 @@ class LayoutStore {
Platform.environment['HOME'] ?? Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ?? Platform.environment['USERPROFILE'] ??
'.'; '.';
return '$home/.fai/data/flows/.layout'; return '$home/.chain/data/flows/.layout';
} }
/// Load the layout for [flowName]. Returns an empty layout /// Load the layout for [flowName]. Returns an empty layout

View file

@ -15,7 +15,7 @@
// 4. The flow's outputs once the run resolves. // 4. The flow's outputs once the run resolves.
// //
// The tab requires the file to be saved on disk the hub's // The tab requires the file to be saved on disk the hub's
// runSavedFlow reads from `~/.fai/data/flows/<name>.yaml`, // runSavedFlow reads from `~/.chain/data/flows/<name>.yaml`,
// not from the in-memory buffer. The dirty banner reminds // not from the in-memory buffer. The dirty banner reminds
// the operator to save before running so they don't run a // the operator to save before running so they don't run a
// stale version by surprise. // stale version by surprise.