feat(studio): store-side source filter, provenance pills, one-click recommended sources (v0.29.0)
The Store is now the centre of capability discovery — provider configuration moves out of Settings into the Store itself. Three changes work together: 1. Source filter chips next to the existing status chips: All sources / Native / MCP / n8n. Applied client-side after the hub returns results so toggling is instant. The result count and the "Clear filters" reset both account for the source filter too. 2. Per-card provenance pill (`_ProvenancePill`): shows whether each entry is native, mcp · <provider>, or n8n · <provider> so the operator can triage the source at a glance — same role the "verified" badge plays in commercial app stores. 3. `_RecommendedSourcesStrip` replaces the older `_FederationNudge`. Renders curated public MCP servers (DeepWiki, Semgrep) as one-click cards with an inline `[+ Add]` button — no Settings detour, no form. Both servers are HTTPS Streamable-HTTP, no API key, no subprocess. Useful AI was considered but its 340+ tools would drown the index — kept out of the curated list. The strip auto-hides the moment any federated entry exists. Removed the now-orphaned storeFederationNudge* ARB keys. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
b5a4c74c4b
commit
581ebbeaf7
8 changed files with 603 additions and 79 deletions
|
|
@ -177,9 +177,36 @@
|
||||||
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
|
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
|
||||||
"storeDocsErrorParse": "README ist kein gültiges UTF-8.",
|
"storeDocsErrorParse": "README ist kein gültiges UTF-8.",
|
||||||
"storeDocsErrorGeneric": "README konnte nicht geladen werden.",
|
"storeDocsErrorGeneric": "README konnte nicht geladen werden.",
|
||||||
"storeFederationNudgeTitle": "Mehr Capabilities?",
|
"storeSourceLabel": "Quelle",
|
||||||
"storeFederationNudgeBody": "MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.",
|
"storeSourceAll": "Alle Quellen",
|
||||||
"storeFederationNudgeButton": "Einstellungen öffnen",
|
"storeSourceNative": "Nativ",
|
||||||
|
"storeSourceMcp": "MCP",
|
||||||
|
"storeSourceN8n": "n8n",
|
||||||
|
"storeProvenanceNative": "nativ",
|
||||||
|
"storeProvenanceMcp": "mcp · {provider}",
|
||||||
|
"@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } },
|
||||||
|
"storeProvenanceN8n": "n8n · {provider}",
|
||||||
|
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
|
||||||
|
"storeRecommendedTitle": "Öffentliche Quelle hinzufügen — ein Klick",
|
||||||
|
"storeRecommendedBody": "Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.",
|
||||||
|
"storeRecommendedAddTooltip": "Hinzufügen und entdecken",
|
||||||
|
"storeRecommendedAdding": "Wird hinzugefügt…",
|
||||||
|
"storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)",
|
||||||
|
"storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen",
|
||||||
|
"storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Capability} other{föderierte Capabilities}}.",
|
||||||
|
"@storeProviderAddedToast": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": { "type": "String" },
|
||||||
|
"n": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storeProviderAddFailed": "{name} konnte nicht hinzugefügt werden: {error}",
|
||||||
|
"@storeProviderAddFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": { "type": "String" },
|
||||||
|
"error": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"auditTitle": "Protokoll",
|
"auditTitle": "Protokoll",
|
||||||
"auditNoEvents": "Noch keine Ereignisse",
|
"auditNoEvents": "Noch keine Ereignisse",
|
||||||
|
|
|
||||||
|
|
@ -178,9 +178,36 @@
|
||||||
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
|
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
|
||||||
"storeDocsErrorParse": "README is not valid UTF-8.",
|
"storeDocsErrorParse": "README is not valid UTF-8.",
|
||||||
"storeDocsErrorGeneric": "Could not load README.",
|
"storeDocsErrorGeneric": "Could not load README.",
|
||||||
"storeFederationNudgeTitle": "Want more capabilities?",
|
"storeSourceLabel": "Source",
|
||||||
"storeFederationNudgeBody": "Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.",
|
"storeSourceAll": "All sources",
|
||||||
"storeFederationNudgeButton": "Open Settings",
|
"storeSourceNative": "Native",
|
||||||
|
"storeSourceMcp": "MCP",
|
||||||
|
"storeSourceN8n": "n8n",
|
||||||
|
"storeProvenanceNative": "native",
|
||||||
|
"storeProvenanceMcp": "mcp · {provider}",
|
||||||
|
"@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } },
|
||||||
|
"storeProvenanceN8n": "n8n · {provider}",
|
||||||
|
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
|
||||||
|
"storeRecommendedTitle": "Add a public source — one click",
|
||||||
|
"storeRecommendedBody": "Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.",
|
||||||
|
"storeRecommendedAddTooltip": "Add and discover",
|
||||||
|
"storeRecommendedAdding": "Adding…",
|
||||||
|
"storeRecommendedDeepWikiTagline": "GitHub repo documentation search (AI-powered)",
|
||||||
|
"storeRecommendedSemgrepTagline": "Security scanning for code vulnerabilities",
|
||||||
|
"storeProviderAddedToast": "Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.",
|
||||||
|
"@storeProviderAddedToast": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": { "type": "String" },
|
||||||
|
"n": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storeProviderAddFailed": "Could not add {name}: {error}",
|
||||||
|
"@storeProviderAddFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": { "type": "String" },
|
||||||
|
"error": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"auditTitle": "Audit",
|
"auditTitle": "Audit",
|
||||||
"auditNoEvents": "No events yet",
|
"auditNoEvents": "No events yet",
|
||||||
|
|
|
||||||
|
|
@ -890,23 +890,101 @@ abstract class AppLocalizations {
|
||||||
/// **'Could not load README.'**
|
/// **'Could not load README.'**
|
||||||
String get storeDocsErrorGeneric;
|
String get storeDocsErrorGeneric;
|
||||||
|
|
||||||
/// No description provided for @storeFederationNudgeTitle.
|
/// No description provided for @storeSourceLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Want more capabilities?'**
|
/// **'Source'**
|
||||||
String get storeFederationNudgeTitle;
|
String get storeSourceLabel;
|
||||||
|
|
||||||
/// No description provided for @storeFederationNudgeBody.
|
/// No description provided for @storeSourceAll.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.'**
|
/// **'All sources'**
|
||||||
String get storeFederationNudgeBody;
|
String get storeSourceAll;
|
||||||
|
|
||||||
/// No description provided for @storeFederationNudgeButton.
|
/// No description provided for @storeSourceNative.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Open Settings'**
|
/// **'Native'**
|
||||||
String get storeFederationNudgeButton;
|
String get storeSourceNative;
|
||||||
|
|
||||||
|
/// No description provided for @storeSourceMcp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MCP'**
|
||||||
|
String get storeSourceMcp;
|
||||||
|
|
||||||
|
/// No description provided for @storeSourceN8n.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'n8n'**
|
||||||
|
String get storeSourceN8n;
|
||||||
|
|
||||||
|
/// No description provided for @storeProvenanceNative.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'native'**
|
||||||
|
String get storeProvenanceNative;
|
||||||
|
|
||||||
|
/// No description provided for @storeProvenanceMcp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'mcp · {provider}'**
|
||||||
|
String storeProvenanceMcp(String provider);
|
||||||
|
|
||||||
|
/// No description provided for @storeProvenanceN8n.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'n8n · {provider}'**
|
||||||
|
String storeProvenanceN8n(String provider);
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a public source — one click'**
|
||||||
|
String get storeRecommendedTitle;
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.'**
|
||||||
|
String get storeRecommendedBody;
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedAddTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add and discover'**
|
||||||
|
String get storeRecommendedAddTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedAdding.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Adding…'**
|
||||||
|
String get storeRecommendedAdding;
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedDeepWikiTagline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'GitHub repo documentation search (AI-powered)'**
|
||||||
|
String get storeRecommendedDeepWikiTagline;
|
||||||
|
|
||||||
|
/// No description provided for @storeRecommendedSemgrepTagline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Security scanning for code vulnerabilities'**
|
||||||
|
String get storeRecommendedSemgrepTagline;
|
||||||
|
|
||||||
|
/// No description provided for @storeProviderAddedToast.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.'**
|
||||||
|
String storeProviderAddedToast(String name, int n);
|
||||||
|
|
||||||
|
/// No description provided for @storeProviderAddFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not add {name}: {error}'**
|
||||||
|
String storeProviderAddFailed(String name, String error);
|
||||||
|
|
||||||
/// No description provided for @auditTitle.
|
/// No description provided for @auditTitle.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -471,14 +471,70 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.';
|
String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeTitle => 'Mehr Capabilities?';
|
String get storeSourceLabel => 'Quelle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeBody =>
|
String get storeSourceAll => 'Alle Quellen';
|
||||||
'MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeButton => 'Einstellungen öffnen';
|
String get storeSourceNative => 'Nativ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSourceMcp => 'MCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSourceN8n => 'n8n';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeProvenanceNative => 'nativ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProvenanceMcp(String provider) {
|
||||||
|
return 'mcp · $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProvenanceN8n(String provider) {
|
||||||
|
return 'n8n · $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedTitle =>
|
||||||
|
'Öffentliche Quelle hinzufügen — ein Klick';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedBody =>
|
||||||
|
'Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedAdding => 'Wird hinzugefügt…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedDeepWikiTagline =>
|
||||||
|
'GitHub-Repo-Doku-Suche (KI-gestützt)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedSemgrepTagline =>
|
||||||
|
'Security-Scanning für Code-Schwachstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProviderAddedToast(String name, int n) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
n,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'föderierte Capabilities',
|
||||||
|
one: 'föderierte Capability',
|
||||||
|
);
|
||||||
|
return '$name hinzugefügt — $n $_temp0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProviderAddFailed(String name, String error) {
|
||||||
|
return '$name konnte nicht hinzugefügt werden: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get auditTitle => 'Protokoll';
|
String get auditTitle => 'Protokoll';
|
||||||
|
|
|
||||||
|
|
@ -489,14 +489,69 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get storeDocsErrorGeneric => 'Could not load README.';
|
String get storeDocsErrorGeneric => 'Could not load README.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeTitle => 'Want more capabilities?';
|
String get storeSourceLabel => 'Source';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeBody =>
|
String get storeSourceAll => 'All sources';
|
||||||
'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeFederationNudgeButton => 'Open Settings';
|
String get storeSourceNative => 'Native';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSourceMcp => 'MCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSourceN8n => 'n8n';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeProvenanceNative => 'native';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProvenanceMcp(String provider) {
|
||||||
|
return 'mcp · $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProvenanceN8n(String provider) {
|
||||||
|
return 'n8n · $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedTitle => 'Add a public source — one click';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedBody =>
|
||||||
|
'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedAddTooltip => 'Add and discover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedAdding => 'Adding…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedDeepWikiTagline =>
|
||||||
|
'GitHub repo documentation search (AI-powered)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRecommendedSemgrepTagline =>
|
||||||
|
'Security scanning for code vulnerabilities';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProviderAddedToast(String name, int n) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
n,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'capabilities',
|
||||||
|
one: 'capability',
|
||||||
|
);
|
||||||
|
return 'Added $name — $n federated $_temp0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeProviderAddFailed(String name, String error) {
|
||||||
|
return 'Could not add $name: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get auditTitle => 'Audit';
|
String get auditTitle => 'Audit';
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.28.0';
|
const String kStudioVersion = '0.29.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,14 @@ class _StorePageState extends State<StorePage> {
|
||||||
final _queryCtrl = TextEditingController();
|
final _queryCtrl = TextEditingController();
|
||||||
String _category = '';
|
String _category = '';
|
||||||
String _status = '';
|
String _status = '';
|
||||||
|
/// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
|
||||||
|
/// client-side after the hub returns results — the search RPC
|
||||||
|
/// has no source field.
|
||||||
|
String _source = '';
|
||||||
bool _installedOnly = false;
|
bool _installedOnly = false;
|
||||||
|
/// Tracks per-recommended-source add buttons to disable them
|
||||||
|
/// while the request is in flight.
|
||||||
|
final Set<String> _addingSources = <String>{};
|
||||||
late Future<List<StoreItem>> _future;
|
late Future<List<StoreItem>> _future;
|
||||||
|
|
||||||
/// Active UI locale, read from the app-wide MaterialApp
|
/// Active UI locale, read from the app-wide MaterialApp
|
||||||
|
|
@ -139,8 +146,9 @@ class _StorePageState extends State<StorePage> {
|
||||||
FutureBuilder<List<StoreItem>>(
|
FutureBuilder<List<StoreItem>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
final items = snap.data ?? const <StoreItem>[];
|
final raw = snap.data ?? const <StoreItem>[];
|
||||||
final categories = _categoriesIn(items);
|
final items = _applySourceFilter(raw);
|
||||||
|
final categories = _categoriesIn(raw);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -155,11 +163,13 @@ class _StorePageState extends State<StorePage> {
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
_FilterRow(
|
_FilterRow(
|
||||||
status: _status,
|
status: _status,
|
||||||
|
source: _source,
|
||||||
installedOnly: _installedOnly,
|
installedOnly: _installedOnly,
|
||||||
onStatus: (v) {
|
onStatus: (v) {
|
||||||
setState(() => _status = v);
|
setState(() => _status = v);
|
||||||
_runSearch();
|
_runSearch();
|
||||||
},
|
},
|
||||||
|
onSource: (v) => setState(() => _source = v),
|
||||||
onInstalledOnly: (v) {
|
onInstalledOnly: (v) {
|
||||||
setState(() => _installedOnly = v);
|
setState(() => _installedOnly = v);
|
||||||
_runSearch();
|
_runSearch();
|
||||||
|
|
@ -190,7 +200,8 @@ class _StorePageState extends State<StorePage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final items = snap.data ?? const <StoreItem>[];
|
final raw = snap.data ?? const <StoreItem>[];
|
||||||
|
final items = _applySourceFilter(raw);
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return FaiEmptyState(
|
return FaiEmptyState(
|
||||||
icon: Icons.search_off,
|
icon: Icons.search_off,
|
||||||
|
|
@ -201,6 +212,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_category = '';
|
_category = '';
|
||||||
_status = '';
|
_status = '';
|
||||||
|
_source = '';
|
||||||
_installedOnly = false;
|
_installedOnly = false;
|
||||||
_queryCtrl.clear();
|
_queryCtrl.clear();
|
||||||
});
|
});
|
||||||
|
|
@ -219,25 +231,31 @@ class _StorePageState extends State<StorePage> {
|
||||||
final showFeatured = _queryCtrl.text.trim().isEmpty &&
|
final showFeatured = _queryCtrl.text.trim().isEmpty &&
|
||||||
_category.isEmpty &&
|
_category.isEmpty &&
|
||||||
_status.isEmpty &&
|
_status.isEmpty &&
|
||||||
|
_source.isEmpty &&
|
||||||
!_installedOnly &&
|
!_installedOnly &&
|
||||||
items.any((e) => e.featured);
|
items.any((e) => e.featured);
|
||||||
// Sparse-store nudge: when no filter is set
|
// Sparse-store nudge: when no filter is set
|
||||||
// and the store carries no federated entries,
|
// and the store carries no federated entries
|
||||||
// hint that adding an MCP / n8n endpoint
|
// yet, surface curated public MCP servers as
|
||||||
// brings dozens of capabilities at once.
|
// one-click add cards. Hides the moment any
|
||||||
// Hides the moment any federated entry shows
|
// federated entry shows up — the strip has
|
||||||
// up — the nudge has done its job.
|
// done its job.
|
||||||
final showFederationNudge =
|
final showRecommendedStrip =
|
||||||
_queryCtrl.text.trim().isEmpty &&
|
_queryCtrl.text.trim().isEmpty &&
|
||||||
_category.isEmpty &&
|
_category.isEmpty &&
|
||||||
_status.isEmpty &&
|
_status.isEmpty &&
|
||||||
|
_source.isEmpty &&
|
||||||
!_installedOnly &&
|
!_installedOnly &&
|
||||||
!items.any((e) => e.isFederated);
|
!raw.any((e) => e.isFederated);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (showFederationNudge) ...[
|
if (showRecommendedStrip) ...[
|
||||||
const _FederationNudge(),
|
_RecommendedSourcesStrip(
|
||||||
|
adding: _addingSources,
|
||||||
|
locale: _locale,
|
||||||
|
onAdd: _addRecommendedSource,
|
||||||
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
],
|
],
|
||||||
if (showFeatured) ...[
|
if (showFeatured) ...[
|
||||||
|
|
@ -277,6 +295,59 @@ class _StorePageState extends State<StorePage> {
|
||||||
final list = set.toList()..sort();
|
final list = set.toList()..sort();
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<StoreItem> _applySourceFilter(List<StoreItem> items) {
|
||||||
|
if (_source.isEmpty) return items;
|
||||||
|
return items.where((e) => _sourceForItem(e) == _source).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addRecommendedSource(_RecommendedSource s) async {
|
||||||
|
if (_addingSources.contains(s.name)) return;
|
||||||
|
setState(() => _addingSources.add(s.name));
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
try {
|
||||||
|
final list = await HubService.instance.addMcpClient(
|
||||||
|
name: s.name,
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
apiKeyEnv: '',
|
||||||
|
description: s.descriptionEn,
|
||||||
|
);
|
||||||
|
final added = list.firstWhere(
|
||||||
|
(c) => c.name == s.name,
|
||||||
|
orElse: () => list.last,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
l.storeProviderAddedToast(s.label, added.toolCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_runSearch();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _addingSources.remove(s.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a store entry to its provenance bucket: 'native' for
|
||||||
|
/// non-federated entries, 'mcp' / 'n8n' for federated tools that
|
||||||
|
/// flow through one of the bridges. Used by the source filter
|
||||||
|
/// chip and the per-card provenance pill so operators can tell
|
||||||
|
/// at a glance which entries come from where.
|
||||||
|
String _sourceForItem(StoreItem i) {
|
||||||
|
if (!i.isFederated) return 'native';
|
||||||
|
if (i.name.startsWith('mcp.')) return 'mcp';
|
||||||
|
if (i.name.startsWith('n8n.')) return 'n8n';
|
||||||
|
return 'federated';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Big search input that owns the page's primary action.
|
/// Big search input that owns the page's primary action.
|
||||||
|
|
@ -361,15 +432,19 @@ class _CategoryStrip extends StatelessWidget {
|
||||||
|
|
||||||
class _FilterRow extends StatelessWidget {
|
class _FilterRow extends StatelessWidget {
|
||||||
final String status;
|
final String status;
|
||||||
|
final String source;
|
||||||
final bool installedOnly;
|
final bool installedOnly;
|
||||||
final void Function(String) onStatus;
|
final void Function(String) onStatus;
|
||||||
|
final void Function(String) onSource;
|
||||||
final void Function(bool) onInstalledOnly;
|
final void Function(bool) onInstalledOnly;
|
||||||
final int resultCount;
|
final int resultCount;
|
||||||
|
|
||||||
const _FilterRow({
|
const _FilterRow({
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.source,
|
||||||
required this.installedOnly,
|
required this.installedOnly,
|
||||||
required this.onStatus,
|
required this.onStatus,
|
||||||
|
required this.onSource,
|
||||||
required this.onInstalledOnly,
|
required this.onInstalledOnly,
|
||||||
required this.resultCount,
|
required this.resultCount,
|
||||||
});
|
});
|
||||||
|
|
@ -378,7 +453,10 @@ class _FilterRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
return Row(
|
return Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: FaiSpace.sm,
|
||||||
|
runSpacing: FaiSpace.xs,
|
||||||
children: [
|
children: [
|
||||||
_ChoiceChip(
|
_ChoiceChip(
|
||||||
label: l.storeStatusAll,
|
label: l.storeStatusAll,
|
||||||
|
|
@ -400,14 +478,37 @@ class _FilterRow extends StatelessWidget {
|
||||||
selected: status == 'planned',
|
selected: status == 'planned',
|
||||||
onSelected: () => onStatus('planned'),
|
onSelected: () => onStatus('planned'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.md),
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 18,
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
_ChoiceChip(
|
||||||
|
label: l.storeSourceAll,
|
||||||
|
selected: source.isEmpty,
|
||||||
|
onSelected: () => onSource(''),
|
||||||
|
),
|
||||||
|
_ChoiceChip(
|
||||||
|
label: l.storeSourceNative,
|
||||||
|
selected: source == 'native',
|
||||||
|
onSelected: () => onSource('native'),
|
||||||
|
),
|
||||||
|
_ChoiceChip(
|
||||||
|
label: l.storeSourceMcp,
|
||||||
|
selected: source == 'mcp',
|
||||||
|
onSelected: () => onSource('mcp'),
|
||||||
|
),
|
||||||
|
_ChoiceChip(
|
||||||
|
label: l.storeSourceN8n,
|
||||||
|
selected: source == 'n8n',
|
||||||
|
onSelected: () => onSource('n8n'),
|
||||||
|
),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
label: Text(l.storeInstalledOnly),
|
label: Text(l.storeInstalledOnly),
|
||||||
selected: installedOnly,
|
selected: installedOnly,
|
||||||
onSelected: onInstalledOnly,
|
onSelected: onInstalledOnly,
|
||||||
showCheckmark: true,
|
showCheckmark: true,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
Text(
|
Text(
|
||||||
l.storeNResults(resultCount),
|
l.storeNResults(resultCount),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
|
@ -816,12 +917,15 @@ class _StoreCard extends StatelessWidget {
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (item.bestVersion.isNotEmpty)
|
if (item.bestVersion.isNotEmpty) ...[
|
||||||
FaiPill(
|
FaiPill(
|
||||||
label: 'v${item.bestVersion}',
|
label: 'v${item.bestVersion}',
|
||||||
tone: FaiPillTone.neutral,
|
tone: FaiPillTone.neutral,
|
||||||
monospace: true,
|
monospace: true,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
],
|
||||||
|
_ProvenancePill(item: item),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (_hasUpdate)
|
if (_hasUpdate)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
|
|
@ -1805,15 +1909,73 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sparse-store nudge: the operator's store has only the
|
/// Curated public MCP server that the operator can register
|
||||||
/// bundled native + planned modules and no federated entries.
|
/// with one click — no Node, no API key, no subprocess. Fields
|
||||||
/// The single biggest store-fullness lever is configuring an
|
/// are kept const so the strip can be a `const` widget tree.
|
||||||
/// MCP server or n8n endpoint, so the banner says exactly
|
class _RecommendedSource {
|
||||||
/// that. Tap → Settings dialog opens straight to the editor.
|
/// Stable lowercase id used as the MCP-client name. Becomes
|
||||||
/// Dismisses automatically the next render after a federated
|
/// the prefix of every synthetic store entry the server emits
|
||||||
/// entry shows up — no per-user "don't show again".
|
/// (`mcp.<name>.<tool>`).
|
||||||
class _FederationNudge extends StatelessWidget {
|
final String name;
|
||||||
const _FederationNudge();
|
/// Display label shown on the card.
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
/// HTTPS Streamable-HTTP endpoint. No auth required.
|
||||||
|
final String endpoint;
|
||||||
|
/// Bilingual description used as the MCP-client `description`
|
||||||
|
/// field — keeps the audit log in the operator's locale.
|
||||||
|
final String descriptionEn;
|
||||||
|
final String descriptionDe;
|
||||||
|
|
||||||
|
const _RecommendedSource({
|
||||||
|
required this.name,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.descriptionEn,
|
||||||
|
required this.descriptionDe,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Curated public MCP servers offered as one-click adds. Two
|
||||||
|
/// guardrails apply when picking entries: outbound HTTPS only
|
||||||
|
/// (no subprocess, no npm), and a small-enough tool surface
|
||||||
|
/// that the store stays browsable after the add (Useful AI was
|
||||||
|
/// considered but its 340+ tools would drown the index).
|
||||||
|
const _kRecommendedSources = <_RecommendedSource>[
|
||||||
|
_RecommendedSource(
|
||||||
|
name: 'deepwiki',
|
||||||
|
label: 'DeepWiki',
|
||||||
|
icon: Icons.menu_book_outlined,
|
||||||
|
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||||||
|
descriptionEn: 'GitHub repo documentation search (AI-powered).',
|
||||||
|
descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).',
|
||||||
|
),
|
||||||
|
_RecommendedSource(
|
||||||
|
name: 'semgrep',
|
||||||
|
label: 'Semgrep',
|
||||||
|
icon: Icons.security,
|
||||||
|
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||||||
|
descriptionEn: 'Security scanning for code vulnerabilities.',
|
||||||
|
descriptionDe: 'Security-Scanning für Code-Schwachstellen.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Recommended-sources strip — replaces the older Settings nudge.
|
||||||
|
/// Renders one card per [_kRecommendedSources] entry with a
|
||||||
|
/// single `[+ Add]` button so the operator can fill the store
|
||||||
|
/// without leaving the Store page. Hides as soon as any
|
||||||
|
/// federated entry exists.
|
||||||
|
class _RecommendedSourcesStrip extends StatelessWidget {
|
||||||
|
final Set<String> adding;
|
||||||
|
final String locale;
|
||||||
|
final void Function(_RecommendedSource) onAdd;
|
||||||
|
|
||||||
|
const _RecommendedSourcesStrip({
|
||||||
|
required this.adding,
|
||||||
|
required this.locale,
|
||||||
|
required this.onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -1827,43 +1989,162 @@ class _FederationNudge extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
Icons.hub_outlined,
|
children: [
|
||||||
size: 22,
|
Icon(
|
||||||
color: theme.colorScheme.primary,
|
Icons.hub_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Text(
|
||||||
|
l.storeRecommendedTitle,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.md),
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Text(
|
||||||
child: Column(
|
l.storeRecommendedBody,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
Text(
|
|
||||||
l.storeFederationNudgeTitle,
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
l.storeFederationNudgeBody,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
FilledButton.tonalIcon(
|
Wrap(
|
||||||
onPressed: () => FaiSettingsDialog.show(context),
|
spacing: FaiSpace.md,
|
||||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
runSpacing: FaiSpace.sm,
|
||||||
label: Text(l.storeFederationNudgeButton),
|
children: [
|
||||||
|
for (final s in _kRecommendedSources)
|
||||||
|
_RecommendedSourceCard(
|
||||||
|
source: s,
|
||||||
|
locale: locale,
|
||||||
|
busy: adding.contains(s.name),
|
||||||
|
onAdd: () => onAdd(s),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecommendedSourceCard extends StatelessWidget {
|
||||||
|
final _RecommendedSource source;
|
||||||
|
final String locale;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
|
const _RecommendedSourceCard({
|
||||||
|
required this.source,
|
||||||
|
required this.locale,
|
||||||
|
required this.busy,
|
||||||
|
required this.onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn;
|
||||||
|
return SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(source.icon, size: 18, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
source.label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
desc,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Tooltip(
|
||||||
|
message: busy
|
||||||
|
? l.storeRecommendedAdding
|
||||||
|
: l.storeRecommendedAddTooltip,
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
onPressed: busy ? null : onAdd,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add, size: 16),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small color-coded chip that names the entry's source: native
|
||||||
|
/// (muted), MCP (warning tone), or n8n (accent). The on-card
|
||||||
|
/// position lets the operator triage at a glance who emitted the
|
||||||
|
/// capability — the same role the "verified" badge plays in
|
||||||
|
/// commercial app stores.
|
||||||
|
class _ProvenancePill extends StatelessWidget {
|
||||||
|
final StoreItem item;
|
||||||
|
|
||||||
|
const _ProvenancePill({required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final source = _sourceForItem(item);
|
||||||
|
switch (source) {
|
||||||
|
case 'mcp':
|
||||||
|
return FaiPill(
|
||||||
|
label: l.storeProvenanceMcp(item.provider),
|
||||||
|
tone: FaiPillTone.warning,
|
||||||
|
monospace: true,
|
||||||
|
icon: Icons.hub_outlined,
|
||||||
|
);
|
||||||
|
case 'n8n':
|
||||||
|
return FaiPill(
|
||||||
|
label: l.storeProvenanceN8n(item.provider),
|
||||||
|
tone: FaiPillTone.accent,
|
||||||
|
monospace: true,
|
||||||
|
icon: Icons.account_tree_outlined,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return FaiPill(
|
||||||
|
label: l.storeProvenanceNative,
|
||||||
|
tone: FaiPillTone.neutral,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.28.0
|
version: 0.29.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue