From 57dac3d999e04e8c1523aad5592163c3427f9b72 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 15:54:04 +0200 Subject: [PATCH] feat(studio): module-store manager (list / add / remove) A store icon in the Store app bar opens a dialog that lists the configured module stores + the bundled seed (with per-source module counts), lets the operator add a store by index URL (the hub fetches + merges it live so its modules appear immediately), and remove a store. Backed by the new ListStores/AddStore/RemoveStore RPCs + SDK methods. This is how a domain app's published modules (e.g. reclaim's) become visible in the Store without touching the CLI. Signed-off-by: flemming-it --- lib/data/hub.dart | 15 ++ lib/pages/store.dart | 5 + lib/widgets/chain_stores_dialog.dart | 196 +++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + 4 files changed, 217 insertions(+) create mode 100644 lib/widgets/chain_stores_dialog.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index f113fa7..5d67fba 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -212,6 +212,21 @@ class HubService { Future healthy() => _client.healthy(); + /// Configured module stores (+ the bundled seed) for the store manager. + Future> listStores() => _client.listStores(); + + /// Register a new module store; the hub fetches + merges it live. + Future addStore({ + required String name, + required String url, + String bearerEnv = '', + }) => + _client.addStore(name: name, url: url, bearerEnv: bearerEnv); + + /// Drop a configured store by name (the bundled seed cannot be removed). + Future removeStore(String name) => + _client.removeStore(name); + /// List of installed WASM modules, grouped by `module_name`. /// Built-in and federated capabilities are excluded — they /// don't correspond to a bundle on disk and would confuse the diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 2614c35..0aea6eb 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -234,6 +234,11 @@ class _StorePageState extends State { onPressed: _openFilterDialog, ), ), + IconButton( + icon: const Icon(Icons.storefront_outlined, size: 18), + tooltip: 'Module stores', + onPressed: () => ChainStoresDialog.show(context), + ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: l.storeReloadTooltip, diff --git a/lib/widgets/chain_stores_dialog.dart b/lib/widgets/chain_stores_dialog.dart new file mode 100644 index 0000000..d36ee6c --- /dev/null +++ b/lib/widgets/chain_stores_dialog.dart @@ -0,0 +1,196 @@ +// Module-store manager — list the configured module stores (+ the +// bundled seed), add a store by index URL, or remove one. The hub merges +// every store's modules into one searchable index, so adding a store +// (e.g. a domain app's published modules) makes them appear in the Store. + +import 'package:chain_client_sdk/chain_client_sdk.dart'; +import 'package:flutter/material.dart'; + +import '../data/error_presentation.dart'; +import '../data/hub.dart'; +import '../theme/tokens.dart'; + +class ChainStoresDialog extends StatefulWidget { + const ChainStoresDialog._(); + + static Future show(BuildContext context) => showDialog( + context: context, + builder: (_) => const ChainStoresDialog._(), + ); + + @override + State createState() => _ChainStoresDialogState(); +} + +class _ChainStoresDialogState extends State { + late Future> _future; + final _name = TextEditingController(); + final _url = TextEditingController(); + bool _busy = false; + + @override + void initState() { + super.initState(); + _reload(); + } + + void _reload() => + setState(() => _future = HubService.instance.listStores()); + + @override + void dispose() { + _name.dispose(); + _url.dispose(); + super.dispose(); + } + + Future _add() async { + final name = _name.text.trim(); + final url = _url.text.trim(); + if (name.isEmpty || url.isEmpty) return; + setState(() => _busy = true); + try { + final r = await HubService.instance.addStore(name: name, url: url); + if (!mounted) return; + _name.clear(); + _url.clear(); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(r.message))); + _reload(); + } catch (e) { + if (mounted) { + showFaiErrorSnack(context, 'store.add', e, + title: 'Could not add store'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _remove(String name) async { + try { + final r = await HubService.instance.removeStore(name); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(r.message))); + _reload(); + } catch (e) { + if (mounted) { + showFaiErrorSnack(context, 'store.remove', e, + title: 'Could not remove store'); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Module stores'), + content: SizedBox( + width: 540, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'The hub merges modules from every store below into one index. ' + 'Add a store index URL to make its modules installable here.', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: ChainSpace.md), + Flexible( + child: FutureBuilder>( + future: _future, + builder: (c, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final stores = snap.data ?? const []; + return ListView( + shrinkWrap: true, + children: [ + for (final s in stores) + ListTile( + dense: true, + leading: Icon( + s.removable + ? Icons.cloud_outlined + : Icons.inventory_2_outlined, + size: 18, + ), + title: Text(s.name), + subtitle: Text( + s.url.isEmpty + ? '${s.moduleCount} modules · bundled' + : '${s.url}\n${s.moduleCount} modules', + ), + isThreeLine: s.url.isNotEmpty, + trailing: s.removable + ? IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () => _remove(s.name), + ) + : const Icon(Icons.lock_outline, size: 16), + ), + ], + ); + }, + ), + ), + const Divider(), + Text('Add a store', style: theme.textTheme.titleSmall), + const SizedBox(height: ChainSpace.sm), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _name, + decoration: const InputDecoration( + labelText: 'Name', + isDense: true, + ), + ), + ), + const SizedBox(width: ChainSpace.sm), + Expanded( + flex: 4, + child: TextField( + controller: _url, + decoration: const InputDecoration( + labelText: 'Index URL (https:// or file://)', + isDense: true, + ), + onSubmitted: (_) => _add(), + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + FilledButton( + onPressed: _busy ? null : _add, + child: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Add store'), + ), + ], + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 5597bd2..6c8ca68 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -16,5 +16,6 @@ export 'chain_log_viewer.dart'; export 'chain_module_sheet.dart'; export 'chain_pill.dart'; export 'chain_settings_dialog.dart'; +export 'chain_stores_dialog.dart'; export 'chain_status_dot.dart'; export 'chain_system_ai_editor.dart';