diff --git a/lib/main.dart b/lib/main.dart index 88b6d10..49b9611 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.64.0'; +const String kStudioVersion = '0.65.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index df7ea9d..378a467 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -32,11 +32,40 @@ class _FlowsPageState extends State { late Future> _capabilities; late final StudioFlowRunDriver _driver; + /// Snapshot of bare capability names the public store can + /// install. Refreshed once at open so the editor's analyzer + /// can decide between "Install" (in store) and "Add source" + /// (not in store) without a per-keystroke network call. + List _storeCaps = const []; + @override void initState() { super.initState(); _driver = StudioFlowRunDriver(); _capabilities = _loadCapabilities(); + _loadStoreCapabilities(); + } + + Future _loadStoreCapabilities() async { + try { + final items = await HubService.instance.searchStore(limit: 500); + if (!mounted) return; + // Store items carry a list of provided capabilities. We + // ingest all of them flat so a single store entry + // shipping multiple caps still reports each as installable. + // Falls back to the item name for legacy entries. + final caps = {}; + for (final item in items) { + if (item.requiresCapabilities.isNotEmpty) { + caps.addAll(item.requiresCapabilities); + } + caps.add(item.name); + } + setState(() => _storeCaps = caps.toList()..sort()); + } catch (_) { + // Soft-fail: empty list disables the Install button on + // unknown-cap fixes, but Add-source remains available. + } } /// Pulls the capability identifiers the picker dialog @@ -64,11 +93,22 @@ class _FlowsPageState extends State { /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. Future?> _onInstallCapability(String capability) async { + return _runInstall(source: capability); + } + + /// `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 { - // Editor passes the raw `use:` value (e.g. - // `text.classify@^1`). HubService.installModule resolves - // any version spec the hub's registry accepts. - await HubService.instance.installModule(source: capability); + await HubService.instance.installModule(source: source); final caps = await HubService.instance.allCapabilities(); final updated = caps .map((c) => '${c.capability}@${c.version}') @@ -76,8 +116,6 @@ class _FlowsPageState extends State { .toList() ..sort(); if (mounted) { - // Refresh the cached capability future so subsequent - // FutureBuilder rebuilds also see the new list. setState(() => _capabilities = Future.value(updated)); } return updated; @@ -110,9 +148,126 @@ class _FlowsPageState extends State { locale: editorLocale, runDriver: _driver, availableCapabilities: caps, + storeCapabilities: _storeCaps, onInstallCapability: _onInstallCapability, + onAddModuleSource: _onAddModuleSource, ); }, ); } } + +/// 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); + return AlertDialog( + title: const Text('Add module source'), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '`${widget.capability}` is not in the public store. ' + 'Point the hub at a `.fai` bundle URL or a local bundle path ' + 'and the hub will install + verify it.', + 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: const InputDecoration( + labelText: 'URL or path to .fai bundle', + hintText: 'https://example.com/foo-0.1.0.fai', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Developing a local module?', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Studio installs from URLs + packed bundles only. For an ' + 'unpacked source directory, use the CLI:', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + SelectableText( + 'fai install --link /path/to/module', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _source.isEmpty + ? null + : () => Navigator.of(context).pop(_source), + child: const Text('Install'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 19c1545..e58f379 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -126,7 +126,7 @@ packages: path: "../fai_studio_flow_editor" relative: true source: path - version: "0.16.0" + version: "0.17.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 129fc1a..78489aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.64.0 +version: 0.65.0 environment: sdk: ^3.11.0-200.1.beta