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
/// output. Used by Doctor's "Restart daemon" button.
static Future<({bool ok, String stdout, String stderr})> faiDaemon(

View file

@ -28,6 +28,8 @@
"buttonOpen": "Öffnen",
"buttonExplain": "Erklären",
"buttonReadDocs": "Doku öffnen",
"buttonCopy": "In Zwischenablage kopieren",
"buttonCopied": "Kopiert",
"settingsTitle": "Hub-Endpunkt",
"settingsHost": "Host",
@ -62,7 +64,7 @@
"n8nAddTooltip": "n8n-Endpunkt hinzufügen",
"n8nRemoveTooltip": "Endpunkt entfernen",
"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",
"addMcpNameLabel": "Name (keine Punkte)",
"addMcpNameHint": "filesystem",
@ -73,7 +75,7 @@
"addMcpNotesLabel": "Notizen (optional)",
"addMcpAddButton": "Hinzufügen + Discovery",
"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)",
"addN8nNameHint": "ops",
"addN8nBaseUrlLabel": "Basis-URL (ohne /api/v1)",
@ -102,7 +104,7 @@
"channelsEnableAutostart": "Autostart bei Anmeldung aktivieren",
"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.",
"storeAskSubmit": "Suchen",
"storeAskAi": "KI fragen",
@ -187,7 +189,7 @@
"storeOpenBrowserFailed": "Browser konnte nicht geöffnet werden: {error}",
"@storeOpenBrowserFailed": { "placeholders": { "error": { "type": "String" } } },
"storeSectionTags": "Tags",
"storeSectionRequiredCapabilities": "Erforderliche Capabilities",
"storeSectionRequiredCapabilities": "Erforderliche Fähigkeiten",
"storeSectionRequiredHostServices": "Erforderliche Host-Dienste",
"storeSectionScreenshots": "Screenshots",
"storeSectionDocumentation": "Dokumentation",
@ -221,19 +223,19 @@
"storeProvenanceN8n": "n8n · {provider}",
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
"storeRecommendedTitle": "Öffentliche Quelle hinzufügen — ein Klick",
"storeRecommendedBody": "Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.",
"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",
"storeRecommendedAdding": "Wird hinzugefügt…",
"storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)",
"storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen",
"storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Capability} other{föderierte Capabilities}}.",
"storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Fähigkeit} other{föderierte Fähigkeiten}}.",
"@storeProviderAddedToast": {
"placeholders": {
"name": { "type": "String" },
"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": {
"placeholders": { "name": { "type": "String" } }
},
@ -253,7 +255,7 @@
"auditFilterStep": "Step",
"auditFilterModule": "Modul",
"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": {
"placeholders": {
"n": { "type": "int" },
@ -276,7 +278,7 @@
"@auditLiveStatus": { "placeholders": { "n": { "type": "int" } } },
"auditDisconnected": "getrennt · {error}",
"@auditDisconnected": { "placeholders": { "error": { "type": "String" } } },
"auditHashChainVerified": "Hash-Chain verifiziert",
"auditHashChainVerified": "Hash-Kette verifiziert",
"auditDetailHeader": "DETAIL",
"auditAskingFull": "Frage an die konfigurierte System-AI läuft…",
"auditFixLabel": "FIX",
@ -308,7 +310,7 @@
"@modulesRecentActivityHint": { "placeholders": { "n": { "type": "int" } } },
"modulesActivityInstalled": "installiert",
"modulesActivityUninstalled": "deinstalliert",
"modulesCapabilitiesLabel": "Capabilities",
"modulesCapabilitiesLabel": "Fähigkeiten",
"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": {
@ -329,7 +331,7 @@
"modulesUninstalling": "Wird deinstalliert…",
"moduleSheetFailedToLoad": "Laden fehlgeschlagen: {error}",
"@moduleSheetFailedToLoad": { "placeholders": { "error": { "type": "String" } } },
"moduleSheetCapabilities": "Capabilities",
"moduleSheetCapabilities": "Fähigkeiten",
"moduleSheetPermissions": "Deklarierte Berechtigungen",
"moduleSheetNoPermissions": "(keine — reines Berechnungsmodul)",
@ -381,7 +383,7 @@
"approvalsRejectFailed": "Ablehnung fehlgeschlagen: {error}",
"@approvalsRejectFailed": { "placeholders": { "error": { "type": "String" } } },
"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",
"approvalsDialogCreated": "erstellt",
"approvalsDialogReason": "Begründung",
@ -396,10 +398,10 @@
"doctorSummaryApprovals": "Freigaben",
"doctorSummaryAudit": "Protokoll",
"doctorSummaryServices": "Dienste",
"doctorSummaryCapabilities": "{n} Capabilities",
"doctorSummaryCapabilities": "{n} Fähigkeiten",
"@doctorSummaryCapabilities": { "placeholders": { "n": { "type": "int" } } },
"doctorSummaryPending": "offen",
"doctorSummaryChain": "{verified}/{total} Chain",
"doctorSummaryChain": "{verified}/{total} Kette",
"@doctorSummaryChain": {
"placeholders": {
"verified": { "type": "int" },
@ -407,7 +409,7 @@
}
},
"doctorSummaryDeclared": "deklariert",
"doctorModulesPanelSummary": "{n} Module · {m} Capabilities",
"doctorModulesPanelSummary": "{n} Module · {m} Fähigkeiten",
"@doctorModulesPanelSummary": {
"placeholders": {
"n": { "type": "int" },
@ -428,7 +430,7 @@
"doctorHostServicesSection": "Host-Dienste",
"doctorDaemonFilesSection": "Daemon-Dateien",
"doctorDaemonControlSection": "Daemon-Steuerung",
"doctorChainIntact": "Hash-Chain intakt",
"doctorChainIntact": "Hash-Kette intakt",
"doctorChainIntactDetail": "{n} Ereignisse · prev_event_sha256 lückenlos verifiziert",
"@doctorChainIntactDetail": { "placeholders": { "n": { "type": "int" } } },
"doctorChainTampered": "Manipulation erkannt",
@ -491,10 +493,10 @@
"doctorApplyDone": "Update angewendet. Studio neu starten, um die neue Daemon-Version zu sehen.",
"mcpHeader": "MCP-CLIENTS",
"mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp.<server>.<tool>` Capabilities ein. Einmal konfigurieren, im Store sichtbar.",
"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.",
"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.",
"hubUnreachable": "Hub nicht erreichbar",

View file

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

View file

@ -242,6 +242,18 @@ abstract class AppLocalizations {
/// **'Read docs'**
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.
///
/// In en, this message translates to:

View file

@ -80,6 +80,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get buttonReadDocs => 'Doku öffnen';
@override
String get buttonCopy => 'In Zwischenablage kopieren';
@override
String get buttonCopied => 'Kopiert';
@override
String get settingsTitle => 'Hub-Endpunkt';
@ -193,7 +199,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get addMcpSuggestionsLabel => 'Vorschläge — zum Vorausfüllen klicken';
@ -227,7 +233,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get addN8nNameLabel => 'Name (keine Punkte)';
@ -304,7 +310,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get storeSearchHint =>
'Module suchen — Name, Tagline, Tag, Capability';
'Module suchen — Name, Tagline, Tag, Fähigkeit';
@override
String get storeAskHint =>
@ -502,7 +508,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeSectionTags => 'Tags';
@override
String get storeSectionRequiredCapabilities => 'Erforderliche Capabilities';
String get storeSectionRequiredCapabilities => 'Erforderliche Fähigkeiten';
@override
String get storeSectionRequiredHostServices => 'Erforderliche Host-Dienste';
@ -605,7 +611,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken';
@ -626,15 +632,15 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: 'föderierte Capabilities',
one: 'föderierte Capability',
other: 'föderierte Fähigkeiten',
one: 'föderierte Fähigkeit',
);
return '$name hinzugefügt — $n $_temp0.';
}
@override
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
@ -669,7 +675,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
@ -723,7 +729,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get auditHashChainVerified => 'Hash-Chain verifiziert';
String get auditHashChainVerified => 'Hash-Kette verifiziert';
@override
String get auditDetailHeader => 'DETAIL';
@ -802,7 +808,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get modulesActivityUninstalled => 'deinstalliert';
@override
String get modulesCapabilitiesLabel => 'Capabilities';
String get modulesCapabilitiesLabel => 'Fähigkeiten';
@override
String get modulesUninstallTitle => 'Modul deinstallieren?';
@ -831,7 +837,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get moduleSheetCapabilities => 'Capabilities';
String get moduleSheetCapabilities => 'Fähigkeiten';
@override
String get moduleSheetPermissions => 'Deklarierte Berechtigungen';
@ -951,7 +957,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get approvalsDialogDecided => 'entschieden';
@ -992,7 +998,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String doctorSummaryCapabilities(int n) {
return '$n Capabilities';
return '$n Fähigkeiten';
}
@override
@ -1000,7 +1006,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String doctorSummaryChain(int verified, int total) {
return '$verified/$total Chain';
return '$verified/$total Kette';
}
@override
@ -1008,7 +1014,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String doctorModulesPanelSummary(int n, int m) {
return '$n Module · $m Capabilities';
return '$n Module · $m Fähigkeiten';
}
@override
@ -1060,7 +1066,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get doctorDaemonControlSection => 'Daemon-Steuerung';
@override
String get doctorChainIntact => 'Hash-Chain intakt';
String get doctorChainIntact => 'Hash-Kette intakt';
@override
String doctorChainIntactDetail(int n) {
@ -1193,7 +1199,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get mcpEmpty =>
@ -1204,7 +1210,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get n8nEmpty =>

View file

@ -80,6 +80,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get buttonReadDocs => 'Read docs';
@override
String get buttonCopy => 'Copy to clipboard';
@override
String get buttonCopied => 'Copied';
@override
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
/// running app self-identifies visible in the sidebar header
/// 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 {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -334,13 +334,17 @@ class _DaemonPathsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final entries = <(String, String, IconData)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint),
// Per-row layout: (label, path, icon, isDirectory). The
// isDirectory bit drives `openOrReveal` so binary files
// (the SQLite audit DB, the PID file) reveal in Finder /
// Explorer instead of failing the OS "open" handler.
final entries = <(String, String, IconData, bool)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
(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();
if (entries.isEmpty) {
@ -356,7 +360,13 @@ class _DaemonPathsPanel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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 path;
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
Widget build(BuildContext context) {
@ -400,7 +416,10 @@ class _PathRow extends StatelessWidget {
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: () async {
final r = await SystemActions.openInOs(path);
final r = await SystemActions.openOrReveal(
path,
isDirectory: isDirectory,
);
if (!context.mounted) return;
if (!r.ok) {
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),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
@ -895,22 +917,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
),
if (_applyOutput != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
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,
),
),
),
FaiErrorBox(text: _applyOutput!, maxHeight: 240),
],
],
),

View file

@ -42,11 +42,24 @@ class _StorePageState extends State<StorePage> {
/// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// Loaded once at init from `~/.fai/today/active.yaml`; falls
/// back to the compiled-in story when the pipeline hasn't
/// produced an accepted candidate yet.
late final TodayStoryData _todayStory =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
/// The carousel of stories rendered in the hero. When an
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
/// it is the only entry (carousel collapses to a single
/// slide). Otherwise the curated fallback list rotates.
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;
// AI search state
@ -151,11 +164,13 @@ class _StorePageState extends State<StorePage> {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
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),
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>>(
future: _future,
builder: (ctx, snap) {
@ -186,7 +201,11 @@ class _StorePageState extends State<StorePage> {
tooltip: l.storeReloadTooltip,
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(
@ -287,11 +306,31 @@ class _StorePageState extends State<StorePage> {
],
if (showToday) ...[
_StoreTodayHero(
story: _todayStory,
story:
_todayStories[_todayIndex % _todayStories.length],
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: () =>
setState(() => _todayDismissed = true),
onCta: _todayStory.cta == TodayCta.openSettings
onCta: _todayStories[
_todayIndex % _todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
: null,
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
/// validation. The pipeline operator can override at any time
/// without recompiling Studio see docs/today-pipeline.md.
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
/// validation. Operator can override at any time without
/// recompiling Studio see docs/today-pipeline.md. When the
/// 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',
badgeDe: 'ÖFFENTLICHE QUELLEN',
titleEn: 'Fill your store in two clicks',
@ -2676,7 +2718,50 @@ const TodayStoryData _kFallbackTodayStory = TodayStoryData(
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
/// sees. Plays the role of Apple's Today tab: one curated
@ -2686,6 +2771,15 @@ const TodayStoryData _kFallbackTodayStory = TodayStoryData(
class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story;
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;
/// CTA action null hides the button. Wired by the parent
/// because navigation targets live in the store-page state,
@ -2703,6 +2797,10 @@ class _StoreTodayHero extends StatelessWidget {
const _StoreTodayHero({
required this.story,
required this.locale,
required this.currentIndex,
required this.totalCount,
required this.onPrev,
required this.onNext,
required this.onDismiss,
required this.onCta,
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(
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 '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_error_box.dart';
import 'fai_pill.dart';
import 'fai_system_ai_editor.dart';
@ -416,22 +417,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
Container(
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,
),
),
),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
if (_aiStatus != null) ...[

View file

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

View file

@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
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_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "fai_studio");
gtk_window_set_title(window, "F\u2206I Studio");
}
gtk_window_set_default_size(window, 1280, 720);

View file

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

View file

@ -8,6 +8,11 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController
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)
super.awakeFromNib()

View file

@ -1,7 +1,7 @@
name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.33.1
version: 0.34.0
environment:
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);
Win32Window::Point origin(10, 10);
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;
}
window.SetQuitOnClose(true);