feat(studio): de-jargonize, reveal-in-OS, copyable errors, today carousel, AppBar alignment, F∆I window title (v0.34.0)

Sweeping pass against six user reports collected this session.

1. "Capabilities ist nicht deutsch, Chain auch nicht."
   The DE locale still leaked English vocabulary. Replaced
   "Capabilities" → "Fähigkeiten" and "Chain" / "Hash-Chain"
   → "Kette" / "Hash-Kette" everywhere — store search hint,
   recommended-source body, federated toast, doctor summary
   chain row, modules panel summary, MCP / n8n hints, the
   approvals history blurb. Wire-level identifiers
   (`chain.reset`) stay as code.

2. "Bei Fehlern unten muss man die auch ins clipboard
   kopieren können." New `FaiErrorBox` widget: selectable
   monospace block with a small copy-to-clipboard icon
   button that flips to a checkmark for two seconds after
   click. Applied to the Doctor update banner output and
   the Settings channel toast — the two places long
   stderr / stdout lands.

3. "Öffnen bei Log kann es nicht öffnen. Audit-DB auch
   nicht. PID auch nicht."
   Cause: `SystemActions.openInOs` shells out to `open` /
   `xdg-open` on file paths the OS has no default handler
   for (SQLite DB, PID file, log without an .ext that
   binds). New `revealInOs` uses `open -R` on macOS,
   `explorer /select,` on Windows, and the parent
   directory via `xdg-open` on Linux. Doctor's path rows
   carry an `isDirectory` flag that routes through the new
   `openOrReveal` so files reveal in Finder / Explorer
   instead of failing silently.

4. "Oben im Store könnte man diesen Redaktionshinweis auch
   so bauen, dass man mit pfeil nach rechts links auch
   weitere anzeigen kann."
   The Today-Hero became a carousel. Curated fallback
   list grew from one entry to four (public sources, the
   sandbox-by-default permission story, the hash-chained
   audit story, the air-gap-ready single-binary pitch).
   Hero gets prev / next chevrons plus a dot indicator
   when the current snapshot has more than one slide.
   Operator-accepted stories stay single — the carousel
   collapses when there's only one to show.

5. "Ich fände es schöner wenn rechts und links im Store
   die Abstände konsistent sind, das Reload-Symbol rechts
   ist zu weit rechts und Store links auch nicht bündig."
   AppBar now has `titleSpacing: FaiSpace.xl` so the
   title's left edge sits flush with the body's left
   padding (24 dp), and the trailing `SizedBox` after the
   reload icon shrunk so the icon's outer edge meets the
   right edge of the rightmost grid card.

6. "Oben der Titel zeigt fai_studio an, das sollte F∆I
   Studio sein." The OS window title was the
   pubspec-derived "fai_studio". Macos/Linux/Windows
   runners now hard-code "F∆I Studio" (with the U+2206
   triangle escape so the C++ source stays ASCII). macOS
   bundle name and display name lifted out of the
   PRODUCT_NAME variable for the same reason.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 14:48:33 +02:00
parent 22a851b7e2
commit 3b5fb027c3
17 changed files with 454 additions and 119 deletions

View file

