// FlowsPage — Studio's flow surface. Thin host wrapper around // the swappable chain_studio_flow_editor package: loads the // hub's installed capabilities, hands them + a Studio-side // FlowRunDriver to the editor, and lets the editor own // everything else (file list, three-tab body, properties // panel, run progress, …). // // Pre-0.52 versions split file-listing and run-dialog state // across this page. v0.52 moves all of that into the editor // package so a swap is one pubspec change — Studio doesn't // need to know what's inside the editor any more. import 'dart:async' show unawaited; import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../data/store_caps.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../widgets/chain_install_confirm.dart'; import '../widgets/chain_workspace_switcher.dart'; class FlowsPage extends StatefulWidget { /// Pre-load this flow when the editor first builds. Studio /// keeps the parameter so the Cmd+K palette + welcome page /// can deep-link straight into a specific flow. final String? initialFlowName; const FlowsPage({super.key, this.initialFlowName}); @override State createState() => _FlowsPageState(); } class _FlowsPageState extends State { late Future> _capabilities; late final StudioFlowRunDriver _driver; /// Snapshot of bare capability names the public store can /// install; null while the store state is UNKNOWN (not yet /// loaded / search failed) so the editor claims neither /// "installable" nor "not in store". Refreshed at open, after /// an install and on a connection switch. List? _storeCaps; /// Which hub connection the snapshots were loaded against — /// the sealed-area slug, or empty for the shared hub. A sealed /// switch keeps this page mounted, so without the guard the /// editor would keep offering the OTHER hub's capabilities. String _connKey = Workspace.instance.activeSealed?.slug ?? ''; @override void initState() { super.initState(); _driver = StudioFlowRunDriver(); _capabilities = _loadCapabilities(); _loadStoreCapabilities(); Workspace.instance.addListener(_onWorkspaceChanged); } @override void dispose() { Workspace.instance.removeListener(_onWorkspaceChanged); super.dispose(); } /// Reload both capability snapshots when the CONNECTION target /// changes (entering/leaving a sealed area) — a plain project /// filter change stays cheap and reuses the loaded lists. void _onWorkspaceChanged() { final key = Workspace.instance.activeSealed?.slug ?? ''; if (key == _connKey) return; _connKey = key; if (!mounted) return; setState(() { _storeCaps = null; // other hub — snapshot unknown again _capabilities = _loadCapabilities(); }); _loadStoreCapabilities(); } Future _loadStoreCapabilities() async { try { final items = await HubService.instance.searchStore(limit: 500); if (!mounted) return; // Only capabilities the store can actually install — the // editor's Install quick-fix and list badge promise exactly // what the hub's install resolver accepts. (This set used to // ingest requiresCapabilities too, which offered installs // that ended in "no store entry for ''".) setState( () => _storeCaps = installableStoreCapabilities(items).toList()..sort(), ); } catch (_) { // Soft-fail: keep the last snapshot for THIS connection if // one exists; otherwise stay in the honest "unknown" state // (no install offers, no not-in-store claims). Add-source // remains available either way. } } /// Pulls the capability identifiers the picker dialog /// offers when adding a step. `@` so /// the operator can see exactly what they'll be wiring in, /// matching the format the `use:` field expects. Soft-fails /// to an empty list if the hub is unreachable — the editor /// falls back to a free-form text field in that case. Future> _loadCapabilities() async { try { final caps = await HubService.instance.allCapabilities(); final entries = caps.map((c) => '${c.capability}@${c.version}').toSet().toList() ..sort(); return entries; } catch (_) { return const []; } } /// Quick-fix handler — invoked when the operator clicks /// `Install ` on an analyzer issue. Parses the /// editor-supplied `provider/name@version` spec into the /// Hub's install API and returns the refreshed capability /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. /// Native picker for a flow file input. `withData` loads the /// bytes directly so the editor never touches the path itself /// (sandbox-friendly: the picker grants access to exactly the /// chosen file). Future _pickFlowInputFile() async { final picked = await FilePicker.pickFiles(withData: true); final f = picked?.files.single; if (f == null || f.bytes == null) return null; return PickedFileData(fileName: f.name, bytes: f.bytes!); } Future?> _onInstallCapability(String capability) async { // The analyzer hands us the full `use:` value (e.g. `debug.echo@^0`). // The hub resolves install sources by bare capability name; the // `@` suffix would make the store lookup miss ("no store // entry for 'debug.echo@^0'"). Strip it — same as the Store page. final at = capability.indexOf('@'); final bare = at < 0 ? capability : capability.substring(0, at); // The quick-fix install goes through the same trust dialog as // the Store page — no quieter direct path. final confirmed = await ChainInstallConfirmDialog.showForCapability(context, bare); if (!confirmed) return null; return _runInstall(source: bare); } /// `Add source for …` handler. Prompts the operator /// for a local path or URL, then routes through the same /// install RPC. Returns the refreshed capability list on /// success, null on cancel / failure. Future?> _onAddModuleSource(String capability) async { final source = await _AddModuleSourceDialog.show(context, capability); if (source == null || source.isEmpty) return null; return _runInstall(source: source); } Future?> _runInstall({required String source}) async { try { await HubService.instance.installModule(source: source); // The store snapshot may have moved too (an install can pull // in new entries / the operator refreshed a store) — keep the // badge's truth in step with the capability list. unawaited(_loadStoreCapabilities()); final caps = await HubService.instance.allCapabilities(); final updated = caps .map((c) => '${c.capability}@${c.version}') .toSet() .toList() ..sort(); if (mounted) { setState(() => _capabilities = Future.value(updated)); } return updated; } catch (e) { if (mounted) { final l = AppLocalizations.of(context); // Copyable error (was a plain SnackBar) — install failures // carry the real cause (network, signature, store lookup). showChainErrorSnack( context, 'flows.install', e, title: l != null ? l.installFailed('') : 'Install failed', ); } return null; } } @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context); final editorLocale = locale.languageCode == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en; // Rebuild the editor's project chip when the workspace changes, // so a mismatch banner appears/clears live. return ListenableBuilder( listenable: Workspace.instance, builder: (context, _) => FutureBuilder>( future: _capabilities, builder: (context, snap) { final caps = snap.data ?? const []; return FlowEditorPage( initialFlowName: widget.initialFlowName, locale: editorLocale, runDriver: _driver, availableCapabilities: caps, storeCapabilities: _storeCaps, onInstallCapability: _onInstallCapability, onAddModuleSource: _onAddModuleSource, activeProject: Workspace.instance.activeSlug, // Same switcher as Audit/Approvals/Runs, hosted in the // editor's toolbar (the page's single header): it // filters the flow list and is the project a new flow // gets stamped with. toolbarTrailing: const ChainWorkspaceSwitcher(), // Native file dialog for the Run tab's file inputs — // nobody should have to type an absolute path by hand. onPickFile: _pickFlowInputFile, // The file wins for the run; accepting the switch only // aligns the workspace view to the file's project. onSwitchToFileProject: (slug) => Workspace.instance.setActive(slug), ); }, ), ); } } /// Modal asking the operator where a missing capability can be /// installed from — either a local module directory (the /// `--link` install path) or a remote URL the hub can resolve. /// Returns the typed source string on submit, null on cancel. class _AddModuleSourceDialog extends StatefulWidget { final String capability; const _AddModuleSourceDialog({required this.capability}); static Future show(BuildContext context, String capability) { return showDialog( context: context, builder: (_) => _AddModuleSourceDialog(capability: capability), ); } @override State<_AddModuleSourceDialog> createState() => _AddModuleSourceDialogState(); } class _AddModuleSourceDialogState extends State<_AddModuleSourceDialog> { final TextEditingController _ctrl = TextEditingController(); String _source = ''; @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.addSourceTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addSourceIntro(widget.capability), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), TextField( controller: _ctrl, autofocus: true, onChanged: (v) => setState(() => _source = v.trim()), decoration: InputDecoration( labelText: l.addSourceField, hintText: l.addSourceHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addSourceHowItWorksTitle, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Text( l.addSourceHowItWorksBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(4), ), child: SelectableText( l.addSourceCliExample, style: TextStyle( fontFamily: 'JetBrains Mono', fontFamilyFallback: const [ 'Menlo', 'Consolas', 'Courier New', 'monospace', ], fontSize: 11, color: theme.colorScheme.onSurface, ), ), ), ], ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l.addSourceCancel), ), FilledButton( onPressed: _source.isEmpty ? null : () => Navigator.of(context).pop(_source), child: Text(l.addSourceInstallButton), ), ], ); } }