feat(studio): Doctor + Modules localized + sparse-store federation nudge (v0.25.0)

Two threads.

Translation expansion: every visible string on the Doctor
page (section headers, status text, button labels, daemon-
control card, daemon-files panel, update banner) and the
Modules page (recent-activity strip, capabilities label,
uninstall dialog + toast) flips between DE and EN with the
sidebar toggle. Adds ~50 ARB keys split between
`app_en.arb` / `app_de.arb`. Pluralised + parametrised
strings (`{n} events verified`, `Running on {name}: {endpoint}`)
use the standard ICU placeholder syntax so future locales
slot in without code changes.

Sparse-store federation nudge: the Store page renders an
inline banner above the grid when the operator has zero
federated entries — single biggest store-fullness lever is
configuring an MCP server / n8n endpoint, so the banner
says exactly that and the button opens Settings straight
to the editor. Banner dismisses automatically the next
render after a federated entry appears.

Coverage stand for translation: Doctor + Modules + sidebar
+ page titles + nav + Cmd+K labels are bilingual. Settings
dialog, Approvals cards, Audit drilldown, MCP/n8n editors,
Store search hint + filter chips remain English-literal.
The infrastructure (ARB plumbing, generator hookup,
locale-notifier) means each follow-up surface is a
one-line ARB edit + one call-site swap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 01:39:42 +02:00
parent ee83eb851a
commit 349619d68e
11 changed files with 1091 additions and 80 deletions

View file

@ -61,6 +61,9 @@
"@storeNResults": {
"placeholders": { "n": { "type": "int" } }
},
"storeFederationNudgeTitle": "Mehr Capabilities?",
"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.",
"storeFederationNudgeButton": "Einstellungen öffnen",
"auditTitle": "Protokoll",
"auditNoEvents": "Noch keine Ereignisse",
@ -68,7 +71,32 @@
"modulesTitle": "Module",
"modulesNoneTitle": "Noch keine Module",
"modulesNoneHint": "`fai install <capability-name>` ausführen oder ~/.fai/modules/ prüfen.",
"modulesReloadTooltip": "Aktualisieren",
"modulesRecentActivity": "LETZTE AKTIVITÄT",
"modulesRecentActivityHint": "letzte {n} install/uninstall-Ereignisse aus dem Audit-Log",
"@modulesRecentActivityHint": { "placeholders": { "n": { "type": "int" } } },
"modulesActivityInstalled": "installiert",
"modulesActivityUninstalled": "deinstalliert",
"modulesCapabilitiesLabel": "Capabilities",
"modulesUninstallTitle": "Modul deinstallieren?",
"modulesUninstallBody": "Entfernt {name} v{version} von diesem Hub. Flows, die es verwenden, schlagen beim nächsten Lauf fehl. Ein `module.uninstalled`-Audit-Event wird festgehalten.",
"@modulesUninstallBody": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"modulesUninstalledToast": "{name} v{version} deinstalliert.",
"@modulesUninstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"modulesUninstallFailed": "Deinstallation fehlgeschlagen: {error}",
"@modulesUninstallFailed": { "placeholders": { "error": { "type": "String" } } },
"modulesUninstalling": "Wird deinstalliert…",
"approvalsTitle": "Freigaben",
"approvalsTabPending": "Offen",
@ -77,6 +105,73 @@
"approvalsInboxHint": "Alles erledigt. Neue `system.approval@^0`-Schritte landen automatisch hier.",
"doctorTitle": "Diagnose",
"doctorRecheckTooltip": "Erneut prüfen",
"doctorEventLogSection": "Audit-Log",
"doctorModulesApprovalsSection": "Module & Freigaben",
"doctorHostServicesSection": "Host-Dienste",
"doctorDaemonFilesSection": "Daemon-Dateien",
"doctorDaemonControlSection": "Daemon-Steuerung",
"doctorChainIntact": "Hash-Chain intakt",
"doctorChainIntactDetail": "{n} Ereignisse · prev_event_sha256 lückenlos verifiziert",
"@doctorChainIntactDetail": { "placeholders": { "n": { "type": "int" } } },
"doctorChainTampered": "Manipulation erkannt",
"doctorChainTamperedDetail": "{verified} von {total} verifiziert vor Mismatch bei {id}",
"@doctorChainTamperedDetail": {
"placeholders": {
"verified": { "type": "int" },
"total": { "type": "int" },
"id": { "type": "String" }
}
},
"doctorChainPillOk": "WORM-1",
"doctorChainPillTamper": "MANIPULIERT",
"doctorVerifyNow": "Jetzt prüfen",
"doctorRestart": "Neustart",
"doctorStart": "Starten",
"doctorStop": "Stoppen",
"doctorStatusAction": "Status",
"doctorRunningOn": "Läuft auf {name}: {endpoint}",
"@doctorRunningOn": {
"placeholders": {
"name": { "type": "String" },
"endpoint": { "type": "String" }
}
},
"doctorStoppedOn": "{name}-Daemon gestoppt",
"@doctorStoppedOn": { "placeholders": { "name": { "type": "String" } } },
"doctorStatusUnknown": "Daemon-Status: …",
"doctorPillRunning": "läuft",
"doctorPillStopped": "gestoppt",
"doctorDaemonControlHint": "Studio ruft das Platform-Binary auf — entspricht `fai daemon …`, sodass CLI und UI im Gleichschritt bleiben.",
"doctorDaemonControlWorking": "Wird ausgeführt…",
"doctorPathLog": "Log",
"doctorPathConfig": "Konfig",
"doctorPathDb": "Audit-DB",
"doctorPathModules": "Module-Verzeichnis",
"doctorPathFlows": "Flow-Verzeichnis",
"doctorPathPid": "PID-Datei",
"doctorPathsEmpty": "Daemon hat keine Datei-Pfade gemeldet. Hub aktualisieren via `fai daemon restart`.",
"doctorOpenError": "Konnte nicht öffnen: {detail}",
"@doctorOpenError": { "placeholders": { "detail": { "type": "String" } } },
"doctorUpdateAvailable": "Update verfügbar — {version}",
"@doctorUpdateAvailable": { "placeholders": { "version": { "type": "String" } } },
"doctorUpdateBody": "Kanal {channel} bietet jetzt {version}. Per Button anwenden — Studio ruft `fai update apply --channel {channel}` auf.",
"@doctorUpdateBody": {
"placeholders": {
"channel": { "type": "String" },
"version": { "type": "String" }
}
},
"doctorUpdateUnreachable": "Release-Host nicht erreichbar",
"doctorUpdateUnreachableBody": "Release-Feed für {channel} konnte nicht erreicht werden.",
"@doctorUpdateUnreachableBody": { "placeholders": { "channel": { "type": "String" } } },
"doctorReleaseNotes": "Notes: {url}",
"@doctorReleaseNotes": { "placeholders": { "url": { "type": "String" } } },
"doctorPillNew": "neu",
"doctorPillOffline": "offline",
"doctorApplyUpdate": "Update anwenden",
"doctorApplying": "Wird angewendet…",
"doctorApplyDone": "Update angewendet. Studio neu starten, um die neue Daemon-Version zu sehen.",
"mcpHeader": "MCP-CLIENTS",
"mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Capabilities ein. Einmal konfigurieren, im Store sichtbar.",