@ -39,6 +39,50 @@ class SystemActions {
} }
} }
/// Reveal [path] in the platform file manager. Works for binary
/// files (the SQLite audit DB, the PID file) where `openInOs`
/// would fall flat macOS refuses to "open" a file with no
/// registered app. macOS uses `open -R`; Windows
/// `explorer /select,`; Linux falls back to opening the parent
/// directory because there is no portable reveal-and-highlight.
static Future<({bool ok, String stderr})> revealInOs(String path) async {
if (path.isEmpty) {
return (ok: false, stderr: 'empty path');
}
try {
final ProcessResult r;
if (Platform.isMacOS) {
r = await Process.run('open', ['-R', path]);
} else if (Platform.isWindows) {
// /select, requires no space after the comma; explorer
// returns 1 even on success, so don't gate on exitCode.
r = await Process.run('explorer', ['/select,$path']);
return (ok: true, stderr: '');
} else {
// Linux: open the containing directory in the default
// file manager. Best portable approximation.
final parent = File(path).parent.path;
r = await Process.run('xdg-open', [parent]);
}
if (r.exitCode == 0) return (ok: true, stderr: '');
return (ok: false, stderr: r.stderr.toString().trim());
} catch (e) {
return (ok: false, stderr: e.toString());
}
}
/// Convenience: reveal binary files, open directories /
/// text-shaped paths normally. Doctor's path rows use this so
/// the operator never has to know which strategy fits which
/// kind of file.
static Future<({bool ok, String stderr})> openOrReveal(
String path, {
required bool isDirectory,
}) async {
if (isDirectory) return openInOs(path);
return revealInOs(path);
}
/// Run a `fai daemon ...` subcommand and surface the captured /// Run a `fai daemon ...` subcommand and surface the captured
/// output. Used by Doctor's "Restart daemon" button. /// output. Used by Doctor's "Restart daemon" button.
static Future<({bool ok, String stdout, String stderr})> faiDaemon( static Future<({bool ok, String stdout, String stderr})> faiDaemon(

View file

@ -28,6 +28,8 @@
"buttonOpen": "Öffnen", "buttonOpen": "Öffnen",
"buttonExplain": "Erklären", "buttonExplain": "Erklären",
"buttonReadDocs": "Doku öffnen", "buttonReadDocs": "Doku öffnen",
"buttonCopy": "In Zwischenablage kopieren",
"buttonCopied": "Kopiert",
"settingsTitle": "Hub-Endpunkt", "settingsTitle": "Hub-Endpunkt",
"settingsHost": "Host", "settingsHost": "Host",
@ -62,7 +64,7 @@
"n8nAddTooltip": "n8n-Endpunkt hinzufügen", "n8nAddTooltip": "n8n-Endpunkt hinzufügen",
"n8nRemoveTooltip": "Endpunkt entfernen", "n8nRemoveTooltip": "Endpunkt entfernen",
"addMcpTitle": "MCP-Server hinzufügen", "addMcpTitle": "MCP-Server hinzufügen",
"addMcpIntro": "Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp.<name>.<tool>`-Capabilities.", "addMcpIntro": "Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp.<name>.<tool>`-Fähigkeiten.",
"addMcpSuggestionsLabel": "Vorschläge — zum Vorausfüllen klicken", "addMcpSuggestionsLabel": "Vorschläge — zum Vorausfüllen klicken",
"addMcpNameLabel": "Name (keine Punkte)", "addMcpNameLabel": "Name (keine Punkte)",
"addMcpNameHint": "filesystem", "addMcpNameHint": "filesystem",
@ -73,7 +75,7 @@
"addMcpNotesLabel": "Notizen (optional)", "addMcpNotesLabel": "Notizen (optional)",
"addMcpAddButton": "Hinzufügen + Discovery", "addMcpAddButton": "Hinzufügen + Discovery",
"addN8nTitle": "n8n-Endpunkt hinzufügen", "addN8nTitle": "n8n-Endpunkt hinzufügen",
"addN8nIntro": "Aktive Workflows erscheinen im Store als `n8n.<name>.<workflow_slug>`-Capabilities.", "addN8nIntro": "Aktive Workflows erscheinen im Store als `n8n.<name>.<workflow_slug>`-Fähigkeiten.",
"addN8nNameLabel": "Name (keine Punkte)", "addN8nNameLabel": "Name (keine Punkte)",
"addN8nNameHint": "ops", "addN8nNameHint": "ops",
"addN8nBaseUrlLabel": "Basis-URL (ohne /api/v1)", "addN8nBaseUrlLabel": "Basis-URL (ohne /api/v1)",
@ -102,7 +104,7 @@
"channelsEnableAutostart": "Autostart bei Anmeldung aktivieren", "channelsEnableAutostart": "Autostart bei Anmeldung aktivieren",
"channelsDisableAutostart": "Autostart deaktivieren", "channelsDisableAutostart": "Autostart deaktivieren",
"storeSearchHint": "Module suchen — Name, Tagline, Tag, Capability…", "storeSearchHint": "Module suchen — Name, Tagline, Tag, Fähigkeit…",
"storeAskHint": "Was brauchst du? Versuch's mit „ein Modul, das tabellarische Daten liest\" oder tippe einfach einen Begriff.", "storeAskHint": "Was brauchst du? Versuch's mit „ein Modul, das tabellarische Daten liest\" oder tippe einfach einen Begriff.",
"storeAskSubmit": "Suchen", "storeAskSubmit": "Suchen",
"storeAskAi": "KI fragen", "storeAskAi": "KI fragen",
@ -187,7 +189,7 @@
"storeOpenBrowserFailed": "Browser konnte nicht geöffnet werden: {error}", "storeOpenBrowserFailed": "Browser konnte nicht geöffnet werden: {error}",
"@storeOpenBrowserFailed": { "placeholders": { "error": { "type": "String" } } }, "@storeOpenBrowserFailed": { "placeholders": { "error": { "type": "String" } } },
"storeSectionTags": "Tags", "storeSectionTags": "Tags",
"storeSectionRequiredCapabilities": "Erforderliche Capabilities", "storeSectionRequiredCapabilities": "Erforderliche Fähigkeiten",
"storeSectionRequiredHostServices": "Erforderliche Host-Dienste", "storeSectionRequiredHostServices": "Erforderliche Host-Dienste",
"storeSectionScreenshots": "Screenshots", "storeSectionScreenshots": "Screenshots",
"storeSectionDocumentation": "Dokumentation", "storeSectionDocumentation": "Dokumentation",
@ -221,19 +223,19 @@
"storeProvenanceN8n": "n8n · {provider}", "storeProvenanceN8n": "n8n · {provider}",
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } }, "@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
"storeRecommendedTitle": "Öffentliche Quelle hinzufügen — ein Klick", "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.", "storeRecommendedBody": "Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Fähigkeiten erscheinen sofort im Store.",
"storeRecommendedAddTooltip": "Hinzufügen und entdecken", "storeRecommendedAddTooltip": "Hinzufügen und entdecken",
"storeRecommendedAdding": "Wird hinzugefügt…", "storeRecommendedAdding": "Wird hinzugefügt…",
"storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)", "storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)",
"storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen", "storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen",
"storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Capability} other{föderierte Capabilities}}.", "storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Fähigkeit} other{föderierte Fähigkeiten}}.",
"@storeProviderAddedToast": { "@storeProviderAddedToast": {
"placeholders": { "placeholders": {
"name": { "type": "String" }, "name": { "type": "String" },
"n": { "type": "int" } "n": { "type": "int" }
} }
}, },
"storeProviderAddedZeroToast": "{name} ist eingetragen, aber der Server hat noch keine Capabilities geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.", "storeProviderAddedZeroToast": "{name} ist eingetragen, aber der Server hat noch keine Fähigkeiten geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.",
"@storeProviderAddedZeroToast": { "@storeProviderAddedZeroToast": {
"placeholders": { "name": { "type": "String" } } "placeholders": { "name": { "type": "String" } }
}, },
@ -253,7 +255,7 @@
"auditFilterStep": "Step", "auditFilterStep": "Step",
"auditFilterModule": "Modul", "auditFilterModule": "Modul",
"auditClearLogTooltip": "Protokoll löschen (nur local/dev Kanal)", "auditClearLogTooltip": "Protokoll löschen (nur local/dev Kanal)",
"auditClearedToast": "{n} Ereignisse auf Kanal „{channel}\" gelöscht. Chain mit Marker neu geseedet.", "auditClearedToast": "{n} Ereignisse auf Kanal „{channel}\" gelöscht. Kette mit Marker neu geseedet.",
"@auditClearedToast": { "@auditClearedToast": {
"placeholders": { "placeholders": {
"n": { "type": "int" }, "n": { "type": "int" },
@ -276,7 +278,7 @@
"@auditLiveStatus": { "placeholders": { "n": { "type": "int" } } }, "@auditLiveStatus": { "placeholders": { "n": { "type": "int" } } },
"auditDisconnected": "getrennt · {error}", "auditDisconnected": "getrennt · {error}",
"@auditDisconnected": { "placeholders": { "error": { "type": "String" } } }, "@auditDisconnected": { "placeholders": { "error": { "type": "String" } } },
"auditHashChainVerified": "Hash-Chain verifiziert", "auditHashChainVerified": "Hash-Kette verifiziert",
"auditDetailHeader": "DETAIL", "auditDetailHeader": "DETAIL",
"auditAskingFull": "Frage an die konfigurierte System-AI läuft…", "auditAskingFull": "Frage an die konfigurierte System-AI läuft…",
"auditFixLabel": "FIX", "auditFixLabel": "FIX",
@ -308,7 +310,7 @@
"@modulesRecentActivityHint": { "placeholders": { "n": { "type": "int" } } }, "@modulesRecentActivityHint": { "placeholders": { "n": { "type": "int" } } },
"modulesActivityInstalled": "installiert", "modulesActivityInstalled": "installiert",
"modulesActivityUninstalled": "deinstalliert", "modulesActivityUninstalled": "deinstalliert",
"modulesCapabilitiesLabel": "Capabilities", "modulesCapabilitiesLabel": "Fähigkeiten",
"modulesUninstallTitle": "Modul deinstallieren?", "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": "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": { "@modulesUninstallBody": {
@ -329,7 +331,7 @@
"modulesUninstalling": "Wird deinstalliert…", "modulesUninstalling": "Wird deinstalliert…",
"moduleSheetFailedToLoad": "Laden fehlgeschlagen: {error}", "moduleSheetFailedToLoad": "Laden fehlgeschlagen: {error}",
"@moduleSheetFailedToLoad": { "placeholders": { "error": { "type": "String" } } }, "@moduleSheetFailedToLoad": { "placeholders": { "error": { "type": "String" } } },
"moduleSheetCapabilities": "Capabilities", "moduleSheetCapabilities": "Fähigkeiten",
"moduleSheetPermissions": "Deklarierte Berechtigungen", "moduleSheetPermissions": "Deklarierte Berechtigungen",
"moduleSheetNoPermissions": "(keine — reines Berechnungsmodul)", "moduleSheetNoPermissions": "(keine — reines Berechnungsmodul)",
@ -381,7 +383,7 @@
"approvalsRejectFailed": "Ablehnung fehlgeschlagen: {error}", "approvalsRejectFailed": "Ablehnung fehlgeschlagen: {error}",
"@approvalsRejectFailed": { "placeholders": { "error": { "type": "String" } } }, "@approvalsRejectFailed": { "placeholders": { "error": { "type": "String" } } },
"approvalsHistoryEmpty": "Noch kein Verlauf", "approvalsHistoryEmpty": "Noch kein Verlauf",
"approvalsHistoryEmptyHint": "Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-chained Datensatz.", "approvalsHistoryEmptyHint": "Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-verketteten Datensatz.",
"approvalsDialogDecided": "entschieden", "approvalsDialogDecided": "entschieden",
"approvalsDialogCreated": "erstellt", "approvalsDialogCreated": "erstellt",
"approvalsDialogReason": "Begründung", "approvalsDialogReason": "Begründung",
@ -396,10 +398,10 @@
"doctorSummaryApprovals": "Freigaben", "doctorSummaryApprovals": "Freigaben",
"doctorSummaryAudit": "Protokoll", "doctorSummaryAudit": "Protokoll",
"doctorSummaryServices": "Dienste", "doctorSummaryServices": "Dienste",
"doctorSummaryCapabilities": "{n} Capabilities", "doctorSummaryCapabilities": "{n} Fähigkeiten",
"@doctorSummaryCapabilities": { "placeholders": { "n": { "type": "int" } } }, "@doctorSummaryCapabilities": { "placeholders": { "n": { "type": "int" } } },
"doctorSummaryPending": "offen", "doctorSummaryPending": "offen",
"doctorSummaryChain": "{verified}/{total} Chain", "doctorSummaryChain": "{verified}/{total} Kette",
"@doctorSummaryChain": { "@doctorSummaryChain": {
"placeholders": { "placeholders": {
"verified": { "type": "int" }, "verified": { "type": "int" },
@ -407,7 +409,7 @@
} }
}, },
"doctorSummaryDeclared": "deklariert", "doctorSummaryDeclared": "deklariert",
"doctorModulesPanelSummary": "{n} Module · {m} Capabilities", "doctorModulesPanelSummary": "{n} Module · {m} Fähigkeiten",
"@doctorModulesPanelSummary": { "@doctorModulesPanelSummary": {
"placeholders": { "placeholders": {
"n": { "type": "int" }, "n": { "type": "int" },
@ -428,7 +430,7 @@
"doctorHostServicesSection": "Host-Dienste", "doctorHostServicesSection": "Host-Dienste",
"doctorDaemonFilesSection": "Daemon-Dateien", "doctorDaemonFilesSection": "Daemon-Dateien",
"doctorDaemonControlSection": "Daemon-Steuerung", "doctorDaemonControlSection": "Daemon-Steuerung",
"doctorChainIntact": "Hash-Chain intakt", "doctorChainIntact": "Hash-Kette intakt",
"doctorChainIntactDetail": "{n} Ereignisse · prev_event_sha256 lückenlos verifiziert", "doctorChainIntactDetail": "{n} Ereignisse · prev_event_sha256 lückenlos verifiziert",
"@doctorChainIntactDetail": { "placeholders": { "n": { "type": "int" } } }, "@doctorChainIntactDetail": { "placeholders": { "n": { "type": "int" } } },
"doctorChainTampered": "Manipulation erkannt", "doctorChainTampered": "Manipulation erkannt",
@ -491,10 +493,10 @@
"doctorApplyDone": "Update angewendet. Studio neu starten, um die neue Daemon-Version zu sehen.", "doctorApplyDone": "Update angewendet. Studio neu starten, um die neue Daemon-Version zu sehen.",
"mcpHeader": "MCP-CLIENTS", "mcpHeader": "MCP-CLIENTS",
"mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Capabilities ein. Einmal konfigurieren, im Store sichtbar.", "mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Fähigkeiten ein. Einmal konfigurieren, im Store sichtbar.",
"mcpEmpty": "Keine MCP-Server konfiguriert. + klicken zum Hinzufügen.", "mcpEmpty": "Keine MCP-Server konfiguriert. + klicken zum Hinzufügen.",
"n8nHeader": "N8N-ENDPUNKTE", "n8nHeader": "N8N-ENDPUNKTE",
"n8nHint": "Aktive n8n-Workflows erscheinen als `n8n.<endpoint>.<slug>` Capabilities. Einmal konfigurieren, im Store sichtbar.", "n8nHint": "Aktive n8n-Workflows erscheinen als `n8n.<endpoint>.<slug>` Fähigkeiten. Einmal konfigurieren, im Store sichtbar.",
"n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.", "n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.",
"hubUnreachable": "Hub nicht erreichbar", "hubUnreachable": "Hub nicht erreichbar",

View file

@ -29,6 +29,8 @@
"buttonOpen": "Open", "buttonOpen": "Open",
"buttonExplain": "Explain", "buttonExplain": "Explain",
"buttonReadDocs": "Read docs", "buttonReadDocs": "Read docs",
"buttonCopy": "Copy to clipboard",
"buttonCopied": "Copied",
"settingsTitle": "Hub endpoint", "settingsTitle": "Hub endpoint",
"settingsHost": "Host", "settingsHost": "Host",

View file

@ -242,6 +242,18 @@ abstract class AppLocalizations {
/// **'Read docs'** /// **'Read docs'**
String get buttonReadDocs; String get buttonReadDocs;
/// No description provided for @buttonCopy.
///
/// In en, this message translates to:
/// **'Copy to clipboard'**
String get buttonCopy;
/// No description provided for @buttonCopied.
///
/// In en, this message translates to:
/// **'Copied'**
String get buttonCopied;
/// No description provided for @settingsTitle. /// No description provided for @settingsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -80,6 +80,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get buttonReadDocs => 'Doku öffnen'; String get buttonReadDocs => 'Doku öffnen';
@override
String get buttonCopy => 'In Zwischenablage kopieren';
@override
String get buttonCopied => 'Kopiert';
@override @override
String get settingsTitle => 'Hub-Endpunkt'; String get settingsTitle => 'Hub-Endpunkt';
@ -193,7 +199,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get addMcpIntro => String get addMcpIntro =>
'Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp.<name>.<tool>`-Capabilities.'; 'Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp.<name>.<tool>`-Fähigkeiten.';
@override @override
String get addMcpSuggestionsLabel => 'Vorschläge — zum Vorausfüllen klicken'; String get addMcpSuggestionsLabel => 'Vorschläge — zum Vorausfüllen klicken';
@ -227,7 +233,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get addN8nIntro => String get addN8nIntro =>
'Aktive Workflows erscheinen im Store als `n8n.<name>.<workflow_slug>`-Capabilities.'; 'Aktive Workflows erscheinen im Store als `n8n.<name>.<workflow_slug>`-Fähigkeiten.';
@override @override
String get addN8nNameLabel => 'Name (keine Punkte)'; String get addN8nNameLabel => 'Name (keine Punkte)';
@ -304,7 +310,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storeSearchHint => String get storeSearchHint =>
'Module suchen — Name, Tagline, Tag, Capability'; 'Module suchen — Name, Tagline, Tag, Fähigkeit';
@override @override
String get storeAskHint => String get storeAskHint =>
@ -502,7 +508,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeSectionTags => 'Tags'; String get storeSectionTags => 'Tags';
@override @override
String get storeSectionRequiredCapabilities => 'Erforderliche Capabilities'; String get storeSectionRequiredCapabilities => 'Erforderliche Fähigkeiten';
@override @override
String get storeSectionRequiredHostServices => 'Erforderliche Host-Dienste'; String get storeSectionRequiredHostServices => 'Erforderliche Host-Dienste';
@ -605,7 +611,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storeRecommendedBody => String get storeRecommendedBody =>
'Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.'; 'Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Fähigkeiten erscheinen sofort im Store.';
@override @override
String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken'; String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken';
@ -626,15 +632,15 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
n, n,
locale: localeName, locale: localeName,
other: 'föderierte Capabilities', other: 'föderierte Fähigkeiten',
one: 'föderierte Capability', one: 'föderierte Fähigkeit',
); );
return '$name hinzugefügt — $n $_temp0.'; return '$name hinzugefügt — $n $_temp0.';
} }
@override @override
String storeProviderAddedZeroToast(String name) { String storeProviderAddedZeroToast(String name) {
return '$name ist eingetragen, aber der Server hat noch keine Capabilities geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.'; return '$name ist eingetragen, aber der Server hat noch keine Fähigkeiten geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.';
} }
@override @override
@ -669,7 +675,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String auditClearedToast(int n, String channel) { String auditClearedToast(int n, String channel) {
return '$n Ereignisse auf Kanal „$channel\" gelöscht. Chain mit Marker neu geseedet.'; return '$n Ereignisse auf Kanal „$channel\" gelöscht. Kette mit Marker neu geseedet.';
} }
@override @override
@ -723,7 +729,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get auditHashChainVerified => 'Hash-Chain verifiziert'; String get auditHashChainVerified => 'Hash-Kette verifiziert';
@override @override
String get auditDetailHeader => 'DETAIL'; String get auditDetailHeader => 'DETAIL';
@ -802,7 +808,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get modulesActivityUninstalled => 'deinstalliert'; String get modulesActivityUninstalled => 'deinstalliert';
@override @override
String get modulesCapabilitiesLabel => 'Capabilities'; String get modulesCapabilitiesLabel => 'Fähigkeiten';
@override @override
String get modulesUninstallTitle => 'Modul deinstallieren?'; String get modulesUninstallTitle => 'Modul deinstallieren?';
@ -831,7 +837,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get moduleSheetCapabilities => 'Capabilities'; String get moduleSheetCapabilities => 'Fähigkeiten';
@override @override
String get moduleSheetPermissions => 'Deklarierte Berechtigungen'; String get moduleSheetPermissions => 'Deklarierte Berechtigungen';
@ -951,7 +957,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get approvalsHistoryEmptyHint => String get approvalsHistoryEmptyHint =>
'Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-chained Datensatz.'; 'Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-verketteten Datensatz.';
@override @override
String get approvalsDialogDecided => 'entschieden'; String get approvalsDialogDecided => 'entschieden';
@ -992,7 +998,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String doctorSummaryCapabilities(int n) { String doctorSummaryCapabilities(int n) {
return '$n Capabilities'; return '$n Fähigkeiten';
} }
@override @override
@ -1000,7 +1006,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String doctorSummaryChain(int verified, int total) { String doctorSummaryChain(int verified, int total) {
return '$verified/$total Chain'; return '$verified/$total Kette';
} }
@override @override
@ -1008,7 +1014,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String doctorModulesPanelSummary(int n, int m) { String doctorModulesPanelSummary(int n, int m) {
return '$n Module · $m Capabilities'; return '$n Module · $m Fähigkeiten';
} }
@override @override
@ -1060,7 +1066,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get doctorDaemonControlSection => 'Daemon-Steuerung'; String get doctorDaemonControlSection => 'Daemon-Steuerung';
@override @override
String get doctorChainIntact => 'Hash-Chain intakt'; String get doctorChainIntact => 'Hash-Kette intakt';
@override @override
String doctorChainIntactDetail(int n) { String doctorChainIntactDetail(int n) {
@ -1193,7 +1199,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get mcpHint => String get mcpHint =>
'Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Capabilities ein. Einmal konfigurieren, im Store sichtbar.'; 'Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Fähigkeiten ein. Einmal konfigurieren, im Store sichtbar.';
@override @override
String get mcpEmpty => String get mcpEmpty =>
@ -1204,7 +1210,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get n8nHint => String get n8nHint =>
'Aktive n8n-Workflows erscheinen als `n8n.<endpoint>.<slug>` Capabilities. Einmal konfigurieren, im Store sichtbar.'; 'Aktive n8n-Workflows erscheinen als `n8n.<endpoint>.<slug>` Fähigkeiten. Einmal konfigurieren, im Store sichtbar.';
@override @override
String get n8nEmpty => String get n8nEmpty =>

View file

@ -80,6 +80,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get buttonReadDocs => 'Read docs'; String get buttonReadDocs => 'Read docs';
@override
String get buttonCopy => 'Copy to clipboard';
@override
String get buttonCopied => 'Copied';
@override @override
String get settingsTitle => 'Hub endpoint'; String get settingsTitle => 'Hub endpoint';

View file

@ -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.33.1'; const String kStudioVersion = '0.34.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -334,13 +334,17 @@ class _DaemonPathsPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final entries = <(String, String, IconData)>[ // Per-row layout: (label, path, icon, isDirectory). The
(l.doctorPathLog, paths.logPath, Icons.description_outlined), // isDirectory bit drives `openOrReveal` so binary files
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined), // (the SQLite audit DB, the PID file) reveal in Finder /
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined), // Explorer instead of failing the OS "open" handler.
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined), final entries = <(String, String, IconData, bool)>[
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined), (l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint), (l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined, true),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint, false),
].where((e) => e.$2.isNotEmpty).toList(); ].where((e) => e.$2.isNotEmpty).toList();
if (entries.isEmpty) { if (entries.isEmpty) {
@ -356,7 +360,13 @@ class _DaemonPathsPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3), for (final e in entries)
_PathRow(
label: e.$1,
path: e.$2,
icon: e.$3,
isDirectory: e.$4,
),
], ],
), ),
); );
@ -367,8 +377,14 @@ class _PathRow extends StatelessWidget {
final String label; final String label;
final String path; final String path;
final IconData icon; final IconData icon;
final bool isDirectory;
const _PathRow({required this.label, required this.path, required this.icon}); const _PathRow({
required this.label,
required this.path,
required this.icon,
required this.isDirectory,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -400,7 +416,10 @@ class _PathRow extends StatelessWidget {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
final r = await SystemActions.openInOs(path); final r = await SystemActions.openOrReveal(
path,
isDirectory: isDirectory,
);
if (!context.mounted) return; if (!context.mounted) return;
if (!r.ok) { if (!r.ok) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -409,7 +428,10 @@ class _PathRow extends StatelessWidget {
); );
} }
}, },
icon: const Icon(Icons.open_in_new, size: 14), icon: Icon(
isDirectory ? Icons.folder_open : Icons.open_in_new,
size: 14,
),
label: Text(AppLocalizations.of(context)!.buttonOpen), label: Text(AppLocalizations.of(context)!.buttonOpen),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -895,22 +917,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
), ),
if (_applyOutput != null) ...[ if (_applyOutput != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Container( FaiErrorBox(text: _applyOutput!, maxHeight: 240),
width: double.infinity,
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: SelectableText(
_applyOutput!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
], ],
], ],
), ),

