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,11 +2698,14 @@ 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
/// active item and the carousel collapses to a single slide.
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
TodayStoryData(
badgeEn: 'PUBLIC SOURCES', badgeEn: 'PUBLIC SOURCES',
badgeDe: 'ÖFFENTLICHE QUELLEN', badgeDe: 'ÖFFENTLICHE QUELLEN',
titleEn: 'Fill your store in two clicks', titleEn: 'Fill your store in two clicks',
@ -2676,7 +2718,50 @@ const TodayStoryData _kFallbackTodayStory = TodayStoryData(
ctaLabelDe: 'Quellen verwalten', ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined, icon: Icons.hub_outlined,
cta: TodayCta.openSettings, 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,12 +2946,63 @@ class _StoreTodayHero extends StatelessWidget {
], ],
), ),
), ),
// Right-edge column: dismiss + (when more than one
// slide exists) a carousel control row. Stacking them
// vertically keeps the hero's main row tidy whatever
// the slide count.
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton( IconButton(
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tooltip: l.storeTodayDismissTooltip, tooltip: l.storeTodayDismissTooltip,
onPressed: onDismiss, 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);