View file

@ -62,6 +62,9 @@
"@storeNResults": {
"placeholders": { "n": { "type": "int" } }
},
"storeFederationNudgeTitle": "Want more capabilities?",
"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.",
"storeFederationNudgeButton": "Open Settings",
"auditTitle": "Audit",
"auditNoEvents": "No events yet",
@ -69,7 +72,32 @@
"modulesTitle": "Modules",
"modulesNoneTitle": "No modules yet",
"modulesNoneHint": "Run `fai install <capability-name>` or check ~/.fai/modules/.",
"modulesReloadTooltip": "Reload",
"modulesRecentActivity": "RECENT ACTIVITY",
"modulesRecentActivityHint": "last {n} install/uninstall events from the audit log",
"@modulesRecentActivityHint": { "placeholders": { "n": { "type": "int" } } },
"modulesActivityInstalled": "installed",
"modulesActivityUninstalled": "uninstalled",
"modulesCapabilitiesLabel": "Capabilities",
"modulesUninstallTitle": "Uninstall module?",
"modulesUninstallBody": "Removes {name} v{version} from this hub. Flows that reference it will fail at the next run. A `module.uninstalled` audit event is recorded.",
"@modulesUninstallBody": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"modulesUninstalledToast": "Uninstalled {name} v{version}.",
"@modulesUninstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"modulesUninstallFailed": "Uninstall failed: {error}",
"@modulesUninstallFailed": { "placeholders": { "error": { "type": "String" } } },
"modulesUninstalling": "Uninstalling…",
"approvalsTitle": "Approvals",
"approvalsTabPending": "Pending",
@ -78,6 +106,73 @@
"approvalsInboxHint": "All caught up. New `system.approval@^0` steps land here automatically.",
"doctorTitle": "Doctor",
"doctorRecheckTooltip": "Re-check",
"doctorEventLogSection": "Event log",
"doctorModulesApprovalsSection": "Modules & approvals",
"doctorHostServicesSection": "Host services",
"doctorDaemonFilesSection": "Daemon files",
"doctorDaemonControlSection": "Daemon control",
"doctorChainIntact": "Hash chain intact",
"doctorChainIntactDetail": "{n} events · prev_event_sha256 verified end-to-end",
"@doctorChainIntactDetail": { "placeholders": { "n": { "type": "int" } } },
"doctorChainTampered": "Tampering detected",
"doctorChainTamperedDetail": "{verified} of {total} verified before mismatch at {id}",
"@doctorChainTamperedDetail": {
"placeholders": {
"verified": { "type": "int" },
"total": { "type": "int" },
"id": { "type": "String" }
}
},
"doctorChainPillOk": "WORM-1",
"doctorChainPillTamper": "TAMPER",
"doctorVerifyNow": "Verify now",
"doctorRestart": "Restart",
"doctorStart": "Start",
"doctorStop": "Stop",
"doctorStatusAction": "Status",
"doctorRunningOn": "Running on {name}: {endpoint}",
"@doctorRunningOn": {
"placeholders": {
"name": { "type": "String" },
"endpoint": { "type": "String" }
}
},
"doctorStoppedOn": "{name} daemon stopped",
"@doctorStoppedOn": { "placeholders": { "name": { "type": "String" } } },
"doctorStatusUnknown": "Daemon status: …",
"doctorPillRunning": "running",
"doctorPillStopped": "stopped",
"doctorDaemonControlHint": "Studio shells out to the platform binary — mirrors `fai daemon …` exactly so the CLI and UI stay in lockstep.",
"doctorDaemonControlWorking": "Working…",
"doctorPathLog": "Log",
"doctorPathConfig": "Config",
"doctorPathDb": "Audit DB",
"doctorPathModules": "Modules dir",
"doctorPathFlows": "Flows dir",
"doctorPathPid": "PID file",
"doctorPathsEmpty": "Daemon did not report file paths. Update the running hub via `fai daemon restart`.",
"doctorOpenError": "Could not open: {detail}",
"@doctorOpenError": { "placeholders": { "detail": { "type": "String" } } },
"doctorUpdateAvailable": "Update available — {version}",
"@doctorUpdateAvailable": { "placeholders": { "version": { "type": "String" } } },
"doctorUpdateBody": "Channel {channel} now offers {version}. Apply via the button — Studio shells out to `fai update apply --channel {channel}`.",
"@doctorUpdateBody": {
"placeholders": {
"channel": { "type": "String" },
"version": { "type": "String" }
}
},
"doctorUpdateUnreachable": "Release host unreachable",
"doctorUpdateUnreachableBody": "Could not contact the release feed for {channel}.",
"@doctorUpdateUnreachableBody": { "placeholders": { "channel": { "type": "String" } } },
"doctorReleaseNotes": "notes: {url}",
"@doctorReleaseNotes": { "placeholders": { "url": { "type": "String" } } },
"doctorPillNew": "new",
"doctorPillOffline": "offline",
"doctorApplyUpdate": "Apply update",
"doctorApplying": "Applying…",
"doctorApplyDone": "Update applied. Restart Studio to see the new daemon version.",
"mcpHeader": "MCP CLIENTS",
"mcpHint": "External MCP servers federate their tools as `mcp.<server>.<tool>` capabilities. Configure once, see them in the Store.",