View file

@ -42,11 +42,24 @@ class _StorePageState extends State<StorePage> {
/// session. Not persisted the next Studio launch shows it /// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen. /// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false; bool _todayDismissed = false;
/// Loaded once at init from `~/.fai/today/active.yaml`; falls /// The carousel of stories rendered in the hero. When an
/// back to the compiled-in story when the pipeline hasn't /// operator-accepted story exists at `~/.fai/today/active.yaml`,
/// produced an accepted candidate yet. /// it is the only entry (carousel collapses to a single
late final TodayStoryData _todayStory = /// slide). Otherwise the curated fallback list rotates.
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory); late final List<TodayStoryData> _todayStories = (() {
final loaded =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStories.first);
// When the loader fell back to the const, it returned the
// first entry of our list surface the whole list so the
// operator can browse. Otherwise the loader produced a real
// accepted story and we honour it as the only item.
if (identical(loaded, _kFallbackTodayStories.first)) {
return _kFallbackTodayStories;
}
return <TodayStoryData>[loaded];
})();
/// Carousel index. Wraps around modulo `_todayStories.length`.
int _todayIndex = 0;
late Future<List<StoreItem>> _future; late Future<List<StoreItem>> _future;
// AI search state // AI search state
@ -151,11 +164,13 @@ class _StorePageState extends State<StorePage> {
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
// titleSpacing matches the body's horizontal padding
// (FaiSpace.xl == 24) so the title's left edge sits
// flush with the cards below. Default 16 left a 8-px
// gap that read as a misaligned heading.
titleSpacing: FaiSpace.xl,
title: Text(l.searchGroupStore), title: Text(l.searchGroupStore),
actions: [ actions: [
// Category dropdown and Filter button live in the
// toolbar so the body stays uncluttered. Both refresh
// off the current store snapshot via FutureBuilder.
FutureBuilder<List<StoreItem>>( FutureBuilder<List<StoreItem>>(
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
@ -186,7 +201,11 @@ class _StorePageState extends State<StorePage> {
tooltip: l.storeReloadTooltip, tooltip: l.storeReloadTooltip,
onPressed: _runSearch, onPressed: _runSearch,
), ),
const SizedBox(width: FaiSpace.sm), // Trailing pad pulls the IconButton's outer edge in
// to match the body's right-side padding. The icon
// glyph itself ends up flush with the rightmost grid
// card, not floating in space.
const SizedBox(width: FaiSpace.md),
], ],
), ),
body: Column( body: Column(
@ -287,11 +306,31 @@ class _StorePageState extends State<StorePage> {
], ],
if (showToday) ...[ if (showToday) ...[
_StoreTodayHero( _StoreTodayHero(
story: _todayStory, story:
_todayStories[_todayIndex % _todayStories.length],
locale: _locale, locale: _locale,
currentIndex: _todayIndex,
totalCount: _todayStories.length,
onPrev: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
: null,
onNext: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex + 1) %
_todayStories.length;
})
: null,
onDismiss: () => onDismiss: () =>
setState(() => _todayDismissed = true), setState(() => _todayDismissed = true),
onCta: _todayStory.cta == TodayCta.openSettings onCta: _todayStories[
_todayIndex % _todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context) ? () => FaiSettingsDialog.show(context)
: null, : null,
recommendedSources: embedRecommended recommendedSources: embedRecommended
@ -2659,24 +2698,70 @@ class _ScreenshotPlaceholder extends StatelessWidget {
} }
} }
/// Compiled-in fallback story shown when /// Compiled-in fallback stories shown when
/// `~/.fai/today/active.yaml` is absent or fails schema /// `~/.fai/today/active.yaml` is absent or fails schema
/// validation. The pipeline operator can override at any time /// validation. Operator can override at any time without
/// without recompiling Studio see docs/today-pipeline.md. /// recompiling Studio see docs/today-pipeline.md. When the
const TodayStoryData _kFallbackTodayStory = TodayStoryData( /// operator accepts a story, that single story becomes the
badgeEn: 'PUBLIC SOURCES', /// active item and the carousel collapses to a single slide.
badgeDe: 'ÖFFENTLICHE QUELLEN', const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
titleEn: 'Fill your store in two clicks', TodayStoryData(
titleDe: 'Den Store in zwei Klicks auffüllen', badgeEn: 'PUBLIC SOURCES',
bodyEn: badgeDe: 'ÖFFENTLICHE QUELLEN',
'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.', titleEn: 'Fill your store in two clicks',
bodyDe: titleDe: 'Den Store in zwei Klicks auffüllen',
'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.', bodyEn:
ctaLabelEn: 'Manage sources', 'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.',
ctaLabelDe: 'Quellen verwalten', bodyDe:
icon: Icons.hub_outlined, 'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.',
cta: TodayCta.openSettings, ctaLabelEn: 'Manage sources',
); ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined,
cta: TodayCta.openSettings,
),
TodayStoryData(
badgeEn: 'SANDBOX BY DEFAULT',
badgeDe: 'SANDBOX VON ANFANG AN',
titleEn: 'Every module declares what it can touch',
titleDe: 'Jedes Modul deklariert, worauf es zugreifen darf',
bodyEn:
'F∆I modules ship with an explicit permission list — network endpoints, files, environment variables. The hub enforces it; nothing reaches outside the sandbox without the operator approving it. Click any installed module to see exactly which permissions it asked for.',
bodyDe:
'F∆I-Module bringen eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox. Klicke ein installiertes Modul, um seine genauen Berechtigungen zu sehen.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.shield_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'TAMPER-EVIDENT AUDIT',
badgeDe: 'MANIPULATIONSSICHERES AUDIT',
titleEn: 'Every event leaves a hash-chained trace',
titleDe: 'Jedes Ereignis hinterlässt eine hash-verkettete Spur',
bodyEn:
'Flow runs, install / uninstall actions, approval decisions — all of them write into a hash-chained audit log. The Doctor page verifies the chain end-to-end on every load; if a single byte gets touched, you see it. Audit-grade by construction, not by policy.',
bodyDe:
'Flow-Läufe, Installationen und Deinstallationen, Freigabe-Entscheidungen — alles wird in ein hash-verkettetes Audit-Log geschrieben. Die Diagnose-Seite verifiziert die Kette bei jedem Laden komplett; ein verändertes Byte ist sofort sichtbar. Audit-tauglich qua Konstruktion, nicht qua Policy.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.shield_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'AIR-GAP READY',
badgeDe: 'AIR-GAP-TAUGLICH',
titleEn: 'One binary, no Docker, no external services',
titleDe: 'Ein Binary, kein Docker, keine externen Dienste',
bodyEn:
'The whole hub fits in a single binary that runs on Linux, macOS and Windows. SQLite for state, no message broker, no background services to babysit. Once a module is installed, the flow it powers runs without further network access — perfect for regulated environments where outbound calls have to be justified.',
bodyDe:
'Der gesamte Hub steckt in einem einzigen Binary für Linux, macOS und Windows. SQLite für den State, kein Message-Broker, keine Hintergrunddienste, die gepflegt werden müssen. Sobald ein Modul installiert ist, läuft der Flow, der es nutzt, ohne weiteren Netzwerkzugriff — ideal für regulierte Umgebungen, in denen jeder ausgehende Call begründet werden muss.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.rocket_launch,
cta: TodayCta.none,
),
];
/// Editorial hero strip the first surface a browsing operator /// Editorial hero strip the first surface a browsing operator
/// sees. Plays the role of Apple's Today tab: one curated /// sees. Plays the role of Apple's Today tab: one curated
@ -2686,6 +2771,15 @@ const TodayStoryData _kFallbackTodayStory = TodayStoryData(
class _StoreTodayHero extends StatelessWidget { class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story; final TodayStoryData story;
final String locale; final String locale;
/// Carousel position (0-based) and total slide count. Drives
/// the dot indicator and the visibility of the prev/next
/// arrows. When `totalCount == 1` the chrome collapses to a
/// single-card layout operator-accepted stories never wear
/// carousel dressing.
final int currentIndex;
final int totalCount;
final VoidCallback? onPrev;
final VoidCallback? onNext;
final VoidCallback onDismiss; final VoidCallback onDismiss;
/// CTA action null hides the button. Wired by the parent /// CTA action null hides the button. Wired by the parent
/// because navigation targets live in the store-page state, /// because navigation targets live in the store-page state,
@ -2703,6 +2797,10 @@ class _StoreTodayHero extends StatelessWidget {
const _StoreTodayHero({ const _StoreTodayHero({
required this.story, required this.story,
required this.locale, required this.locale,
required this.currentIndex,
required this.totalCount,
required this.onPrev,
required this.onNext,
required this.onDismiss, required this.onDismiss,
required this.onCta, required this.onCta,
this.recommendedSources = const <_RecommendedSource>[], this.recommendedSources = const <_RecommendedSource>[],
@ -2848,11 +2946,62 @@ class _StoreTodayHero extends StatelessWidget {
], ],
), ),
), ),
IconButton( // Right-edge column: dismiss + (when more than one
icon: const Icon(Icons.close, size: 16), // slide exists) a carousel control row. Stacking them
visualDensity: VisualDensity.compact, // vertically keeps the hero's main row tidy whatever
tooltip: l.storeTodayDismissTooltip, // the slide count.
onPressed: onDismiss, Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeTodayDismissTooltip,
onPressed: onDismiss,
),
if (totalCount > 1) ...[
const SizedBox(height: FaiSpace.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onPrev,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < totalCount; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == currentIndex
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onNext,
),
],
),
],
],
), ),
], ],
), ),

