Compare commits

..

No commits in common. "a0a5038ad71c2df569c8fe09562a1b28ee8aaf46" and "5c5ec4f990d67ee7edf75b30e4fcbd8e74677aaa" have entirely different histories.

4 changed files with 19 additions and 313 deletions

View file

@ -49,7 +49,7 @@ String _defaultFlowsDir() {
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'.';
return '$home/.chain/data/flows';
return '$home/.fai/data/flows';
}
class FlowEditorPage extends StatefulWidget {
@ -123,11 +123,6 @@ 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
@ -269,23 +264,20 @@ class _FlowEditorPageState extends State<FlowEditorPage>
.where((e) => e is File && e.path.endsWith('.yaml'))
.cast<File>()
.toList();
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));
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));
return files;
}
@ -423,40 +415,6 @@ 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,
@ -546,14 +504,8 @@ 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),
@ -1092,107 +1044,13 @@ 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.
@ -1208,26 +1066,15 @@ 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
@ -1307,7 +1154,6 @@ 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(
@ -1376,25 +1222,6 @@ 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),
),
],
),
],
],
),
),
@ -1409,108 +1236,6 @@ 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 {

View file

@ -245,25 +245,6 @@ 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) =>

View file

@ -18,7 +18,7 @@
//
// Storage layout:
//
// ~/.chain/data/flows/.layout/<flow-name>.json
// ~/.fai/data/flows/.layout/<flow-name>.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 `~/.chain/data/flows/.layout/`, created on first
/// Directory `~/.fai/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/.chain/data/flows/.layout';
return '$home/.fai/data/flows/.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.
//
// The tab requires the file to be saved on disk the hub's
// runSavedFlow reads from `~/.chain/data/flows/<name>.yaml`,
// runSavedFlow reads from `~/.fai/data/flows/<name>.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.