View file

@ -404,6 +404,24 @@ abstract class AppLocalizations {
/// **'{n} result{n, plural, =1{} other{s}}'**
String storeNResults(int n);
/// No description provided for @storeFederationNudgeTitle.
///
/// In en, this message translates to:
/// **'Want more capabilities?'**
String get storeFederationNudgeTitle;
/// No description provided for @storeFederationNudgeBody.
///
/// 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.'**
String get storeFederationNudgeBody;
/// No description provided for @storeFederationNudgeButton.
///
/// In en, this message translates to:
/// **'Open Settings'**
String get storeFederationNudgeButton;
/// No description provided for @auditTitle.
///
/// In en, this message translates to:
@ -434,12 +452,78 @@ abstract class AppLocalizations {
/// **'No modules yet'**
String get modulesNoneTitle;
/// No description provided for @modulesNoneHint.
///
/// In en, this message translates to:
/// **'Run `fai install <capability-name>` or check ~/.fai/modules/.'**
String get modulesNoneHint;
/// No description provided for @modulesReloadTooltip.
///
/// In en, this message translates to:
/// **'Reload'**
String get modulesReloadTooltip;
/// No description provided for @modulesRecentActivity.
///
/// In en, this message translates to:
/// **'RECENT ACTIVITY'**
String get modulesRecentActivity;
/// No description provided for @modulesRecentActivityHint.
///
/// In en, this message translates to:
/// **'last {n} install/uninstall events from the audit log'**
String modulesRecentActivityHint(int n);
/// No description provided for @modulesActivityInstalled.
///
/// In en, this message translates to:
/// **'installed'**
String get modulesActivityInstalled;
/// No description provided for @modulesActivityUninstalled.
///
/// In en, this message translates to:
/// **'uninstalled'**
String get modulesActivityUninstalled;
/// No description provided for @modulesCapabilitiesLabel.
///
/// In en, this message translates to:
/// **'Capabilities'**
String get modulesCapabilitiesLabel;
/// No description provided for @modulesUninstallTitle.
///
/// In en, this message translates to:
/// **'Uninstall module?'**
String get modulesUninstallTitle;
/// No description provided for @modulesUninstallBody.
///
/// In en, this message translates to:
/// **'Removes {name} v{version} from this hub. Flows that reference it will fail at the next run. A `module.uninstalled` audit event is recorded.'**
String modulesUninstallBody(String name, String version);
/// No description provided for @modulesUninstalledToast.
///
/// In en, this message translates to:
/// **'Uninstalled {name} v{version}.'**
String modulesUninstalledToast(String name, String version);
/// No description provided for @modulesUninstallFailed.
///
/// In en, this message translates to:
/// **'Uninstall failed: {error}'**
String modulesUninstallFailed(String error);
/// No description provided for @modulesUninstalling.
///
/// In en, this message translates to:
/// **'Uninstalling…'**
String get modulesUninstalling;
/// No description provided for @approvalsTitle.
///
/// In en, this message translates to:
@ -476,6 +560,258 @@ abstract class AppLocalizations {
/// **'Doctor'**
String get doctorTitle;
/// No description provided for @doctorRecheckTooltip.
///
/// In en, this message translates to:
/// **'Re-check'**
String get doctorRecheckTooltip;
/// No description provided for @doctorEventLogSection.
///
/// In en, this message translates to:
/// **'Event log'**
String get doctorEventLogSection;
/// No description provided for @doctorModulesApprovalsSection.
///
/// In en, this message translates to:
/// **'Modules & approvals'**
String get doctorModulesApprovalsSection;
/// No description provided for @doctorHostServicesSection.
///
/// In en, this message translates to:
/// **'Host services'**
String get doctorHostServicesSection;
/// No description provided for @doctorDaemonFilesSection.
///
/// In en, this message translates to:
/// **'Daemon files'**
String get doctorDaemonFilesSection;
/// No description provided for @doctorDaemonControlSection.
///
/// In en, this message translates to:
/// **'Daemon control'**
String get doctorDaemonControlSection;
/// No description provided for @doctorChainIntact.
///
/// In en, this message translates to:
/// **'Hash chain intact'**
String get doctorChainIntact;
/// No description provided for @doctorChainIntactDetail.
///
/// In en, this message translates to:
/// **'{n} events · prev_event_sha256 verified end-to-end'**
String doctorChainIntactDetail(int n);
/// No description provided for @doctorChainTampered.
///
/// In en, this message translates to:
/// **'Tampering detected'**
String get doctorChainTampered;
/// No description provided for @doctorChainTamperedDetail.
///
/// In en, this message translates to:
/// **'{verified} of {total} verified before mismatch at {id}'**
String doctorChainTamperedDetail(int verified, int total, String id);
/// No description provided for @doctorChainPillOk.
///
/// In en, this message translates to:
/// **'WORM-1'**
String get doctorChainPillOk;
/// No description provided for @doctorChainPillTamper.
///
/// In en, this message translates to:
/// **'TAMPER'**
String get doctorChainPillTamper;
/// No description provided for @doctorVerifyNow.
///
/// In en, this message translates to:
/// **'Verify now'**
String get doctorVerifyNow;
/// No description provided for @doctorRestart.
///
/// In en, this message translates to:
/// **'Restart'**
String get doctorRestart;
/// No description provided for @doctorStart.
///
/// In en, this message translates to:
/// **'Start'**
String get doctorStart;
/// No description provided for @doctorStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get doctorStop;
/// No description provided for @doctorStatusAction.
///
/// In en, this message translates to:
/// **'Status'**
String get doctorStatusAction;
/// No description provided for @doctorRunningOn.
///
/// In en, this message translates to:
/// **'Running on {name}: {endpoint}'**
String doctorRunningOn(String name, String endpoint);
/// No description provided for @doctorStoppedOn.
///
/// In en, this message translates to:
/// **'{name} daemon stopped'**
String doctorStoppedOn(String name);
/// No description provided for @doctorStatusUnknown.
///
/// In en, this message translates to:
/// **'Daemon status: …'**
String get doctorStatusUnknown;
/// No description provided for @doctorPillRunning.
///
/// In en, this message translates to:
/// **'running'**
String get doctorPillRunning;
/// No description provided for @doctorPillStopped.
///
/// In en, this message translates to:
/// **'stopped'**
String get doctorPillStopped;
/// No description provided for @doctorDaemonControlHint.
///
/// In en, this message translates to:
/// **'Studio shells out to the platform binary — mirrors `fai daemon …` exactly so the CLI and UI stay in lockstep.'**
String get doctorDaemonControlHint;
/// No description provided for @doctorDaemonControlWorking.
///
/// In en, this message translates to:
/// **'Working…'**
String get doctorDaemonControlWorking;
/// No description provided for @doctorPathLog.
///
/// In en, this message translates to:
/// **'Log'**
String get doctorPathLog;
/// No description provided for @doctorPathConfig.
///
/// In en, this message translates to:
/// **'Config'**
String get doctorPathConfig;
/// No description provided for @doctorPathDb.
///
/// In en, this message translates to:
/// **'Audit DB'**
String get doctorPathDb;
/// No description provided for @doctorPathModules.
///
/// In en, this message translates to:
/// **'Modules dir'**
String get doctorPathModules;
/// No description provided for @doctorPathFlows.
///
/// In en, this message translates to:
/// **'Flows dir'**
String get doctorPathFlows;
/// No description provided for @doctorPathPid.
///
/// In en, this message translates to:
/// **'PID file'**
String get doctorPathPid;
/// No description provided for @doctorPathsEmpty.
///
/// In en, this message translates to:
/// **'Daemon did not report file paths. Update the running hub via `fai daemon restart`.'**
String get doctorPathsEmpty;
/// No description provided for @doctorOpenError.
///
/// In en, this message translates to:
/// **'Could not open: {detail}'**
String doctorOpenError(String detail);
/// No description provided for @doctorUpdateAvailable.
///
/// In en, this message translates to:
/// **'Update available — {version}'**
String doctorUpdateAvailable(String version);
/// No description provided for @doctorUpdateBody.
///
/// In en, this message translates to:
/// **'Channel {channel} now offers {version}. Apply via the button — Studio shells out to `fai update apply --channel {channel}`.'**
String doctorUpdateBody(String channel, String version);
/// No description provided for @doctorUpdateUnreachable.
///
/// In en, this message translates to:
/// **'Release host unreachable'**
String get doctorUpdateUnreachable;
/// No description provided for @doctorUpdateUnreachableBody.
///
/// In en, this message translates to:
/// **'Could not contact the release feed for {channel}.'**
String doctorUpdateUnreachableBody(String channel);
/// No description provided for @doctorReleaseNotes.
///
/// In en, this message translates to:
/// **'notes: {url}'**
String doctorReleaseNotes(String url);
/// No description provided for @doctorPillNew.
///
/// In en, this message translates to:
/// **'new'**
String get doctorPillNew;
/// No description provided for @doctorPillOffline.
///
/// In en, this message translates to:
/// **'offline'**
String get doctorPillOffline;
/// No description provided for @doctorApplyUpdate.
///
/// In en, this message translates to:
/// **'Apply update'**
String get doctorApplyUpdate;
/// No description provided for @doctorApplying.
///
/// In en, this message translates to:
/// **'Applying…'**
String get doctorApplying;
/// No description provided for @doctorApplyDone.
///
/// In en, this message translates to:
/// **'Update applied. Restart Studio to see the new daemon version.'**
String get doctorApplyDone;
/// No description provided for @mcpHeader.
///
/// In en, this message translates to:

View file

@ -166,6 +166,16 @@ class AppLocalizationsDe extends AppLocalizations {
return '$n Treffer';
}
@override
String get storeFederationNudgeTitle => 'Mehr Capabilities?';
@override
String get 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.';
@override
String get storeFederationNudgeButton => 'Einstellungen öffnen';
@override
String get auditTitle => 'Protokoll';
@ -182,9 +192,51 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get modulesNoneTitle => 'Noch keine Module';
@override
String get modulesNoneHint =>
'`fai install <capability-name>` ausführen oder ~/.fai/modules/ prüfen.';
@override
String get modulesReloadTooltip => 'Aktualisieren';
@override
String get modulesRecentActivity => 'LETZTE AKTIVITÄT';
@override
String modulesRecentActivityHint(int n) {
return 'letzte $n install/uninstall-Ereignisse aus dem Audit-Log';
}
@override
String get modulesActivityInstalled => 'installiert';
@override
String get modulesActivityUninstalled => 'deinstalliert';
@override
String get modulesCapabilitiesLabel => 'Capabilities';
@override
String get modulesUninstallTitle => 'Modul deinstallieren?';
@override
String modulesUninstallBody(String name, String version) {
return 'Entfernt $name v$version von diesem Hub. Flows, die es verwenden, schlagen beim nächsten Lauf fehl. Ein `module.uninstalled`-Audit-Event wird festgehalten.';
}
@override
String modulesUninstalledToast(String name, String version) {
return '$name v$version deinstalliert.';
}
@override
String modulesUninstallFailed(String error) {
return 'Deinstallation fehlgeschlagen: $error';
}
@override
String get modulesUninstalling => 'Wird deinstalliert…';
@override
String get approvalsTitle => 'Freigaben';
@ -204,6 +256,153 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get doctorTitle => 'Diagnose';
@override
String get doctorRecheckTooltip => 'Erneut prüfen';
@override
String get doctorEventLogSection => 'Audit-Log';
@override
String get doctorModulesApprovalsSection => 'Module & Freigaben';
@override
String get doctorHostServicesSection => 'Host-Dienste';
@override
String get doctorDaemonFilesSection => 'Daemon-Dateien';
@override
String get doctorDaemonControlSection => 'Daemon-Steuerung';
@override
String get doctorChainIntact => 'Hash-Chain intakt';
@override
String doctorChainIntactDetail(int n) {
return '$n Ereignisse · prev_event_sha256 lückenlos verifiziert';
}
@override
String get doctorChainTampered => 'Manipulation erkannt';
@override
String doctorChainTamperedDetail(int verified, int total, String id) {
return '$verified von $total verifiziert vor Mismatch bei $id';
}
@override
String get doctorChainPillOk => 'WORM-1';
@override
String get doctorChainPillTamper => 'MANIPULIERT';
@override
String get doctorVerifyNow => 'Jetzt prüfen';
@override
String get doctorRestart => 'Neustart';
@override
String get doctorStart => 'Starten';
@override
String get doctorStop => 'Stoppen';
@override
String get doctorStatusAction => 'Status';
@override
String doctorRunningOn(String name, String endpoint) {
return 'Läuft auf $name: $endpoint';
}
@override
String doctorStoppedOn(String name) {
return '$name-Daemon gestoppt';
}
@override
String get doctorStatusUnknown => 'Daemon-Status: …';
@override
String get doctorPillRunning => 'läuft';
@override
String get doctorPillStopped => 'gestoppt';
@override
String get doctorDaemonControlHint =>
'Studio ruft das Platform-Binary auf — entspricht `fai daemon …`, sodass CLI und UI im Gleichschritt bleiben.';
@override
String get doctorDaemonControlWorking => 'Wird ausgeführt…';
@override
String get doctorPathLog => 'Log';
@override
String get doctorPathConfig => 'Konfig';
@override
String get doctorPathDb => 'Audit-DB';
@override
String get doctorPathModules => 'Module-Verzeichnis';
@override
String get doctorPathFlows => 'Flow-Verzeichnis';
@override
String get doctorPathPid => 'PID-Datei';
@override
String get doctorPathsEmpty =>
'Daemon hat keine Datei-Pfade gemeldet. Hub aktualisieren via `fai daemon restart`.';
@override
String doctorOpenError(String detail) {
return 'Konnte nicht öffnen: $detail';
}
@override
String doctorUpdateAvailable(String version) {
return 'Update verfügbar — $version';
}
@override
String doctorUpdateBody(String channel, String version) {
return 'Kanal $channel bietet jetzt $version. Per Button anwenden — Studio ruft `fai update apply --channel $channel` auf.';
}
@override
String get doctorUpdateUnreachable => 'Release-Host nicht erreichbar';
@override
String doctorUpdateUnreachableBody(String channel) {
return 'Release-Feed für $channel konnte nicht erreicht werden.';
}
@override
String doctorReleaseNotes(String url) {
return 'Notes: $url';
}
@override
String get doctorPillNew => 'neu';
@override
String get doctorPillOffline => 'offline';
@override
String get doctorApplyUpdate => 'Update anwenden';
@override
String get doctorApplying => 'Wird angewendet…';
@override
String get doctorApplyDone =>
'Update angewendet. Studio neu starten, um die neue Daemon-Version zu sehen.';
@override
String get mcpHeader => 'MCP-CLIENTS';

View file

@ -172,6 +172,16 @@ class AppLocalizationsEn extends AppLocalizations {
return '$n result$_temp0';
}
@override
String get storeFederationNudgeTitle => 'Want more capabilities?';
@override
String get 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.';
@override
String get storeFederationNudgeButton => 'Open Settings';
@override
String get auditTitle => 'Audit';
@ -188,9 +198,51 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get modulesNoneTitle => 'No modules yet';
@override
String get modulesNoneHint =>
'Run `fai install <capability-name>` or check ~/.fai/modules/.';
@override
String get modulesReloadTooltip => 'Reload';
@override
String get modulesRecentActivity => 'RECENT ACTIVITY';
@override
String modulesRecentActivityHint(int n) {
return 'last $n install/uninstall events from the audit log';
}
@override
String get modulesActivityInstalled => 'installed';
@override
String get modulesActivityUninstalled => 'uninstalled';
@override
String get modulesCapabilitiesLabel => 'Capabilities';
@override
String get modulesUninstallTitle => 'Uninstall module?';
@override
String modulesUninstallBody(String name, String version) {
return 'Removes $name v$version from this hub. Flows that reference it will fail at the next run. A `module.uninstalled` audit event is recorded.';
}
@override
String modulesUninstalledToast(String name, String version) {
return 'Uninstalled $name v$version.';
}
@override
String modulesUninstallFailed(String error) {
return 'Uninstall failed: $error';
}
@override
String get modulesUninstalling => 'Uninstalling…';
@override
String get approvalsTitle => 'Approvals';
@ -210,6 +262,153 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get doctorTitle => 'Doctor';
@override
String get doctorRecheckTooltip => 'Re-check';
@override
String get doctorEventLogSection => 'Event log';
@override
String get doctorModulesApprovalsSection => 'Modules & approvals';
@override
String get doctorHostServicesSection => 'Host services';
@override
String get doctorDaemonFilesSection => 'Daemon files';
@override
String get doctorDaemonControlSection => 'Daemon control';
@override
String get doctorChainIntact => 'Hash chain intact';
@override
String doctorChainIntactDetail(int n) {
return '$n events · prev_event_sha256 verified end-to-end';
}
@override
String get doctorChainTampered => 'Tampering detected';
@override
String doctorChainTamperedDetail(int verified, int total, String id) {
return '$verified of $total verified before mismatch at $id';
}
@override
String get doctorChainPillOk => 'WORM-1';
@override
String get doctorChainPillTamper => 'TAMPER';
@override
String get doctorVerifyNow => 'Verify now';
@override
String get doctorRestart => 'Restart';
@override
String get doctorStart => 'Start';
@override
String get doctorStop => 'Stop';
@override
String get doctorStatusAction => 'Status';
@override
String doctorRunningOn(String name, String endpoint) {
return 'Running on $name: $endpoint';
}
@override
String doctorStoppedOn(String name) {
return '$name daemon stopped';
}
@override
String get doctorStatusUnknown => 'Daemon status: …';
@override
String get doctorPillRunning => 'running';
@override
String get doctorPillStopped => 'stopped';
@override
String get doctorDaemonControlHint =>
'Studio shells out to the platform binary — mirrors `fai daemon …` exactly so the CLI and UI stay in lockstep.';
@override
String get doctorDaemonControlWorking => 'Working…';
@override
String get doctorPathLog => 'Log';
@override
String get doctorPathConfig => 'Config';
@override
String get doctorPathDb => 'Audit DB';
@override
String get doctorPathModules => 'Modules dir';
@override
String get doctorPathFlows => 'Flows dir';
@override
String get doctorPathPid => 'PID file';
@override
String get doctorPathsEmpty =>
'Daemon did not report file paths. Update the running hub via `fai daemon restart`.';
@override
String doctorOpenError(String detail) {
return 'Could not open: $detail';
}
@override
String doctorUpdateAvailable(String version) {
return 'Update available — $version';
}
@override
String doctorUpdateBody(String channel, String version) {
return 'Channel $channel now offers $version. Apply via the button — Studio shells out to `fai update apply --channel $channel`.';
}
@override
String get doctorUpdateUnreachable => 'Release host unreachable';
@override
String doctorUpdateUnreachableBody(String channel) {
return 'Could not contact the release feed for $channel.';
}
@override
String doctorReleaseNotes(String url) {
return 'notes: $url';
}
@override
String get doctorPillNew => 'new';
@override
String get doctorPillOffline => 'offline';
@override
String get doctorApplyUpdate => 'Apply update';
@override
String get doctorApplying => 'Applying…';
@override
String get doctorApplyDone =>
'Update applied. Restart Studio to see the new daemon version.';
@override
String get mcpHeader => 'MCP CLIENTS';

View file

@ -26,7 +26,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.24.0';
const String kStudioVersion = '0.25.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -36,7 +36,7 @@ class _DoctorPageState extends State<DoctorPage> {
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Re-check',
tooltip: AppLocalizations.of(context)!.doctorRecheckTooltip,
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
@ -49,14 +49,15 @@ class _DoctorPageState extends State<DoctorPage> {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
final l = AppLocalizations.of(context)!;
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: Theme.of(context).colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
child: Text(l.buttonRetry),
),
);
}
@ -72,7 +73,7 @@ class _DoctorPageState extends State<DoctorPage> {
_SummaryStrip(snapshot: s),
const SizedBox(height: FaiSpace.xl),
_Section(
title: 'Event log',
title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(
snapshot: s,
onRefresh: _refresh,
@ -80,22 +81,22 @@ class _DoctorPageState extends State<DoctorPage> {
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Modules & approvals',
title: AppLocalizations.of(context)!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Host services',
title: AppLocalizations.of(context)!.doctorHostServicesSection,
child: _ServicesPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Daemon files',
title: AppLocalizations.of(context)!.doctorDaemonFilesSection,
child: _DaemonPathsPanel(paths: s.paths),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Daemon control',
title: AppLocalizations.of(context)!.doctorDaemonControlSection,
child: _DaemonActionsCard(),
),
],
@ -266,6 +267,7 @@ class _EventLogPanel extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!;
return FaiCard(
child: Row(
children: [
@ -280,9 +282,7 @@ class _EventLogPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthy
? 'Hash chain intact'
: 'Tampering detected',
healthy ? l.doctorChainIntact : l.doctorChainTampered,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
@ -290,8 +290,12 @@ class _EventLogPanel extends StatelessWidget {
const SizedBox(height: 2),
Text(
healthy
? '${snapshot.eventChainTotal} events · prev_event_sha256 verified end-to-end'
: '${snapshot.eventChainVerified} of ${snapshot.eventChainTotal} verified before mismatch at ${snapshot.eventChainTamperedAt}',
? l.doctorChainIntactDetail(snapshot.eventChainTotal)
: l.doctorChainTamperedDetail(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
snapshot.eventChainTamperedAt ?? '',
),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -302,11 +306,11 @@ class _EventLogPanel extends StatelessWidget {
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined, size: 16),
label: const Text('Verify now'),
label: Text(l.doctorVerifyNow),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: healthy ? 'WORM-1' : 'TAMPER',
label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper,
tone: healthy ? FaiPillTone.success : FaiPillTone.danger,
),
],
@ -326,20 +330,20 @@ class _DaemonPathsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final entries = <(String, String, IconData)>[
('Log', paths.logPath, Icons.description_outlined),
('Config', paths.configPath, Icons.settings_outlined),
('Audit DB', paths.dbPath, Icons.storage_outlined),
('Modules dir', paths.modulesDir, Icons.extension_outlined),
('Flows dir', paths.flowsDir, Icons.account_tree_outlined),
('PID file', paths.pidPath, Icons.fingerprint),
(l.doctorPathLog, paths.logPath, Icons.description_outlined),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint),
].where((e) => e.$2.isNotEmpty).toList();
if (entries.isEmpty) {
return FaiCard(
child: Text(
'Daemon did not report file paths. '
'Update the running hub via `fai daemon restart`.',
l.doctorPathsEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
);
@ -396,13 +400,14 @@ class _PathRow extends StatelessWidget {
final r = await SystemActions.openInOs(path);
if (!context.mounted) return;
if (!r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open: ${r.stderr}')),
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
);
}
},
icon: const Icon(Icons.open_in_new, size: 14),
label: const Text('Open'),
label: Text(AppLocalizations.of(context)!.buttonOpen),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
@ -468,6 +473,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
ChannelInfo? active;
if (_channels != null) {
for (final c in _channels!.channels) {
@ -502,10 +508,10 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
Expanded(
child: Text(
active == null
? 'Daemon status: …'
? l.doctorStatusUnknown
: active.running
? 'Running on ${active.name}: ${active.endpoint}'
: '${active.name} daemon stopped',
? l.doctorRunningOn(active.name, active.endpoint)
: l.doctorStoppedOn(active.name),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
@ -514,22 +520,20 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
),
if (active != null) ...[
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: 'PID file at the channel\'s run/<name>.pid',
child: FaiPill(
label: active.running ? 'running' : 'stopped',
tone: active.running
? FaiPillTone.success
: FaiPillTone.neutral,
),
FaiPill(
label: active.running
? l.doctorPillRunning
: l.doctorPillStopped,
tone: active.running
? FaiPillTone.success
: FaiPillTone.neutral,
),
],
],
),
const SizedBox(height: FaiSpace.sm),
Text(
'Studio shells out to the platform binary — mirrors '
'`fai daemon …` exactly so the CLI and UI stay in lockstep.',
l.doctorDaemonControlHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -547,7 +551,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
() => SystemActions.faiDaemon(['restart']),
),
icon: const Icon(Icons.restart_alt, size: 16),
label: const Text('Restart'),
label: Text(l.doctorRestart),
),
if (active?.running != true)
FilledButton.icon(
@ -558,7 +562,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
() => SystemActions.faiDaemon(['start']),
),
icon: const Icon(Icons.play_arrow, size: 16),
label: const Text('Start'),
label: Text(l.doctorStart),
),
OutlinedButton.icon(
onPressed: _busy
@ -568,7 +572,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
() => SystemActions.faiDaemon(['stop']),
),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: const Text('Stop'),
label: Text(l.doctorStop),
),
OutlinedButton.icon(
onPressed: _busy
@ -578,7 +582,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
() => SystemActions.faiDaemon(['status']),
),
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: const Text('Status'),
label: Text(l.doctorStatusAction),
),
],
),
@ -593,7 +597,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
),
const SizedBox(width: FaiSpace.sm),
Text(
'Working…',
l.doctorDaemonControlWorking,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -797,7 +801,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
setState(() {
_applying = false;
_applyOutput = r.ok
? 'Update applied. Restart Studio to see the new daemon version.'
? AppLocalizations.of(context)!.doctorApplyDone
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
});
}
@ -805,6 +809,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final status = widget.status;
final available = status.updateAvailable;
final iconData = available
@ -814,13 +819,11 @@ class _UpdateBannerState extends State<_UpdateBanner> {
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
final title = available
? 'Update available — ${status.latestVersion}'
: 'Release host unreachable';
? l.doctorUpdateAvailable(status.latestVersion)
: l.doctorUpdateUnreachable;
final body = available
? 'Channel ${status.channel} now offers ${status.latestVersion}. '
'Apply via the button — Studio shells out to '
'`fai update apply --channel ${status.channel}`.'
: (status.reason ?? 'Could not contact the release feed for ${status.channel}.');
? l.doctorUpdateBody(status.channel, status.latestVersion)
: (status.reason ?? l.doctorUpdateUnreachableBody(status.channel));
return FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -851,7 +854,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
if (available && status.releaseNotesUrl != null) ...[
const SizedBox(height: 4),
Text(
'notes: ${status.releaseNotesUrl}',
l.doctorReleaseNotes(status.releaseNotesUrl!),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.primary,
@ -871,11 +874,11 @@ class _UpdateBannerState extends State<_UpdateBanner> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt, size: 16),
label: Text(_applying ? 'Applying…' : 'Apply update'),
label: Text(_applying ? l.doctorApplying : l.doctorApplyUpdate),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: available ? 'new' : 'offline',
label: available ? l.doctorPillNew : l.doctorPillOffline,
tone: available ? FaiPillTone.success : FaiPillTone.neutral,
),
],