View file

@ -0,0 +1,113 @@
// FaiErrorBox selectable monospace error / output block with
// a small copy-to-clipboard button in the top-right corner.
//
// Used wherever the operator might want to paste daemon stderr
// into a chat or bug report: the update banner, install
// progress dialog, system-AI editor, audit-clear failures, etc.
// SelectableText alone works for keyboard users but the copy
// affordance has to be explicit so trackpad operators see it.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
class FaiErrorBox extends StatefulWidget {
/// The text the operator wants to read (and copy). Rendered
/// monospace, selectable, multi-line.
final String text;
/// When true, the box border + foreground colour come from the
/// error palette. False renders neutral chrome useful for
/// success / informational copy that still wants the copy
/// button.
final bool isError;
/// Optional max-height before the content scrolls inside the
/// box. Falls back to no constraint when null so short
/// messages don't get a useless scrollbar.
final double? maxHeight;
const FaiErrorBox({
super.key,
required this.text,
this.isError = false,
this.maxHeight,
});
@override
State<FaiErrorBox> createState() => _FaiErrorBoxState();
}
class _FaiErrorBoxState extends State<FaiErrorBox> {
bool _justCopied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.text));
if (!mounted) return;
setState(() => _justCopied = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _justCopied = false);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final accent = widget.isError
? theme.colorScheme.error
: theme.colorScheme.outlineVariant;
final fg = widget.isError
? theme.colorScheme.error
: theme.colorScheme.onSurface;
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(
FaiSpace.sm,
FaiSpace.xs,
FaiSpace.xs,
FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Copy button sits above the text on its own row so
// long messages never collide with it. Compact enough
// not to dominate short single-line messages.
Tooltip(
message: _justCopied ? l.buttonCopied : l.buttonCopy,
child: IconButton(
icon: Icon(
_justCopied ? Icons.check : Icons.content_copy,
size: 14,
),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: _copy,
),
),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: widget.maxHeight ?? double.infinity,
),
child: Scrollbar(
child: SingleChildScrollView(
child: SelectableText(
widget.text,
style: FaiTheme.mono(size: 11, color: fg),
),
),
),
),
],
),
);
}
}

