diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 64dde52..5ba47dc 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -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( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 18c8b71..0f32c3d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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..`-Capabilities.", + "addMcpIntro": "Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp..`-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..`-Capabilities.", + "addN8nIntro": "Aktive Workflows erscheinen im Store als `n8n..`-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..` Capabilities ein. Einmal konfigurieren, im Store sichtbar.", + "mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp..` 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..` Capabilities. Einmal konfigurieren, im Store sichtbar.", + "n8nHint": "Aktive n8n-Workflows erscheinen als `n8n..` Fähigkeiten. Einmal konfigurieren, im Store sichtbar.", "n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.", "hubUnreachable": "Hub nicht erreichbar", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5186dcf..9144c22 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,8 @@ "buttonOpen": "Open", "buttonExplain": "Explain", "buttonReadDocs": "Read docs", + "buttonCopy": "Copy to clipboard", + "buttonCopied": "Copied", "settingsTitle": "Hub endpoint", "settingsHost": "Host", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d5a1d54..43b8cc2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 234ed67..d9a471a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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..`-Capabilities.'; + 'Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp..`-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..`-Capabilities.'; + 'Aktive Workflows erscheinen im Store als `n8n..`-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..` Capabilities ein. Einmal konfigurieren, im Store sichtbar.'; + 'Externe MCP-Server bringen ihre Tools als `mcp..` 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..` Capabilities. Einmal konfigurieren, im Store sichtbar.'; + 'Aktive n8n-Workflows erscheinen als `n8n..` Fähigkeiten. Einmal konfigurieren, im Store sichtbar.'; @override String get n8nEmpty => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7632cae..92b2376 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/main.dart b/lib/main.dart index 73d57b4..b9715e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 79b2929..b607337 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -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), ], ], ), diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 913dc57..d435127 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -42,11 +42,24 @@ class _StorePageState extends State { /// 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 _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 [loaded]; + })(); + /// Carousel index. Wraps around modulo `_todayStories.length`. + int _todayIndex = 0; late Future> _future; // ── AI search state ────────────────────────────────────── @@ -151,11 +164,13 @@ class _StorePageState extends State { 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>( future: _future, builder: (ctx, snap) { @@ -186,7 +201,11 @@ class _StorePageState extends State { 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 { ], 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,24 +2698,70 @@ class _ScreenshotPlaceholder extends StatelessWidget { } } -/// Compiled-in fallback story shown when +/// Compiled-in fallback stories shown when /// `~/.fai/today/active.yaml` is absent or fails schema -/// validation. The pipeline operator can override at any time -/// without recompiling Studio — see docs/today-pipeline.md. -const TodayStoryData _kFallbackTodayStory = TodayStoryData( - badgeEn: 'PUBLIC SOURCES', - badgeDe: 'ÖFFENTLICHE QUELLEN', - titleEn: 'Fill your store in two clicks', - titleDe: 'Den Store in zwei Klicks auffüllen', - bodyEn: - 'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.', - bodyDe: - 'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.', - ctaLabelEn: 'Manage sources', - ctaLabelDe: 'Quellen verwalten', - icon: Icons.hub_outlined, - cta: TodayCta.openSettings, -); +/// 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 _kFallbackTodayStories = [ + TodayStoryData( + badgeEn: 'PUBLIC SOURCES', + badgeDe: 'ÖFFENTLICHE QUELLEN', + titleEn: 'Fill your store in two clicks', + titleDe: 'Den Store in zwei Klicks auffüllen', + bodyEn: + 'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.', + bodyDe: + 'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.', + ctaLabelEn: 'Manage sources', + ctaLabelDe: 'Quellen verwalten', + icon: Icons.hub_outlined, + cta: TodayCta.openSettings, + ), + TodayStoryData( + badgeEn: 'SANDBOX BY DEFAULT', + badgeDe: 'SANDBOX VON ANFANG AN', + titleEn: 'Every module declares what it can touch', + titleDe: 'Jedes Modul deklariert, worauf es zugreifen darf', + bodyEn: + 'F∆I modules ship with an explicit permission list — network endpoints, files, environment variables. The hub enforces it; nothing reaches outside the sandbox without the operator approving it. Click any installed module to see exactly which permissions it asked for.', + bodyDe: + 'F∆I-Module bringen eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox. Klicke ein installiertes Modul, um seine genauen Berechtigungen zu sehen.', + ctaLabelEn: '', + ctaLabelDe: '', + icon: Icons.shield_outlined, + cta: TodayCta.none, + ), + TodayStoryData( + badgeEn: 'TAMPER-EVIDENT AUDIT', + badgeDe: 'MANIPULATIONSSICHERES AUDIT', + titleEn: 'Every event leaves a hash-chained trace', + titleDe: 'Jedes Ereignis hinterlässt eine hash-verkettete Spur', + bodyEn: + 'Flow runs, install / uninstall actions, approval decisions — all of them write into a hash-chained audit log. The Doctor page verifies the chain end-to-end on every load; if a single byte gets touched, you see it. Audit-grade by construction, not by policy.', + bodyDe: + 'Flow-Läufe, Installationen und Deinstallationen, Freigabe-Entscheidungen — alles wird in ein hash-verkettetes Audit-Log geschrieben. Die Diagnose-Seite verifiziert die Kette bei jedem Laden komplett; ein verändertes Byte ist sofort sichtbar. Audit-tauglich qua Konstruktion, nicht qua Policy.', + ctaLabelEn: '', + ctaLabelDe: '', + icon: Icons.shield_outlined, + cta: TodayCta.none, + ), + TodayStoryData( + badgeEn: 'AIR-GAP READY', + badgeDe: 'AIR-GAP-TAUGLICH', + titleEn: 'One binary, no Docker, no external services', + titleDe: 'Ein Binary, kein Docker, keine externen Dienste', + bodyEn: + 'The whole hub fits in a single binary that runs on Linux, macOS and Windows. SQLite for state, no message broker, no background services to babysit. Once a module is installed, the flow it powers runs without further network access — perfect for regulated environments where outbound calls have to be justified.', + bodyDe: + 'Der gesamte Hub steckt in einem einzigen Binary für Linux, macOS und Windows. SQLite für den State, kein Message-Broker, keine Hintergrunddienste, die gepflegt werden müssen. Sobald ein Modul installiert ist, läuft der Flow, der es nutzt, ohne weiteren Netzwerkzugriff — ideal für regulierte Umgebungen, in denen jeder ausgehende Call begründet werden muss.', + ctaLabelEn: '', + ctaLabelDe: '', + icon: Icons.rocket_launch, + cta: TodayCta.none, + ), +]; /// Editorial hero strip — the first surface a browsing operator /// 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,11 +2946,62 @@ class _StoreTodayHero extends StatelessWidget { ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 16), - visualDensity: VisualDensity.compact, - tooltip: l.storeTodayDismissTooltip, - onPressed: onDismiss, + // 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, + ), + ], + ), + ], + ], ), ], ), diff --git a/lib/widgets/fai_error_box.dart b/lib/widgets/fai_error_box.dart new file mode 100644 index 0000000..1212702 --- /dev/null +++ b/lib/widgets/fai_error_box.dart @@ -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 createState() => _FaiErrorBoxState(); +} + +class _FaiErrorBoxState extends State { + bool _justCopied = false; + + Future _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), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 5e9531b..2a77262 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -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 { ), 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) ...[ diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 378302d..daa7145 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 13b81fb..f3bd58e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -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); diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..3d844e0 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -13,7 +13,9 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + F∆I Studio + CFBundleDisplayName + F∆I Studio CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb..3d56843 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -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() diff --git a/pubspec.yaml b/pubspec.yaml index 88aada3..e957f27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 7fcacfd..f7734db 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -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);