View file

@ -45,7 +45,7 @@ class _ModulesPageState extends State<ModulesPage> {
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
tooltip: AppLocalizations.of(context)!.modulesReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
@ -54,6 +54,7 @@ class _ModulesPageState extends State<ModulesPage> {
body: FutureBuilder<List<ModuleSummary>>(
future: _future,
builder: (context, snapshot) {
final l = AppLocalizations.of(context)!;
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
@ -61,22 +62,20 @@ class _ModulesPageState extends State<ModulesPage> {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: Theme.of(context).colorScheme.error,
title: 'Hub unreachable',
hint:
'Start the hub with `fai serve`. Studio will retry automatically.',
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
child: Text(l.buttonRetry),
),
);
}
final modules = snapshot.data ?? [];
if (modules.isEmpty) {
return const FaiEmptyState(
return FaiEmptyState(
icon: Icons.extension_outlined,
title: 'No modules yet',
hint:
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
title: l.modulesNoneTitle,
hint: l.modulesNoneHint,
);
}
return ListView(
@ -142,7 +141,7 @@ class _ModuleCard extends StatelessWidget {
SizedBox(
width: 110,
child: Text(
'Capabilities',
AppLocalizations.of(context)!.modulesCapabilitiesLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
@ -212,7 +211,7 @@ class _RecentActivityPanel extends StatelessWidget {
),
const SizedBox(width: 6),
Text(
'RECENT ACTIVITY',
AppLocalizations.of(context)!.modulesRecentActivity,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -221,7 +220,8 @@ class _RecentActivityPanel extends StatelessWidget {
),
const SizedBox(width: FaiSpace.sm),
Text(
'last ${events.length} install/uninstall events from the audit log',
AppLocalizations.of(context)!
.modulesRecentActivityHint(events.length),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -269,7 +269,7 @@ class _ActivityRow extends StatelessWidget {
),
Expanded(
child: Text(
'${installed ? "installed" : "uninstalled"} '
'${installed ? AppLocalizations.of(context)!.modulesActivityInstalled : AppLocalizations.of(context)!.modulesActivityUninstalled} '
'${event.moduleName ?? "(unknown)"}'
'${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}',
style: theme.textTheme.bodySmall,

View file

@ -10,6 +10,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
@ -220,9 +221,25 @@ class _StorePageState extends State<StorePage> {
_status.isEmpty &&
!_installedOnly &&
items.any((e) => e.featured);
// Sparse-store nudge: when no filter is set
// and the store carries no federated entries,
// hint that adding an MCP / n8n endpoint
// brings dozens of capabilities at once.
// Hides the moment any federated entry shows
// up the nudge has done its job.
final showFederationNudge =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
!_installedOnly &&
!items.any((e) => e.isFederated);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showFederationNudge) ...[
const _FederationNudge(),
const SizedBox(height: FaiSpace.md),
],
if (showFeatured) ...[
_FeaturedStrip(
items: items.where((e) => e.featured).toList(),
@ -1773,3 +1790,66 @@ class _ScreenshotPlaceholder extends StatelessWidget {
);
}
}
/// Sparse-store nudge: the operator's store has only the
/// bundled native + planned modules and no federated entries.
/// The single biggest store-fullness lever is configuring an
/// MCP server or n8n endpoint, so the banner says exactly
/// that. Tap Settings dialog opens straight to the editor.
/// Dismisses automatically the next render after a federated
/// entry shows up no per-user "don't show again".
class _FederationNudge extends StatelessWidget {
const _FederationNudge();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.hub_outlined,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
FilledButton.tonalIcon(
onPressed: () => FaiSettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 16),
label: Text(l.storeFederationNudgeButton),
),
],
),
);
}
}

View file

@ -6,6 +6,7 @@
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_pill.dart';
@ -48,19 +49,16 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
}
Future<void> _confirmAndUninstall(ModuleDetail detail) async {
final l = AppLocalizations.of(context)!;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Uninstall module?'),
content: Text(
'Removes ${detail.name} v${detail.version} from this hub. '
'Flows that reference it will fail at the next run. '
'A `module.uninstalled` audit event is recorded.',
),
title: Text(l.modulesUninstallTitle),
content: Text(l.modulesUninstallBody(detail.name, detail.version)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
@ -68,7 +66,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Uninstall'),
child: Text(l.buttonUninstall),
),
],
),
@ -79,14 +77,16 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
final r = await HubService.instance.uninstallModule(detail.name);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Uninstalled ${r.name} v${r.version}.')),
SnackBar(
content: Text(l.modulesUninstalledToast(r.name, r.version)),
),
);
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() => _uninstalling = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Uninstall failed: $e')),
SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))),
);
}
}
@ -269,7 +269,11 @@ class _Body extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline, size: 16),
label: Text(uninstalling ? 'Uninstalling…' : 'Uninstall'),
label: Text(
uninstalling
? AppLocalizations.of(context)!.modulesUninstalling
: AppLocalizations.of(context)!.buttonUninstall,
),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(

View file

@ -1,7 +1,7 @@
name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.24.0
version: 0.25.0
environment:
sdk: ^3.11.0-200.1.beta