View file

@ -10,6 +10,7 @@ import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'fai_error_box.dart';
import 'fai_pill.dart'; import 'fai_pill.dart';
import 'fai_system_ai_editor.dart'; import 'fai_system_ai_editor.dart';
@ -416,22 +417,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
), ),
if (_channelToast != null) ...[ if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Container( FaiErrorBox(text: _channelToast!, maxHeight: 200),
width: double.infinity,
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: SelectableText(
_channelToast!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
], ],
], ],
if (_aiStatus != null) ...[ if (_aiStatus != null) ...[

View file

@ -8,6 +8,7 @@ export 'fai_card.dart';
export 'fai_data_row.dart'; export 'fai_data_row.dart';
export 'fai_delta_mark.dart'; export 'fai_delta_mark.dart';
export 'fai_empty_state.dart'; export 'fai_empty_state.dart';
export 'fai_error_box.dart';
export 'fai_module_sheet.dart'; export 'fai_module_sheet.dart';
export 'fai_pill.dart'; export 'fai_pill.dart';
export 'fai_settings_dialog.dart'; export 'fai_settings_dialog.dart';

View file

@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "fai_studio"); gtk_header_bar_set_title(header_bar, "F\u2206I Studio");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "fai_studio"); gtk_window_set_title(window, "F\u2206I Studio");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View file

@ -13,7 +13,9 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>F∆I Studio</string>
<key>CFBundleDisplayName</key>
<string>F∆I Studio</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -8,6 +8,11 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true) self.setFrame(windowFrame, display: true)
// Override the window title so the OS task switcher and
// window chrome read "FI Studio" instead of the
// pubspec-derived "fai_studio".
self.title = "F∆I Studio"
RegisterGeneratedPlugins(registry: flutterViewController) RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib() super.awakeFromNib()

View file

@ -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.33.1 version: 0.34.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"fai_studio", origin, size)) { if (!window.Create(L"F\u2206I Studio", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);