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:
parent
22a851b7e2
commit
3b5fb027c3
17 changed files with 454 additions and 119 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
"buttonOpen": "Open",
|
||||
"buttonExplain": "Explain",
|
||||
"buttonReadDocs": "Read docs",
|
||||
"buttonCopy": "Copy to clipboard",
|
||||
"buttonCopied": "Copied",
|
||||
|
||||
"settingsTitle": "Hub endpoint",
|
||||
"settingsHost": "Host",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
113
lib/widgets/fai_error_box.dart
Normal file
113
lib/widgets/fai_error_box.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ...[
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "F∆I Studio" instead of the
|
||||
// pubspec-derived "fai_studio".
|
||||
self.title = "F∆I Studio"
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
super.awakeFromNib()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue