From 34f2b7b3138f0dff9551bf8b91bdc40cfa655cef Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 11:17:58 +0200 Subject: [PATCH 1/7] feat(studio): typed flow outputs + on-disk docs + UX polish Documentation system (the original "whole docs system is broken" complaint): - Studio reads inline docs from disk via the new getInstalledModuleDocs RPC. No network, no auth, no provider lock-in. - store.dart probes for MODULE.md eagerly when a module-detail sheet opens (a single file stat). The Documentation section only renders when the bundle actually shipped docs. - Shared FaiTheme.markdownStyle helper used by Welcome's DocReaderSheet and Store's DocsPanel so every inline doc reads in the same typography. The shared style forces code.backgroundColor = transparent to suppress the per-span bands flutter_markdown's default code style draws on dark themes. Flow run dialog: - Run-button gating now strips the @version suffix from installed capabilities before the contains() check. Without this fix the button stayed disabled for every flow with dependencies, even after a successful install. - Studio derives a MIME type from the picked file's extension (small per-suffix map; .pdf, .docx, .txt, .json, ...) and forwards it to the hub. Fixes "unsupported MIME type: application/octet-stream" from text.extract. - Dialog title tracks the future's state: running -> "extract laeuft", success -> "extract -- Ergebnis", failure -> "extract -- fehlgeschlagen". - Output rendering moved from String stringification to a sealed FlowOutput type (Text / Json / Bytes / File / Unknown). A new FaiFlowOutput widget dispatches per variant: markdown for text/markdown (heuristic), pretty JSON for proto Struct, inline image preview + Save-As for bytes, Open for file URIs. Byte sizes: - New humanBytes() helper renders 23.3 kB / 1.04 MB style values with three significant digits, matching Finder / GNOME Files. Wired into flow-card pills, picked-file readouts, and the bytes-payload preview line. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/flow_output.dart | 85 +++++++++ lib/data/format.dart | 33 ++++ lib/data/hub.dart | 60 +++--- lib/l10n/app_de.arb | 15 ++ lib/l10n/app_en.arb | 15 ++ lib/l10n/app_localizations.dart | 60 ++++++ lib/l10n/app_localizations_de.dart | 40 ++++ lib/l10n/app_localizations_en.dart | 40 ++++ lib/pages/flows.dart | 140 +++++++++++--- lib/pages/store.dart | 292 ++++++----------------------- lib/pages/welcome.dart | 12 +- lib/theme/theme.dart | 64 +++++++ lib/widgets/fai_flow_output.dart | 288 ++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + 14 files changed, 849 insertions(+), 296 deletions(-) create mode 100644 lib/data/flow_output.dart create mode 100644 lib/data/format.dart create mode 100644 lib/widgets/fai_flow_output.dart diff --git a/lib/data/flow_output.dart b/lib/data/flow_output.dart new file mode 100644 index 0000000..e8d52ad --- /dev/null +++ b/lib/data/flow_output.dart @@ -0,0 +1,85 @@ +// FlowOutput — typed representation of one entry in +// `SubmitResponse.outputs`. Lets Studio render text vs. JSON vs. +// bytes vs. file with the right widget instead of stringifying +// everything into `` placeholders. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fai_dart_sdk/fai_dart_sdk.dart' show Payload; + +/// One named output of a flow run. Sealed via factory constructors +/// — callers pattern-match with `switch` on the runtime type to +/// pick a renderer. Variants: +/// +/// - [FlowOutputText] : plain text. `mimeType` may be set +/// (e.g. `text/markdown`) so the +/// renderer can pick a Markdown widget. +/// - [FlowOutputJson] : a proto Struct, already pretty- +/// printed as JSON so the UI can render +/// it directly. +/// - [FlowOutputBytes] : raw bytes + MIME. The renderer +/// decides whether to inline a preview +/// (e.g. images) and exposes a +/// Save-As affordance otherwise. +/// - [FlowOutputFile] : a URI the host already wrote to. The +/// renderer offers an Open action. +/// - [FlowOutputUnknown] : oneof was empty / unset. Never +/// expected in practice but kept so +/// the UI never panics on a future +/// schema bump. +sealed class FlowOutput { + const FlowOutput(); + + factory FlowOutput.fromPayload(Payload p) { + if (p.hasText()) { + return FlowOutputText(text: p.text); + } + if (p.hasJson()) { + // proto Struct → toProto3Json gives a Dart-side + // Map/List/primitive tree. We re-encode with an indent so + // the UI can show the operator-friendly form directly. + final tree = p.json.toProto3Json(); + const encoder = JsonEncoder.withIndent(' '); + return FlowOutputJson(pretty: encoder.convert(tree)); + } + if (p.hasBytes()) { + return FlowOutputBytes( + bytes: Uint8List.fromList(p.bytes.data), + mimeType: p.bytes.mimeType, + ); + } + if (p.hasFile()) { + return FlowOutputFile(uri: p.file.uri, mimeType: p.file.mimeType); + } + return const FlowOutputUnknown(); + } +} + +class FlowOutputText extends FlowOutput { + final String text; + const FlowOutputText({required this.text}); +} + +class FlowOutputJson extends FlowOutput { + /// Already indented JSON, ready for SelectableText / Markdown + /// code-block rendering. + final String pretty; + const FlowOutputJson({required this.pretty}); +} + +class FlowOutputBytes extends FlowOutput { + final Uint8List bytes; + final String mimeType; + const FlowOutputBytes({required this.bytes, required this.mimeType}); +} + +class FlowOutputFile extends FlowOutput { + final String uri; + final String mimeType; + const FlowOutputFile({required this.uri, required this.mimeType}); +} + +class FlowOutputUnknown extends FlowOutput { + const FlowOutputUnknown(); +} diff --git a/lib/data/format.dart b/lib/data/format.dart new file mode 100644 index 0000000..ac85b47 --- /dev/null +++ b/lib/data/format.dart @@ -0,0 +1,33 @@ +// Display-only formatters shared across pages. Pure functions, +// no I/O, no locale fall-through — locale-specific labels (the +// unit word itself, e.g. "Bytes" vs. "bytes") stay in `.arb`; +// these helpers only handle the numeric formatting. + +/// Render a byte count with an SI suffix that stays at three +/// significant digits ("23.3 kB", "1.04 MB", "847 B"). Uses 1000 +/// as the base, not 1024 — matches macOS Finder / GNOME Files +/// and is what most operators expect when they read "kB". +/// +/// Returns plain `n B` for values under 1000 so small flows +/// and tiny inputs stay precise instead of becoming "0.5 kB". +String humanBytes(int bytes) { + if (bytes < 1000) return '$bytes B'; + const units = ['kB', 'MB', 'GB', 'TB', 'PB']; + var v = bytes.toDouble() / 1000.0; + var unitIndex = 0; + while (v >= 1000 && unitIndex < units.length - 1) { + v /= 1000.0; + unitIndex++; + } + // Pick decimals so the rendered number stays at 3 significant + // figures: 9.99 / 99.9 / 999 — same approach Finder uses. + final String formatted; + if (v >= 100) { + formatted = v.toStringAsFixed(0); + } else if (v >= 10) { + formatted = v.toStringAsFixed(1); + } else { + formatted = v.toStringAsFixed(2); + } + return '$formatted ${units[unitIndex]}'; +} diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 61d4f92..0745dd2 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -11,6 +11,9 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'flow_output.dart'; +export 'flow_output.dart'; + class HubService { HubService._(); static final HubService instance = HubService._(); @@ -346,17 +349,26 @@ class HubService { return (name: r.name, version: r.version); } - /// Fetch a module's README markdown via the hub. Errors come - /// back as `errorKind` strings (not exceptions) so callers can - /// render fallback UI without try/catch ceremony. - Future<({String errorKind, String text, String sourceUrl})> - fetchModuleDocs(String name, {String locale = ''}) async { - final r = await _client.fetchModuleDocs(name, locale: locale); - return ( - errorKind: r.errorKind, - text: r.text, - sourceUrl: r.sourceUrl, - ); + /// Read the installed module's `MODULE.md` (or + /// `MODULE..md`) from disk under + /// `~/.fai/modules//`. Air-gap friendly, no network. + /// + /// `state` is one of: + /// - `'found'` — `text` holds the markdown + /// - `'no_docs'` — module installed but bundle had no docs + /// - `'not_installed'` — module isn't installed at all + Future<({String state, String text, String sourcePath})> + readInstalledModuleDocs(String name, {String locale = ''}) async { + final r = await _client.getInstalledModuleDocs(name, locale: locale); + final String state; + if (r.notInstalled) { + state = 'not_installed'; + } else if (r.text.isEmpty) { + state = 'no_docs'; + } else { + state = 'found'; + } + return (state: state, text: r.text, sourcePath: r.sourcePath); } /// Snapshot of every configured MCP server. @@ -560,35 +572,31 @@ class HubService { /// land as text Payloads, byte values as bytes Payloads — /// flows that mix both (e.g. extract taking a `document: /// bytes` plus a text-shaped option) flow through one RPC. - /// Returns the named outputs as a map of UI-friendly strings. - Future> runSavedFlow({ + /// [fileMimeTypes] is keyed the same as [fileInputs]; missing + /// entries fall back to `application/octet-stream`. Modules + /// often gate on MIME (the `text.extract` module refuses + /// octet-stream), so callers should provide a real type. + /// Returns the named outputs as a map of typed [FlowOutput] + /// values so each page can render text / JSON / bytes / file + /// with the right widget. + Future> runSavedFlow({ required String name, Map textInputs = const {}, Map fileInputs = const {}, + Map fileMimeTypes = const {}, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, fileInputs: fileInputs, + fileMimeTypes: fileMimeTypes, ); return { for (final entry in r.outputs.entries) - entry.key: _payloadToText(entry.value), + entry.key: FlowOutput.fromPayload(entry.value), }; } - String _payloadToText(Payload p) { - if (p.hasText()) return p.text; - if (p.hasJson()) return p.json.toString(); - if (p.hasBytes()) { - return ''; - } - if (p.hasFile()) { - return ''; - } - return ''; - } - Future> recentEvents({ int limit = 50, List types = const [], diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bbe24f4..5409e7e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -506,8 +506,23 @@ "flowsRunButton": "Starten", "flowsRunningTitle": "{name} läuft", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsResultTitle": "{name} — Ergebnis", + "@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsErrorTitle": "{name} — fehlgeschlagen", + "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow läuft…", "flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.", + "flowsOutputUnknown": "Unbekannter Output-Payload-Typ.", + "flowsOutputBytesUnknownMime": "Binärdaten", + "flowsOutputSaveAs": "Speichern unter…", + "flowsOutputSaveAsTitle": "Flow-Output speichern", + "flowsOutputSavedAt": "Gespeichert: {path}", + "@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } }, + "flowsOutputSaveFailed": "Speichern fehlgeschlagen: {error}", + "@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } }, + "flowsOutputOpen": "Öffnen", + "flowsOutputOpenFailed": "Öffnen fehlgeschlagen: {error}", + "@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } }, "approvalsTitle": "Freigaben", "approvalsTabPending": "Offen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3cc7081..da2c961 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,8 +507,23 @@ "flowsRunButton": "Run", "flowsRunningTitle": "Running {name}", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsResultTitle": "{name} — result", + "@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsErrorTitle": "{name} — failed", + "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow running…", "flowsNoOutputs": "Flow completed with no declared outputs.", + "flowsOutputUnknown": "Output payload variant not recognised.", + "flowsOutputBytesUnknownMime": "binary", + "flowsOutputSaveAs": "Save as…", + "flowsOutputSaveAsTitle": "Save flow output", + "flowsOutputSavedAt": "Saved to {path}", + "@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } }, + "flowsOutputSaveFailed": "Save failed: {error}", + "@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } }, + "flowsOutputOpen": "Open", + "flowsOutputOpenFailed": "Open failed: {error}", + "@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } }, "approvalsTitle": "Approvals", "approvalsTabPending": "Pending", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3c9c664..b733d6b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2282,6 +2282,18 @@ abstract class AppLocalizations { /// **'Running {name}'** String flowsRunningTitle(String name); + /// No description provided for @flowsResultTitle. + /// + /// In en, this message translates to: + /// **'{name} — result'** + String flowsResultTitle(String name); + + /// No description provided for @flowsErrorTitle. + /// + /// In en, this message translates to: + /// **'{name} — failed'** + String flowsErrorTitle(String name); + /// No description provided for @flowsRunning. /// /// In en, this message translates to: @@ -2294,6 +2306,54 @@ abstract class AppLocalizations { /// **'Flow completed with no declared outputs.'** String get flowsNoOutputs; + /// No description provided for @flowsOutputUnknown. + /// + /// In en, this message translates to: + /// **'Output payload variant not recognised.'** + String get flowsOutputUnknown; + + /// No description provided for @flowsOutputBytesUnknownMime. + /// + /// In en, this message translates to: + /// **'binary'** + String get flowsOutputBytesUnknownMime; + + /// No description provided for @flowsOutputSaveAs. + /// + /// In en, this message translates to: + /// **'Save as…'** + String get flowsOutputSaveAs; + + /// No description provided for @flowsOutputSaveAsTitle. + /// + /// In en, this message translates to: + /// **'Save flow output'** + String get flowsOutputSaveAsTitle; + + /// No description provided for @flowsOutputSavedAt. + /// + /// In en, this message translates to: + /// **'Saved to {path}'** + String flowsOutputSavedAt(String path); + + /// No description provided for @flowsOutputSaveFailed. + /// + /// In en, this message translates to: + /// **'Save failed: {error}'** + String flowsOutputSaveFailed(String error); + + /// No description provided for @flowsOutputOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get flowsOutputOpen; + + /// No description provided for @flowsOutputOpenFailed. + /// + /// In en, this message translates to: + /// **'Open failed: {error}'** + String flowsOutputOpenFailed(String error); + /// No description provided for @approvalsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b60a03f..1493743 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1308,12 +1308,52 @@ class AppLocalizationsDe extends AppLocalizations { return '$name läuft'; } + @override + String flowsResultTitle(String name) { + return '$name — Ergebnis'; + } + + @override + String flowsErrorTitle(String name) { + return '$name — fehlgeschlagen'; + } + @override String get flowsRunning => 'Flow läuft…'; @override String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.'; + @override + String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.'; + + @override + String get flowsOutputBytesUnknownMime => 'Binärdaten'; + + @override + String get flowsOutputSaveAs => 'Speichern unter…'; + + @override + String get flowsOutputSaveAsTitle => 'Flow-Output speichern'; + + @override + String flowsOutputSavedAt(String path) { + return 'Gespeichert: $path'; + } + + @override + String flowsOutputSaveFailed(String error) { + return 'Speichern fehlgeschlagen: $error'; + } + + @override + String get flowsOutputOpen => 'Öffnen'; + + @override + String flowsOutputOpenFailed(String error) { + return 'Öffnen fehlgeschlagen: $error'; + } + @override String get approvalsTitle => 'Freigaben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 612165f..b9523e3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1321,12 +1321,52 @@ class AppLocalizationsEn extends AppLocalizations { return 'Running $name'; } + @override + String flowsResultTitle(String name) { + return '$name — result'; + } + + @override + String flowsErrorTitle(String name) { + return '$name — failed'; + } + @override String get flowsRunning => 'Flow running…'; @override String get flowsNoOutputs => 'Flow completed with no declared outputs.'; + @override + String get flowsOutputUnknown => 'Output payload variant not recognised.'; + + @override + String get flowsOutputBytesUnknownMime => 'binary'; + + @override + String get flowsOutputSaveAs => 'Save as…'; + + @override + String get flowsOutputSaveAsTitle => 'Save flow output'; + + @override + String flowsOutputSavedAt(String path) { + return 'Saved to $path'; + } + + @override + String flowsOutputSaveFailed(String error) { + return 'Save failed: $error'; + } + + @override + String get flowsOutputOpen => 'Open'; + + @override + String flowsOutputOpenFailed(String error) { + return 'Open failed: $error'; + } + @override String get approvalsTitle => 'Approvals'; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 34829fc..8d4246a 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -5,6 +5,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../data/format.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; @@ -40,6 +41,18 @@ class _FlowsPageState extends State { flows: results[0] as List, installedCapabilities: (results[1] as List) .expand((m) => m.capabilities) + // `listModules()` emits capability strings with a + // `@version` suffix (e.g. `text.extract@0.1.0`). The + // flow's `requiredCapabilities` carry their own + // user-typed version constraint, so we compare on the + // bare capability name and let the hub do the strict + // version match at run time. Without this strip, the + // contains-check would never hit and the Run button + // would stay disabled even after a successful install. + .map((c) { + final at = c.indexOf('@'); + return at >= 0 ? c.substring(0, at) : c; + }) .toSet(), ); } @@ -74,6 +87,7 @@ class _FlowsPageState extends State { flow: flow, textInputs: inputs.textInputs, fileInputs: inputs.fileInputs, + fileMimeTypes: inputs.fileMimeTypes, ), ); } @@ -254,7 +268,7 @@ class _FlowCard extends StatelessWidget { ), ), FaiPill( - label: '${flow.sizeBytes} B', + label: humanBytes(flow.sizeBytes), tone: FaiPillTone.neutral, monospace: true, ), @@ -369,16 +383,75 @@ class _ClickablePill extends StatelessWidget { class _FlowRunInputs { final Map textInputs; final Map fileInputs; + /// Parallel to [fileInputs]; same keys. Modules like + /// `text.extract` reject `application/octet-stream`, so we + /// derive a real MIME type from the picked file's extension + /// and forward it to the hub. + final Map fileMimeTypes; const _FlowRunInputs({ required this.textInputs, required this.fileInputs, + required this.fileMimeTypes, }); } class _PickedFile { final String name; final Uint8List bytes; - const _PickedFile({required this.name, required this.bytes}); + /// MIME derived from the file extension at pick time. Empty + /// when the extension is unknown — the SDK then falls back to + /// `application/octet-stream`. + final String mimeType; + const _PickedFile({ + required this.name, + required this.bytes, + required this.mimeType, + }); +} + +/// Minimal extension → MIME map covering the file types that +/// shipped modules actually accept (PDF, DOCX for text.extract; +/// plain text, json, csv for upcoming modules). Keeping it tiny +/// avoids pulling the `mime` package as a new dependency. Returns +/// empty string for unknown extensions so the SDK default +/// (`application/octet-stream`) still applies. +String _mimeForFilename(String filename) { + final dot = filename.lastIndexOf('.'); + if (dot < 0 || dot == filename.length - 1) return ''; + final ext = filename.substring(dot + 1).toLowerCase(); + switch (ext) { + case 'pdf': + return 'application/pdf'; + case 'docx': + return 'application/vnd.openxmlformats-officedocument' + '.wordprocessingml.document'; + case 'doc': + return 'application/msword'; + case 'txt': + return 'text/plain'; + case 'md': + case 'markdown': + return 'text/markdown'; + case 'json': + return 'application/json'; + case 'csv': + return 'text/csv'; + case 'xml': + return 'application/xml'; + case 'yaml': + case 'yml': + return 'application/yaml'; + case 'html': + case 'htm': + return 'text/html'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return ''; + } } /// Run-flow input dialog. Fetches the flow's declared input @@ -457,16 +530,26 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { _FlowRunInputs _collect(List defs) { final textInputs = {}; final fileInputs = {}; + final fileMimeTypes = {}; for (final d in defs) { if (_isBytesType(d.type)) { final picked = _pickedFiles[d.name]; - if (picked != null) fileInputs[d.name] = picked.bytes; + if (picked != null) { + fileInputs[d.name] = picked.bytes; + if (picked.mimeType.isNotEmpty) { + fileMimeTypes[d.name] = picked.mimeType; + } + } } else { final c = _textControllers[d.name]; if (c != null) textInputs[d.name] = c.text; } } - return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs); + return _FlowRunInputs( + textInputs: textInputs, + fileInputs: fileInputs, + fileMimeTypes: fileMimeTypes, + ); } Future _pickFile(String inputName) async { @@ -490,7 +573,11 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { } if (bytes == null || !mounted) return; setState(() { - _pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!); + _pickedFiles[inputName] = _PickedFile( + name: f.name, + bytes: bytes!, + mimeType: _mimeForFilename(f.name), + ); }); } @@ -676,7 +763,7 @@ class _InputField extends StatelessWidget { pickedFile == null ? l.flowsTypeBytesNoFile : '${pickedFile!.name} · ' - '${l.flowsTypeBytesSize(pickedFile!.bytes.length)}', + '${humanBytes(pickedFile!.bytes.length)}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -702,11 +789,13 @@ class _FlowRunDialog extends StatefulWidget { final SavedFlow flow; final Map textInputs; final Map fileInputs; + final Map fileMimeTypes; const _FlowRunDialog({ required this.flow, required this.textInputs, required this.fileInputs, + required this.fileMimeTypes, }); @override @@ -714,7 +803,7 @@ class _FlowRunDialog extends StatefulWidget { } class _FlowRunDialogState extends State<_FlowRunDialog> { - late final Future> _future; + late final Future> _future; @override void initState() { @@ -723,6 +812,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { name: widget.flow.name, textInputs: widget.textInputs, fileInputs: widget.fileInputs, + fileMimeTypes: widget.fileMimeTypes, ); } @@ -731,13 +821,29 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( - title: Text(l.flowsRunningTitle(widget.flow.name)), + // Title tracks the future's state so the dialog header + // stops claiming "extract läuft" once the run is over. + // Recomputed on every snapshot change via FutureBuilder. + title: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + final String title; + if (snapshot.connectionState == ConnectionState.waiting) { + title = l.flowsRunningTitle(widget.flow.name); + } else if (snapshot.hasError) { + title = l.flowsErrorTitle(widget.flow.name); + } else { + title = l.flowsResultTitle(widget.flow.name); + } + return Text(title); + }, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), - child: FutureBuilder>( + child: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -812,21 +918,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { ), ), ), - Container( - width: double.infinity, - padding: const EdgeInsets.all(FaiSpace.md), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), - ), - child: SelectableText( - entry.value, - style: FaiTheme.mono(size: 11), - ), - ), + FaiFlowOutput(output: entry.value), const SizedBox(height: FaiSpace.md), ], ], diff --git a/lib/pages/store.dart b/lib/pages/store.dart index b28a236..59b4868 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1823,8 +1823,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { String get _locale => Localizations.localeOf(context).languageCode; bool _busy = false; String? _toast; - Future<({String errorKind, String text, String sourceUrl})>? _docsFuture; - bool _docsLoaded = false; + /// `null` while the initial probe is in flight; non-null once + /// `readInstalledModuleDocs` has returned. The probe is cheap — + /// a single file stat on disk — so we run it eagerly when the + /// sheet opens for an installed module instead of gating it + /// behind a "Load docs" click. + ({String state, String text, String sourcePath})? _docsResult; + bool _docsProbed = false; /// Live module-info for installed entries: declared /// permissions and on-disk directory. Pulled lazily so /// not-installed entries don't run an unnecessary RPC. Stays @@ -1840,6 +1845,32 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Locale is read off `Localizations`, which is only ready in + // didChangeDependencies, not initState. + if (widget.item.installed && !_docsProbed) { + _docsProbed = true; + _probeInstalledDocs(); + } + } + + Future _probeInstalledDocs() async { + final locale = Localizations.localeOf(context).languageCode; + try { + final r = await HubService.instance + .readInstalledModuleDocs(widget.item.name, locale: locale); + if (!mounted) return; + setState(() => _docsResult = r); + } catch (_) { + // Treat probe failures as "no docs available" — the section + // simply hides, the rest of the sheet still works. + if (!mounted) return; + setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: '')); + } + } + Future _loadModuleDetail() async { try { final detail = @@ -1934,19 +1965,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { } } - void _loadDocs() { - if (_docsLoaded) return; - // Sprache vom aktuellen Studio-Locale ans Hub weitergeben, - // damit `README.de.md` zuerst probiert wird wenn Studio auf - // Deutsch steht. - final locale = Localizations.localeOf(context).languageCode; - setState(() { - _docsLoaded = true; - _docsFuture = - HubService.instance.fetchModuleDocs(widget.item.name, locale: locale); - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -2191,15 +2209,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ), const SizedBox(height: FaiSpace.lg), ], - if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[ + if (_docsResult?.state == 'found') ...[ _SectionHeader(l.storeSectionDocumentation), const SizedBox(height: FaiSpace.sm), - _DocsPanel( - future: _docsFuture, - onLoad: _loadDocs, - onOpenRepo: _openRepository, - ), + _DocsPanel(text: _docsResult!.text), const SizedBox(height: FaiSpace.lg), + ], + if (item.repository.isNotEmpty) ...[ _SectionHeader(l.storeSectionSource), const SizedBox(height: FaiSpace.sm), InkWell( @@ -2242,12 +2258,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ], Row( children: [ - if (item.repository.isNotEmpty) - OutlinedButton.icon( - onPressed: _openRepository, - icon: const Icon(Icons.menu_book_outlined, size: 16), - label: Text(l.buttonReadDocs), - ), const Spacer(), if (item.installed) ...[ OutlinedButton.icon( @@ -2462,210 +2472,40 @@ IconData _iconForCategory(String category) { } } -/// Lazy-loaded inline README renderer. Shows a "Load -/// documentation" button until the operator clicks it (so we -/// don't blow the network unprompted), then a markdown view of -/// the result. Auth / not-found errors come back with friendly -/// fix-hint copy and a fallback "View on repository" button. +/// Renders the module's inline `MODULE.md` (or its localized +/// sibling) in the same theme as the rest of Studio. The markdown +/// body is supplied by the parent — `_DocsPanel` itself stays a +/// pure renderer with no async / no error handling. +/// +/// Blockquote + table-cell decoration is overridden because +/// flutter_markdown's default styles hardcode +/// `Colors.blue.shade100`, which is unreadable on a dark theme. class _DocsPanel extends StatelessWidget { - final Future<({String errorKind, String text, String sourceUrl})>? future; - final VoidCallback onLoad; - final VoidCallback onOpenRepo; + final String text; - const _DocsPanel({ - required this.future, - required this.onLoad, - required this.onOpenRepo, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - if (future == null) { - // Row im Modul-Detail-Sheet kann eng werden (~340 dp). - // Hint-Text wird in Expanded gewrappt damit er bricht - // statt zu overflowen (CrossAxisAlignment.center hält die - // Optik mit dem Button auf einer Höhe). - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: onLoad, - icon: const Icon(Icons.menu_book_outlined, size: 16), - label: Text(l.storeLoadDocs), - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - l.storeDocsHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ); - } - return FutureBuilder<({String errorKind, String text, String sourceUrl})>( - future: future, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), - child: Row( - children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: FaiSpace.sm), - Text(l.storeDocsFetching), - ], - ), - ); - } - if (snap.hasError) { - return _DocsErrorPanel( - message: snap.error.toString(), - onOpenRepo: onOpenRepo, - ); - } - final r = snap.data!; - if (r.errorKind.isNotEmpty) { - return _DocsErrorPanel( - message: _friendlyDocsError(context, r.errorKind, r.text), - onOpenRepo: onOpenRepo, - sourceUrl: r.sourceUrl, - ); - } - return Container( - width: double.infinity, - constraints: const BoxConstraints(maxHeight: 480), - padding: const EdgeInsets.all(FaiSpace.md), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Markdown( - data: r.text, - shrinkWrap: true, - selectable: true, - onTapLink: (text, href, title) async { - if (href == null || href.isEmpty) return; - await SystemActions.openInOs(href); - }, - styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: theme.textTheme.bodyMedium, - code: FaiTheme.mono( - size: 12, - color: theme.colorScheme.onSurface, - ), - codeblockDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), - // flutter_markdown default-blockquote nutzt - // hardcodiertes `Colors.blue.shade100` als - // Background, was im Dark-Theme mit weißem Text - // unlesbar wird. Wir setzen Theme-konsistente - // Werte: surfaceContainer-Background + linke - // Akzent-Border, Text in onSurface. - blockquote: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, - ), - blockquoteDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 3, - ), - ), - ), - blockquotePadding: const EdgeInsets.fromLTRB( - FaiSpace.md, - FaiSpace.sm, - FaiSpace.sm, - FaiSpace.sm, - ), - // Tabellen-Cells haben dasselbe Default-Problem - // (Material-2-Light-Annahme). Theme-Werte erzwingen. - tableCellsDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - ), - ), - ), - ); - }, - ); - } -} - -class _DocsErrorPanel extends StatelessWidget { - final String message; - final VoidCallback onOpenRepo; - final String? sourceUrl; - - const _DocsErrorPanel({ - required this.message, - required this.onOpenRepo, - this.sourceUrl, - }); + const _DocsPanel({required this.text}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: double.infinity, + constraints: const BoxConstraints(maxHeight: 480), padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - message, - style: theme.textTheme.bodySmall, - ), - ), - ], - ), - if (sourceUrl != null && sourceUrl!.isNotEmpty) ...[ - const SizedBox(height: 4), - SelectableText( - sourceUrl!, - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: FaiSpace.sm), - OutlinedButton.icon( - onPressed: onOpenRepo, - icon: const Icon(Icons.open_in_new, size: 14), - label: Text(AppLocalizations.of(context)!.storeOpenRepoButton), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], + child: Markdown( + data: text, + shrinkWrap: true, + selectable: true, + onTapLink: (text, href, title) async { + if (href == null || href.isEmpty) return; + await SystemActions.openInOs(href); + }, + styleSheet: FaiTheme.markdownStyle(theme), ), ); } @@ -2697,24 +2537,6 @@ int _versionCmp(String a, String b) { return 0; } -String _friendlyDocsError(BuildContext context, String kind, String detail) { - final l = AppLocalizations.of(context)!; - switch (kind) { - case 'not_found': - return l.storeDocsErrorNotFound; - case 'no_docs': - return l.storeDocsErrorNoDocs; - case 'auth': - return l.storeDocsErrorAuth; - case 'network': - return l.storeDocsErrorNetwork(detail); - case 'parse': - return l.storeDocsErrorParse; - default: - return detail.isEmpty ? l.storeDocsErrorGeneric : detail; - } -} - /// Module icon: renders the explicit icon URL when set, falls /// back to the category-derived `Icons.X` glyph in a colored /// circle when the URL is empty or fails to load. Wraps the diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index db2d6a3..ce8a7a1 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1058,17 +1058,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { await SystemActions.openInOs(href); } }, - styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: theme.textTheme.bodyMedium?.copyWith(height: 1.5), - code: FaiTheme.mono( - size: 12, - color: theme.colorScheme.onSurface, - ), - codeblockDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), - ), + styleSheet: FaiTheme.markdownStyle(theme), ); }, ), diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index ed68f2b..3546db4 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,6 +1,7 @@ // Theme assembly. ColorScheme + typography + component themes. import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; @@ -77,6 +78,69 @@ class FaiTheme { height: 1.4, ); + /// Shared markdown style used by every inline doc renderer + /// (Welcome DocReaderSheet, Store module-detail DocsPanel, + /// future help surfaces). Centralising it means every doc + /// reads in the same typography, with the same dark-theme + /// safe colors. + /// + /// Why every property is set explicitly even when it just + /// re-states the default: + /// + /// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)` + /// sets `code` with `backgroundColor: theme.cardTheme.color`. + /// That per-span backgroundColor is rendered behind every + /// character inside a fenced code block, which on a dark + /// theme shows up as horizontal bands behind ASCII diagrams. + /// We force `backgroundColor: Colors.transparent` to suppress + /// it — the codeblock's own decoration provides the panel + /// background. + /// - `blockquote` defaults to a hardcoded `Colors.blue.shade100` + /// background that is unreadable in dark mode. We replace it + /// with theme-aware tokens (surfaceContainer + left accent + /// border). + /// - `tableCellsDecoration` has the same dark-mode bug, fixed + /// the same way. + static MarkdownStyleSheet markdownStyle(ThemeData theme) { + return MarkdownStyleSheet.fromTheme(theme).copyWith( + p: theme.textTheme.bodyMedium?.copyWith(height: 1.5), + code: mono( + size: 12, + color: theme.colorScheme.onSurface, + ).copyWith( + backgroundColor: Colors.transparent, + ), + codeblockDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + codeblockPadding: const EdgeInsets.all(FaiSpace.md), + blockquote: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + blockquoteDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), + ), + blockquotePadding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.sm, + FaiSpace.sm, + FaiSpace.sm, + ), + tableCellsDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + ), + ); + } + static ThemeData dark() { final scheme = ColorScheme( brightness: Brightness.dark, diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart new file mode 100644 index 0000000..1f15666 --- /dev/null +++ b/lib/widgets/fai_flow_output.dart @@ -0,0 +1,288 @@ +// FaiFlowOutput — type-aware renderer for one entry in +// `runSavedFlow`'s output map. Reads `FlowOutput` and dispatches +// to the right widget: +// +// - text: SelectableText for plain text; Markdown widget +// when the (hinted) MIME is text/markdown. +// - json: indented JSON in a code-block container. +// - bytes: inline image thumbnail when image/*, otherwise a +// "{mime}, {size}" summary. Always offers Save-as. +// - file: "Open" button → SystemActions.openInOs(uri). +// +// Stateless on purpose: the dialog wraps it; we never own a +// future or a controller here. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import '../data/flow_output.dart'; +import '../data/format.dart'; +import '../data/system_actions.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; + +class FaiFlowOutput extends StatelessWidget { + final FlowOutput output; + + const FaiFlowOutput({super.key, required this.output}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + switch (output) { + case FlowOutputText(:final text): + return _TextView(text: text); + case FlowOutputJson(:final pretty): + return _CodeBlock(text: pretty, theme: theme); + case FlowOutputBytes(:final bytes, :final mimeType): + return _BytesView(bytes: bytes, mimeType: mimeType); + case FlowOutputFile(:final uri, :final mimeType): + return _FileView(uri: uri, mimeType: mimeType); + case FlowOutputUnknown(): + return _CodeBlock( + text: AppLocalizations.of(context)!.flowsOutputUnknown, + theme: theme, + ); + } + } +} + +/// Plain-text renderer. Auto-detects markdown via a cheap +/// heuristic (leading `#` heading, fenced code block, or +/// numbered/bulleted list) so flows that emit text/markdown +/// without setting an explicit MIME still render formatted. +class _TextView extends StatelessWidget { + final String text; + + const _TextView({required this.text}); + + static final RegExp _markdownHeuristic = RegExp( + r'(^|\n)(#{1,6}\s|```|\*\s|-\s|\d+\.\s)', + ); + + bool get _looksLikeMarkdown => + text.length < 64 * 1024 && _markdownHeuristic.hasMatch(text); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_looksLikeMarkdown) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: MarkdownBody( + data: text, + selectable: true, + onTapLink: (_, href, _) async { + if (href != null && href.isNotEmpty) { + await SystemActions.openInOs(href); + } + }, + styleSheet: FaiTheme.markdownStyle(theme), + ), + ); + } + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + text, + style: theme.textTheme.bodyMedium?.copyWith(height: 1.5), + ), + ); + } +} + +class _CodeBlock extends StatelessWidget { + final String text; + final ThemeData theme; + + const _CodeBlock({required this.text, required this.theme}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + text, + style: FaiTheme.mono( + size: 12, + color: theme.colorScheme.onSurface, + ), + ), + ); + } +} + +class _BytesView extends StatelessWidget { + final Uint8List bytes; + final String mimeType; + + const _BytesView({required this.bytes, required this.mimeType}); + + bool get _isImage => mimeType.startsWith('image/'); + + /// Reasonable default filename for the Save dialog. Strips the + /// MIME up to the slash and uses the suffix when known + /// (image/png → output.png), otherwise a generic `.bin`. + String _defaultFilename() { + final slash = mimeType.indexOf('/'); + if (slash < 0 || slash == mimeType.length - 1) return 'output.bin'; + final sub = mimeType.substring(slash + 1); + // Strip common parameter suffixes (`png; charset=...`). + final semi = sub.indexOf(';'); + final ext = semi < 0 ? sub : sub.substring(0, semi); + // `vnd.openxmlformats-…wordprocessingml.document` → docx. + if (ext.contains('wordprocessingml')) return 'output.docx'; + if (ext.contains('spreadsheetml')) return 'output.xlsx'; + if (ext.contains('presentationml')) return 'output.pptx'; + return 'output.$ext'; + } + + Future _save(BuildContext context) async { + final l = AppLocalizations.of(context)!; + final path = await FilePicker.platform.saveFile( + dialogTitle: l.flowsOutputSaveAsTitle, + fileName: _defaultFilename(), + ); + if (path == null) return; + try { + await File(path).writeAsBytes(bytes, flush: true); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsOutputSavedAt(path))), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isImage) ...[ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ClipRRect( + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + const SizedBox(height: FaiSpace.sm), + ], + Text( + '${mimeType.isEmpty ? l.flowsOutputBytesUnknownMime : mimeType}' + ' · ${humanBytes(bytes.length)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), + OutlinedButton.icon( + icon: const Icon(Icons.save_alt, size: 16), + label: Text(l.flowsOutputSaveAs), + onPressed: () => _save(context), + ), + ], + ), + ); + } +} + +class _FileView extends StatelessWidget { + final String uri; + final String mimeType; + + const _FileView({required this.uri, required this.mimeType}); + + Future _open(BuildContext context) async { + // Most file-URIs come from modules that wrote a temp/output + // path on the host. SystemActions normalises `file://` for + // open-in-OS on all three platforms. + final target = uri.startsWith('file://') ? uri.substring(7) : uri; + final r = await SystemActions.openInOs(target); + if (!context.mounted || r.ok) return; + final l = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + uri, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ), + ), + if (mimeType.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + mimeType, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: FaiSpace.sm), + OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.flowsOutputOpen), + onPressed: () => _open(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index daa7145..22dc088 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -9,6 +9,7 @@ export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; export 'fai_empty_state.dart'; export 'fai_error_box.dart'; +export 'fai_flow_output.dart'; export 'fai_module_sheet.dart'; export 'fai_pill.dart'; export 'fai_settings_dialog.dart'; From c11461b0f99a55cf28ec4e641d4c415a97d68d92 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 12:36:14 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(studio):=20trust=20pass=20=E2=80=94=20?= =?UTF-8?q?friendly=20errors=20+=20store=20clarity=20+=20MCP=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the May-2026 trust pass. Drops the wall of gRPC trailers from every error surface and makes the Store honest about what is and isn't installable. Friendly errors: - New `friendlyError(Object, AppLocalizations)` mapper turns GrpcError + arbitrary throwables into a localised headline, optional recovery hint, and a verbatim detail string kept behind a "Show details" expander. Duck-typed on `.code` / `.message` so Studio doesn't have to depend on package:grpc directly. - `FaiErrorBox` gains an `error:` constructor that runs the mapper. Every call site that used to render `snap.error.toString()` (flows, welcome, store) switches to it. - 9 .arb entries per locale cover the gRPC codes we actually emit (INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, FAILED_PRECONDITION, INTERNAL, UNAVAILABLE, UNAUTHENTICATED) plus copy/details affordances. - `test/friendly_error_test.dart` — 6 unit tests for the mapper. Covers the mapping table, locale-switching, and the non-gRPC fallback so future regressions show up in CI. Capability discovery: - New `HubService.allCapabilities()` reads the kind-aware capability list (wasm + builtin + federated) and returns a Dart-side `CapabilityInfo` value type. The flow page's missing-dependency check uses it so `system.approval` and federated MCP/n8n tools count as "available" — fixes the Run button staying disabled forever. - `HubService.listModules()` filters to kind=wasm so the Modules page doesn't sprout synthetic "system" entries that the operator can't uninstall. Store clarity: - New "Installable only" filter, on by default. Roughly 2/3 of seed entries currently carry `status: planned`; the default view stops being noise. - Featured-strip cards for planned modules now show a "Coming soon" pill instead of an empty action area. - Main-grid cards for non-installable modules dim to 60% opacity so the eye lands on actionable cards first. - Detail-sheet "Nicht installierbar" tooltip → inline hint box. The reason is visible without hovering. MCP localisation: - `_kMcpSuggestions` no longer holds 11 hardcoded English description strings. The `description` field is replaced with a `resolveDescription(AppLocalizations)` lookup that switches on the suggestion `name` to read the matching `mcpSuggestion*Desc` .arb key. EN + DE shipped. - New `FaiEnBadge` widget renders a small `[EN]` pill when the active locale isn't English. Used next to MCP / federated store entries' tagline + description because the server supplies them in English and we can't translate on the fly yet — the badge is the honest signal until the planned `studio.translate` plugin lands. Plus housekeeping: removed the unused `_keepImport` lint escape in the test and the dangling library doc-comment in `format.dart`. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/friendly_error.dart | 148 +++++++++++++++++++ lib/data/hub.dart | 58 +++++++- lib/l10n/app_de.arb | 38 +++++ lib/l10n/app_en.arb | 38 +++++ lib/l10n/app_localizations.dart | 210 +++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 126 ++++++++++++++++ lib/l10n/app_localizations_en.dart | 125 ++++++++++++++++ lib/pages/flows.dart | 51 +++---- lib/pages/store.dart | 172 +++++++++++++++++++--- lib/pages/welcome.dart | 2 +- lib/widgets/fai_en_badge.dart | 61 ++++++++ lib/widgets/fai_error_box.dart | 139 ++++++++++++++---- lib/widgets/fai_settings_dialog.dart | 70 ++++++--- lib/widgets/widgets.dart | 1 + test/friendly_error_test.dart | 84 +++++++++++ 15 files changed, 1219 insertions(+), 104 deletions(-) create mode 100644 lib/data/friendly_error.dart create mode 100644 lib/widgets/fai_en_badge.dart create mode 100644 test/friendly_error_test.dart diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart new file mode 100644 index 0000000..dfe4b6b --- /dev/null +++ b/lib/data/friendly_error.dart @@ -0,0 +1,148 @@ +// Friendly-error mapper. Turns a thrown object (typically a +// `GrpcError` from package:grpc) into a short human sentence +// plus an optional recovery hint, ready to drop into the UI. +// +// Why this exists: Studio used to render `GrpcError.toString()` +// verbatim — operators saw walls of text like +// "gRPC Error (code: 13, codeName: INTERNAL, message: ..., details: [], rawResponse: null, trailers: {...})". +// The friendly mapper folds every gRPC code into a one-line +// explanation we can localise, plus a recovery action when one +// is obvious (open settings, restart hub, retry). + +import '../l10n/app_localizations.dart'; + +/// Result of running an error through [friendlyError]. The +/// `headline` is the short user-facing sentence; `detail` is +/// the verbatim original error string (kept around in case the +/// operator wants to copy-paste it into a bug report). `hint` +/// is a one-sentence recovery suggestion or `null`. +class FriendlyError { + final String headline; + final String detail; + final String? hint; + + const FriendlyError({ + required this.headline, + required this.detail, + this.hint, + }); +} + +/// Map an arbitrary thrown object to a [FriendlyError]. Always +/// returns a value — never throws — so callers can drop the +/// result straight into UI without try/catch ceremony. +FriendlyError friendlyError(Object error, AppLocalizations l) { + // We deliberately don't import package:grpc here so Studio + // doesn't have to add it to its own pubspec — the dependency + // lives one layer down in fai_dart_sdk. `GrpcError` has a + // stable `.code` (int) and `.message` (String?) shape; we + // duck-type on those instead of an `is` check. + final code = _intField(error, 'code'); + final detail = _stringField(error, 'message') ?? ''; + if (code != null) { + switch (code) { + case 3: // INVALID_ARGUMENT + return FriendlyError( + headline: l.errInvalidArgument, + detail: detail, + hint: l.errInvalidArgumentHint, + ); + case 5: // NOT_FOUND + return FriendlyError( + headline: l.errNotFound, + detail: detail, + hint: l.errNotFoundHint, + ); + case 6: // ALREADY_EXISTS + return FriendlyError( + headline: l.errAlreadyExists, + detail: detail, + hint: null, + ); + case 7: // PERMISSION_DENIED + return FriendlyError( + headline: l.errPermissionDenied, + detail: detail, + hint: l.errPermissionDeniedHint, + ); + case 9: // FAILED_PRECONDITION + return FriendlyError( + headline: l.errFailedPrecondition, + detail: detail, + hint: l.errFailedPreconditionHint, + ); + case 13: // INTERNAL + return FriendlyError( + headline: l.errInternal, + detail: detail, + hint: l.errInternalHint, + ); + case 14: // UNAVAILABLE + return FriendlyError( + headline: l.errUnavailable, + detail: detail, + hint: l.errUnavailableHint, + ); + case 16: // UNAUTHENTICATED + return FriendlyError( + headline: l.errUnauthenticated, + detail: detail, + hint: l.errUnauthenticatedHint, + ); + default: + final codeName = _stringField(error, 'codeName') ?? 'gRPC $code'; + return FriendlyError( + headline: '$codeName: ${detail.isEmpty ? l.errGeneric : detail}', + detail: detail, + hint: null, + ); + } + } + // Non-gRPC error (e.g. FormatException from a bad URL). Show + // its toString — but as the headline, not buried in a wall. + return FriendlyError( + headline: error.toString(), + detail: error.toString(), + hint: null, + ); +} + +/// Try to read an `int` field by name off an arbitrary object. +/// Returns `null` when the field doesn't exist or has another +/// runtime type. Used to duck-type `GrpcError.code` without +/// pulling package:grpc as a Studio dependency. +int? _intField(Object obj, String field) { + try { + final dyn = obj as dynamic; + // ignore: avoid_dynamic_calls + final v = (() { + switch (field) { + case 'code': + return dyn.code; + } + return null; + })(); + return v is int ? v : null; + } catch (_) { + return null; + } +} + +String? _stringField(Object obj, String field) { + try { + final dyn = obj as dynamic; + // ignore: avoid_dynamic_calls + final v = (() { + switch (field) { + case 'message': + return dyn.message; + case 'codeName': + return dyn.codeName; + } + return null; + })(); + return v is String ? v : null; + } catch (_) { + return null; + } +} diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 0745dd2..1a461cb 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -93,13 +93,18 @@ class HubService { Future healthy() => _client.healthy(); + /// List of installed WASM modules, grouped by `module_name`. + /// Built-in and federated capabilities are excluded — they + /// don't correspond to a bundle on disk and would confuse the + /// Modules page (a "system" or "via:filesystem" pseudo-module + /// has nothing to uninstall). Callers that need every callable + /// capability — e.g. the flow's missing-dependencies check — + /// use [allCapabilities] instead. Future> listModules() async { final caps = await _client.listCapabilities(); - - // Group capabilities by module so Studio's UI maps a card - // to a module rather than a capability. + final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm'); final byModule = >{}; - for (final c in caps) { + for (final c in wasm) { byModule.putIfAbsent(c.moduleName, () => []).add(c); } return byModule.entries.map((e) { @@ -115,6 +120,25 @@ class HubService { ..sort((a, b) => a.name.compareTo(b.name)); } + /// Every capability the hub can execute, with the `kind` tag + /// telling apart wasm / builtin / federated. The flow page + /// uses this to decide which capabilities are "already + /// available" — including built-ins like `system.approval` + /// and federated MCP / n8n tools — so the Run button enables + /// when the dependency is reachable, not only when a bundle + /// happens to be installed. + Future> allCapabilities() async { + final caps = await _client.listCapabilities(); + return caps + .map((c) => CapabilityInfo( + capability: c.capability, + version: c.version, + moduleName: c.moduleName, + kind: c.kind.isEmpty ? 'wasm' : c.kind, + )) + .toList(); + } + /// Fully-detailed manifest for one installed module. Future moduleInfo(String name) async { final r = await _client.moduleInfo(name); @@ -126,6 +150,7 @@ class HubService { .toList(), permissions: r.permissions, directory: r.directory, + acceptsMime: r.acceptsMime, ); } @@ -834,6 +859,26 @@ enum ThemeModeValue { } } +/// Single capability with its provenance. `kind` is one of +/// `wasm` (installed module), `builtin` (hub-internal, e.g. +/// `system.approval`), or `federated` (MCP / n8n tool reachable +/// through the bridge). UI uses `kind` to decide what actions +/// to offer — only `wasm` capabilities have an install / +/// uninstall affordance. +class CapabilityInfo { + final String capability; + final String version; + final String moduleName; + final String kind; + + const CapabilityInfo({ + required this.capability, + required this.version, + required this.moduleName, + required this.kind, + }); +} + /// UI-side type, decoupled from the proto wire type so pages /// don't import protobuf packages. class ModuleSummary { @@ -872,6 +917,10 @@ class ModuleDetail { final List capabilities; final List permissions; final String directory; + /// MIME allow-list declared in the module's `module.yaml`. + /// Empty when the module didn't declare any — caller falls + /// back to its built-in extension heuristic. + final List acceptsMime; const ModuleDetail({ required this.name, @@ -879,6 +928,7 @@ class ModuleDetail { required this.capabilities, required this.permissions, required this.directory, + this.acceptsMime = const [], }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5409e7e..ad60a1a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -75,6 +75,25 @@ "buttonReadDocs": "Doku öffnen", "buttonCopy": "In Zwischenablage kopieren", "buttonCopied": "Kopiert", + "buttonShowDetails": "Details anzeigen", + "buttonHideDetails": "Details ausblenden", + "storeInstallableOnly": "Nur installierbare", + "storePillComingSoon": "Bald verfügbar", + "storeNotInstallableInline": "Dieses Modul ist auf der Roadmap (Status: {status}). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.", + "@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } }, + + "mcpSuggestionDeepwikiDesc": "Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.", + "mcpSuggestionSemgrepDesc": "Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.", + "mcpSuggestionFilesystemDesc": "Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.", + "mcpSuggestionFetchDesc": "Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.", + "mcpSuggestionGithubDesc": "Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.", + "mcpSuggestionPuppeteerDesc": "Anthropic — Headless-Browser-Automation für Screenshots / Scraping.", + "mcpSuggestionPostgresDesc": "Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.", + "mcpSuggestionSqliteDesc": "Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.", + "mcpSuggestionBraveSearchDesc": "Anthropic — Websuche via Brave. API-Key erforderlich.", + "mcpSuggestionMemoryDesc": "Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.", + "mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.", + "mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.", "settingsTitle": "Hub-Endpunkt", "settingsHost": "Host", @@ -512,6 +531,25 @@ "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow läuft…", "flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.", + + "errGeneric": "Da ist etwas schiefgegangen.", + "errInvalidArgument": "Ungültige Eingabe.", + "errInvalidArgumentHint": "Werte im Formular prüfen und erneut versuchen.", + "errNotFound": "Nicht gefunden.", + "errNotFoundHint": "Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.", + "errAlreadyExists": "Existiert bereits.", + "errPermissionDenied": "Berechtigung verweigert.", + "errPermissionDeniedHint": "Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.", + "errFailedPrecondition": "Setup-Problem hat das blockiert.", + "errFailedPreconditionHint": "Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.", + "errInternal": "Unerwarteter interner Fehler.", + "errInternalHint": "Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.", + "errUnavailable": "Hub nicht erreichbar.", + "errUnavailableHint": "Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.", + "errUnauthenticated": "Authentifizierung erforderlich.", + "errUnauthenticatedHint": "`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.", + "errCopyDetail": "Detail kopieren", + "errDetailCopied": "Detail in die Zwischenablage kopiert.", "flowsOutputUnknown": "Unbekannter Output-Payload-Typ.", "flowsOutputBytesUnknownMime": "Binärdaten", "flowsOutputSaveAs": "Speichern unter…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da2c961..b5078ab 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -76,6 +76,25 @@ "buttonReadDocs": "Read docs", "buttonCopy": "Copy to clipboard", "buttonCopied": "Copied", + "buttonShowDetails": "Show details", + "buttonHideDetails": "Hide details", + "storeInstallableOnly": "Installable only", + "storePillComingSoon": "Coming soon", + "storeNotInstallableInline": "This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.", + "@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } }, + + "mcpSuggestionDeepwikiDesc": "Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.", + "mcpSuggestionSemgrepDesc": "Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.", + "mcpSuggestionFilesystemDesc": "Anthropic — read / write files in /tmp. Edit the path before saving.", + "mcpSuggestionFetchDesc": "Anthropic — fetch arbitrary HTTP(S) URLs as markdown.", + "mcpSuggestionGithubDesc": "Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.", + "mcpSuggestionPuppeteerDesc": "Anthropic — headless browser automation for screenshots / scraping.", + "mcpSuggestionPostgresDesc": "Anthropic — read-only SQL queries against a Postgres database. Edit the URL.", + "mcpSuggestionSqliteDesc": "Anthropic — query a local SQLite file. Edit the path.", + "mcpSuggestionBraveSearchDesc": "Anthropic — web search via Brave. API key required.", + "mcpSuggestionMemoryDesc": "Anthropic — persistent knowledge graph for an agent's memory.", + "mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.", + "mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.", "settingsTitle": "Hub endpoint", "settingsHost": "Host", @@ -513,6 +532,25 @@ "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow running…", "flowsNoOutputs": "Flow completed with no declared outputs.", + + "errGeneric": "Something went wrong.", + "errInvalidArgument": "Invalid input.", + "errInvalidArgumentHint": "Check the form values and try again.", + "errNotFound": "Not found.", + "errNotFoundHint": "The item you asked for isn't here — it may have been removed or never existed.", + "errAlreadyExists": "Already exists.", + "errPermissionDenied": "Permission denied.", + "errPermissionDeniedHint": "The hub's operator policy blocks this action. Check `~/.fai/config.yaml`.", + "errFailedPrecondition": "Setup issue blocked this.", + "errFailedPreconditionHint": "The hub couldn't proceed because something it needs isn't ready. The detail below explains what.", + "errInternal": "Unexpected internal error.", + "errInternalHint": "Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.", + "errUnavailable": "Hub not reachable.", + "errUnavailableHint": "Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio's settings.", + "errUnauthenticated": "Authentication required.", + "errUnauthenticatedHint": "Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.", + "errCopyDetail": "Copy detail", + "errDetailCopied": "Detail copied to clipboard.", "flowsOutputUnknown": "Output payload variant not recognised.", "flowsOutputBytesUnknownMime": "binary", "flowsOutputSaveAs": "Save as…", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b733d6b..9715977 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -518,6 +518,108 @@ abstract class AppLocalizations { /// **'Copied'** String get buttonCopied; + /// No description provided for @buttonShowDetails. + /// + /// In en, this message translates to: + /// **'Show details'** + String get buttonShowDetails; + + /// No description provided for @buttonHideDetails. + /// + /// In en, this message translates to: + /// **'Hide details'** + String get buttonHideDetails; + + /// No description provided for @storeInstallableOnly. + /// + /// In en, this message translates to: + /// **'Installable only'** + String get storeInstallableOnly; + + /// No description provided for @storePillComingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get storePillComingSoon; + + /// No description provided for @storeNotInstallableInline. + /// + /// In en, this message translates to: + /// **'This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.'** + String storeNotInstallableInline(String status); + + /// No description provided for @mcpSuggestionDeepwikiDesc. + /// + /// In en, this message translates to: + /// **'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'** + String get mcpSuggestionDeepwikiDesc; + + /// No description provided for @mcpSuggestionSemgrepDesc. + /// + /// In en, this message translates to: + /// **'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'** + String get mcpSuggestionSemgrepDesc; + + /// No description provided for @mcpSuggestionFilesystemDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — read / write files in /tmp. Edit the path before saving.'** + String get mcpSuggestionFilesystemDesc; + + /// No description provided for @mcpSuggestionFetchDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'** + String get mcpSuggestionFetchDesc; + + /// No description provided for @mcpSuggestionGithubDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'** + String get mcpSuggestionGithubDesc; + + /// No description provided for @mcpSuggestionPuppeteerDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — headless browser automation for screenshots / scraping.'** + String get mcpSuggestionPuppeteerDesc; + + /// No description provided for @mcpSuggestionPostgresDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'** + String get mcpSuggestionPostgresDesc; + + /// No description provided for @mcpSuggestionSqliteDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — query a local SQLite file. Edit the path.'** + String get mcpSuggestionSqliteDesc; + + /// No description provided for @mcpSuggestionBraveSearchDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — web search via Brave. API key required.'** + String get mcpSuggestionBraveSearchDesc; + + /// No description provided for @mcpSuggestionMemoryDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — persistent knowledge graph for an agent\'s memory.'** + String get mcpSuggestionMemoryDesc; + + /// No description provided for @mcpSuggestionTimeDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — current time + timezone conversion.'** + String get mcpSuggestionTimeDesc; + + /// No description provided for @mcpServerEnLanguageBadge. + /// + /// In en, this message translates to: + /// **'Server text in English — Studio shows it as supplied.'** + String get mcpServerEnLanguageBadge; + /// No description provided for @settingsTitle. /// /// In en, this message translates to: @@ -2306,6 +2408,114 @@ abstract class AppLocalizations { /// **'Flow completed with no declared outputs.'** String get flowsNoOutputs; + /// No description provided for @errGeneric. + /// + /// In en, this message translates to: + /// **'Something went wrong.'** + String get errGeneric; + + /// No description provided for @errInvalidArgument. + /// + /// In en, this message translates to: + /// **'Invalid input.'** + String get errInvalidArgument; + + /// No description provided for @errInvalidArgumentHint. + /// + /// In en, this message translates to: + /// **'Check the form values and try again.'** + String get errInvalidArgumentHint; + + /// No description provided for @errNotFound. + /// + /// In en, this message translates to: + /// **'Not found.'** + String get errNotFound; + + /// No description provided for @errNotFoundHint. + /// + /// In en, this message translates to: + /// **'The item you asked for isn\'t here — it may have been removed or never existed.'** + String get errNotFoundHint; + + /// No description provided for @errAlreadyExists. + /// + /// In en, this message translates to: + /// **'Already exists.'** + String get errAlreadyExists; + + /// No description provided for @errPermissionDenied. + /// + /// In en, this message translates to: + /// **'Permission denied.'** + String get errPermissionDenied; + + /// No description provided for @errPermissionDeniedHint. + /// + /// In en, this message translates to: + /// **'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'** + String get errPermissionDeniedHint; + + /// No description provided for @errFailedPrecondition. + /// + /// In en, this message translates to: + /// **'Setup issue blocked this.'** + String get errFailedPrecondition; + + /// No description provided for @errFailedPreconditionHint. + /// + /// In en, this message translates to: + /// **'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'** + String get errFailedPreconditionHint; + + /// No description provided for @errInternal. + /// + /// In en, this message translates to: + /// **'Unexpected internal error.'** + String get errInternal; + + /// No description provided for @errInternalHint. + /// + /// In en, this message translates to: + /// **'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'** + String get errInternalHint; + + /// No description provided for @errUnavailable. + /// + /// In en, this message translates to: + /// **'Hub not reachable.'** + String get errUnavailable; + + /// No description provided for @errUnavailableHint. + /// + /// In en, this message translates to: + /// **'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'** + String get errUnavailableHint; + + /// No description provided for @errUnauthenticated. + /// + /// In en, this message translates to: + /// **'Authentication required.'** + String get errUnauthenticated; + + /// No description provided for @errUnauthenticatedHint. + /// + /// In en, this message translates to: + /// **'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'** + String get errUnauthenticatedHint; + + /// No description provided for @errCopyDetail. + /// + /// In en, this message translates to: + /// **'Copy detail'** + String get errCopyDetail; + + /// No description provided for @errDetailCopied. + /// + /// In en, this message translates to: + /// **'Detail copied to clipboard.'** + String get errDetailCopied; + /// No description provided for @flowsOutputUnknown. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1493743..7701ca1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -237,6 +237,71 @@ class AppLocalizationsDe extends AppLocalizations { @override String get buttonCopied => 'Kopiert'; + @override + String get buttonShowDetails => 'Details anzeigen'; + + @override + String get buttonHideDetails => 'Details ausblenden'; + + @override + String get storeInstallableOnly => 'Nur installierbare'; + + @override + String get storePillComingSoon => 'Bald verfügbar'; + + @override + String storeNotInstallableInline(String status) { + return 'Dieses Modul ist auf der Roadmap (Status: $status). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.'; + } + + @override + String get mcpSuggestionDeepwikiDesc => + 'Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.'; + + @override + String get mcpSuggestionSemgrepDesc => + 'Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.'; + + @override + String get mcpSuggestionFilesystemDesc => + 'Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.'; + + @override + String get mcpSuggestionFetchDesc => + 'Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.'; + + @override + String get mcpSuggestionGithubDesc => + 'Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.'; + + @override + String get mcpSuggestionPuppeteerDesc => + 'Anthropic — Headless-Browser-Automation für Screenshots / Scraping.'; + + @override + String get mcpSuggestionPostgresDesc => + 'Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.'; + + @override + String get mcpSuggestionSqliteDesc => + 'Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.'; + + @override + String get mcpSuggestionBraveSearchDesc => + 'Anthropic — Websuche via Brave. API-Key erforderlich.'; + + @override + String get mcpSuggestionMemoryDesc => + 'Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.'; + + @override + String get mcpSuggestionTimeDesc => + 'Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.'; + + @override + String get mcpServerEnLanguageBadge => + 'Server-Text in Englisch — Studio zeigt ihn wie geliefert.'; + @override String get settingsTitle => 'Hub-Endpunkt'; @@ -1324,6 +1389,67 @@ class AppLocalizationsDe extends AppLocalizations { @override String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.'; + @override + String get errGeneric => 'Da ist etwas schiefgegangen.'; + + @override + String get errInvalidArgument => 'Ungültige Eingabe.'; + + @override + String get errInvalidArgumentHint => + 'Werte im Formular prüfen und erneut versuchen.'; + + @override + String get errNotFound => 'Nicht gefunden.'; + + @override + String get errNotFoundHint => + 'Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.'; + + @override + String get errAlreadyExists => 'Existiert bereits.'; + + @override + String get errPermissionDenied => 'Berechtigung verweigert.'; + + @override + String get errPermissionDeniedHint => + 'Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.'; + + @override + String get errFailedPrecondition => 'Setup-Problem hat das blockiert.'; + + @override + String get errFailedPreconditionHint => + 'Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.'; + + @override + String get errInternal => 'Unerwarteter interner Fehler.'; + + @override + String get errInternalHint => + 'Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.'; + + @override + String get errUnavailable => 'Hub nicht erreichbar.'; + + @override + String get errUnavailableHint => + 'Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.'; + + @override + String get errUnauthenticated => 'Authentifizierung erforderlich.'; + + @override + String get errUnauthenticatedHint => + '`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.'; + + @override + String get errCopyDetail => 'Detail kopieren'; + + @override + String get errDetailCopied => 'Detail in die Zwischenablage kopiert.'; + @override String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b9523e3..f045bd3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -237,6 +237,71 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonCopied => 'Copied'; + @override + String get buttonShowDetails => 'Show details'; + + @override + String get buttonHideDetails => 'Hide details'; + + @override + String get storeInstallableOnly => 'Installable only'; + + @override + String get storePillComingSoon => 'Coming soon'; + + @override + String storeNotInstallableInline(String status) { + return 'This module is on the roadmap (status: $status). It has no installable bundle yet — follow the repository for updates.'; + } + + @override + String get mcpSuggestionDeepwikiDesc => + 'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'; + + @override + String get mcpSuggestionSemgrepDesc => + 'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'; + + @override + String get mcpSuggestionFilesystemDesc => + 'Anthropic — read / write files in /tmp. Edit the path before saving.'; + + @override + String get mcpSuggestionFetchDesc => + 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'; + + @override + String get mcpSuggestionGithubDesc => + 'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'; + + @override + String get mcpSuggestionPuppeteerDesc => + 'Anthropic — headless browser automation for screenshots / scraping.'; + + @override + String get mcpSuggestionPostgresDesc => + 'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'; + + @override + String get mcpSuggestionSqliteDesc => + 'Anthropic — query a local SQLite file. Edit the path.'; + + @override + String get mcpSuggestionBraveSearchDesc => + 'Anthropic — web search via Brave. API key required.'; + + @override + String get mcpSuggestionMemoryDesc => + 'Anthropic — persistent knowledge graph for an agent\'s memory.'; + + @override + String get mcpSuggestionTimeDesc => + 'Anthropic — current time + timezone conversion.'; + + @override + String get mcpServerEnLanguageBadge => + 'Server text in English — Studio shows it as supplied.'; + @override String get settingsTitle => 'Hub endpoint'; @@ -1337,6 +1402,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flowsNoOutputs => 'Flow completed with no declared outputs.'; + @override + String get errGeneric => 'Something went wrong.'; + + @override + String get errInvalidArgument => 'Invalid input.'; + + @override + String get errInvalidArgumentHint => 'Check the form values and try again.'; + + @override + String get errNotFound => 'Not found.'; + + @override + String get errNotFoundHint => + 'The item you asked for isn\'t here — it may have been removed or never existed.'; + + @override + String get errAlreadyExists => 'Already exists.'; + + @override + String get errPermissionDenied => 'Permission denied.'; + + @override + String get errPermissionDeniedHint => + 'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'; + + @override + String get errFailedPrecondition => 'Setup issue blocked this.'; + + @override + String get errFailedPreconditionHint => + 'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'; + + @override + String get errInternal => 'Unexpected internal error.'; + + @override + String get errInternalHint => + 'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'; + + @override + String get errUnavailable => 'Hub not reachable.'; + + @override + String get errUnavailableHint => + 'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'; + + @override + String get errUnauthenticated => 'Authentication required.'; + + @override + String get errUnauthenticatedHint => + 'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'; + + @override + String get errCopyDetail => 'Copy detail'; + + @override + String get errDetailCopied => 'Detail copied to clipboard.'; + @override String get flowsOutputUnknown => 'Output payload variant not recognised.'; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 8d4246a..48eb306 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -29,30 +29,27 @@ class _FlowsPageState extends State { _future = _load(); } - /// Pulls the saved flows AND the installed-modules list in - /// parallel so the page can compute "is this flow runnable" - /// per row from the same data the Run button gates on. + /// Pulls the saved flows AND every capability the hub can + /// execute (WASM modules + built-ins like `system.approval` + + /// federated MCP/n8n tools) in parallel so the page can + /// compute "is this flow runnable" per row. + /// + /// Using `allCapabilities()` instead of `listModules()` is + /// load-bearing: built-ins are not WASM modules, so they would + /// never appear in a module-grouped list — and a flow that + /// uses `system.approval@^0` would stay stuck on "missing + /// dependency" forever. Future<_FlowsBundle> _load() async { final results = await Future.wait([ HubService.instance.listFlows(), - HubService.instance.listModules().catchError((_) => []), + HubService.instance + .allCapabilities() + .catchError((_) => []), ]); return _FlowsBundle( flows: results[0] as List, - installedCapabilities: (results[1] as List) - .expand((m) => m.capabilities) - // `listModules()` emits capability strings with a - // `@version` suffix (e.g. `text.extract@0.1.0`). The - // flow's `requiredCapabilities` carry their own - // user-typed version constraint, so we compare on the - // bare capability name and let the hub do the strict - // version match at run time. Without this strip, the - // contains-check would never hit and the Run button - // would stay disabled even after a successful install. - .map((c) { - final at = c.indexOf('@'); - return at >= 0 ? c.substring(0, at) : c; - }) + installedCapabilities: (results[1] as List) + .map((c) => c.capability) .toSet(), ); } @@ -638,7 +635,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { ), const SizedBox(height: FaiSpace.sm), FaiErrorBox( - text: snap.error.toString(), + error: snap.error, isError: true, maxHeight: 200, ), @@ -887,7 +884,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { ), const SizedBox(height: FaiSpace.md), FaiErrorBox( - text: snapshot.error.toString(), + error: snapshot.error, isError: true, maxHeight: 280, ), @@ -957,7 +954,10 @@ class _InstallItemState { _InstallStatus status; String? installedVersion; - String? error; + /// Original thrown object so the UI can run it through + /// [friendlyError] later instead of staring at a wall of gRPC + /// trailers. + Object? error; _InstallItemState(this.spec) : bareName = _stripVersion(spec), @@ -1015,7 +1015,7 @@ class _InstallDependenciesDialogState if (!mounted) return; setState(() { item.status = _InstallStatus.failed; - item.error = e.toString(); + item.error = e; }); } } @@ -1042,8 +1042,9 @@ class _InstallDependenciesDialogState i.status == _InstallStatus.failed && _isAuthWallError(i.error), ); - static bool _isAuthWallError(String? msg) { - if (msg == null) return false; + static bool _isAuthWallError(Object? err) { + if (err == null) return false; + final msg = err.toString(); return msg.contains('registry returned an HTML page') || msg.contains('no registry token configured'); } @@ -1163,7 +1164,7 @@ class _InstallRow extends StatelessWidget { item.error != null) ...[ const SizedBox(height: FaiSpace.xs), FaiErrorBox( - text: item.error!, + error: item.error, isError: true, maxHeight: 120, ), diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 59b4868..dae419e 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -36,6 +36,13 @@ class _StorePageState extends State { /// has no source field. String _source = ''; bool _installedOnly = false; + /// "Show installable modules only". On by default: roughly + /// 2/3 of the seed-index entries currently carry status + /// `planned` (no bundle to install), and pre-filter polish + /// found that operators couldn't tell why they couldn't + /// install half the cards. Planned modules are one chip-flip + /// away in the filter dialog. + bool _installableOnly = true; /// Tracks per-recommended-source add buttons to disable them /// while the request is in flight. final Set _addingSources = {}; @@ -136,7 +143,19 @@ class _StorePageState extends State { final all = results[0] as List; final mods = results[1] as List; _installedVersions = {for (final m in mods) m.name: m.version}; - return _installedOnly ? all.where((e) => e.installed).toList() : all; + var out = all; + if (_installedOnly) { + out = out.where((e) => e.installed).toList(); + } + if (_installableOnly) { + // An already-installed item counts as "installable" for + // this filter — the user can still update / uninstall it. + out = out + .where((e) => + e.installed || e.status == 'published' || e.status == 'alpha') + .toList(); + } + return out; } void _runSearch() { @@ -261,6 +280,7 @@ class _StorePageState extends State { _status = ''; _source = ''; _installedOnly = false; + _installableOnly = false; _queryCtrl.clear(); }); _runSearch(); @@ -440,6 +460,10 @@ class _StorePageState extends State { if (_status.isNotEmpty) n++; if (_source.isNotEmpty) n++; if (_installedOnly) n++; + // `installableOnly` defaults to ON, so we only highlight it + // as an "active filter" when the operator turned it OFF — + // the surprising state is the one worth flagging. + if (!_installableOnly) n++; return n; } @@ -557,6 +581,7 @@ class _StorePageState extends State { status: _status, source: _source, installedOnly: _installedOnly, + installableOnly: _installableOnly, ), ); if (outcome == null) return; @@ -564,6 +589,7 @@ class _StorePageState extends State { _status = outcome.status; _source = outcome.source; _installedOnly = outcome.installedOnly; + _installableOnly = outcome.installableOnly; }); _runSearch(); } @@ -892,10 +918,12 @@ class _FilterOutcome { final String status; final String source; final bool installedOnly; + final bool installableOnly; const _FilterOutcome({ required this.status, required this.source, required this.installedOnly, + required this.installableOnly, }); } @@ -908,11 +936,13 @@ class _FilterDialog extends StatefulWidget { final String status; final String source; final bool installedOnly; + final bool installableOnly; const _FilterDialog({ required this.status, required this.source, required this.installedOnly, + required this.installableOnly, }); @override @@ -923,6 +953,7 @@ class _FilterDialogState extends State<_FilterDialog> { late String _status = widget.status; late String _source = widget.source; late bool _installedOnly = widget.installedOnly; + late bool _installableOnly = widget.installableOnly; @override Widget build(BuildContext context) { @@ -989,11 +1020,23 @@ class _FilterDialogState extends State<_FilterDialog> { ), const SizedBox(height: FaiSpace.lg), header(l.storeFilterInstalledGroup), - FilterChip( - label: Text(l.storeInstalledOnly), - selected: _installedOnly, - onSelected: (v) => setState(() => _installedOnly = v), - showCheckmark: true, + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + FilterChip( + label: Text(l.storeInstallableOnly), + selected: _installableOnly, + onSelected: (v) => setState(() => _installableOnly = v), + showCheckmark: true, + ), + FilterChip( + label: Text(l.storeInstalledOnly), + selected: _installedOnly, + onSelected: (v) => setState(() => _installedOnly = v), + showCheckmark: true, + ), + ], ), ], ), @@ -1005,6 +1048,11 @@ class _FilterDialogState extends State<_FilterDialog> { _status = ''; _source = ''; _installedOnly = false; + // Reset puts `installableOnly` back to its default + // (on) — the operator hits Reset to escape a + // custom-filter state and expects the default view + // back, not an everything-including-planned view. + _installableOnly = true; }); }, child: Text(l.storeFilterReset), @@ -1021,6 +1069,7 @@ class _FilterDialogState extends State<_FilterDialog> { status: _status, source: _source, installedOnly: _installedOnly, + installableOnly: _installableOnly, ), ), child: Text(l.storeFilterApply), @@ -1566,11 +1615,29 @@ class _FeaturedTile extends StatelessWidget { ), const SizedBox(height: FaiSpace.md), Expanded( - child: Text( - tagline.isEmpty ? l.storeNoTagline : tagline, - style: theme.textTheme.bodyLarge, - maxLines: 3, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.isFederated) + // Federated items get tagline copy straight + // from the upstream MCP / n8n server, which + // is English regardless of Studio's locale. + // The [EN] pill is the honest signal until + // the planned `studio.translate` plugin + // rewrites it on the fly. + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: FaiEnBadge.forContext(context), + ), + Expanded( + child: Text( + tagline.isEmpty ? l.storeNoTagline : tagline, + style: theme.textTheme.bodyLarge, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), const SizedBox(height: FaiSpace.sm), @@ -1600,6 +1667,16 @@ class _FeaturedTile extends StatelessWidget { onPressed: onInstall, icon: const Icon(Icons.download, size: 16), label: Text(l.buttonInstall), + ) + else + // Status is `planned` (or another non-installable + // wire value). Without an explicit pill here the + // featured tile would just go silent, leaving the + // operator wondering why the card has no action. + FaiPill( + label: l.storePillComingSoon, + tone: FaiPillTone.neutral, + icon: Icons.schedule, ), ], ), @@ -1647,7 +1724,14 @@ class _StoreCard extends StatelessWidget { ? item.taglineDe : item.taglineEn; final installable = item.status == 'published' || item.status == 'alpha'; - return Material( + // Dim planned/non-installable cards so the eye lands on + // actionable cards first. We don't disable interaction — the + // detail sheet still opens — but the visual hierarchy is + // honest about what the operator can act on. + final notInstallable = !installable && !item.installed; + return Opacity( + opacity: notInstallable ? 0.6 : 1.0, + child: Material( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.md), child: InkWell( @@ -1782,6 +1866,7 @@ class _StoreCard extends StatelessWidget { ), ), ), + ), ); } } @@ -2081,6 +2166,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { const SizedBox(height: FaiSpace.lg), ], if (description.isNotEmpty) ...[ + if (item.isFederated) ...[ + // Description copy was supplied by the + // upstream server, not by Studio's + // localisation pipeline — flag it. + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: FaiEnBadge.forContext(context), + ), + ], SelectableText( description, style: theme.textTheme.bodyMedium, @@ -2294,14 +2388,46 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { _busy ? l.storeInstallingButton : l.buttonInstall, ), ) - else - Tooltip( - message: l.storeNotInstallableTooltip(item.status), - child: FilledButton( - onPressed: null, - child: Text(l.storeNotInstallable), + else ...[ + // Inline hint replaces the previous + // tooltip-only affordance: the operator + // sees the reason without having to hover. + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + Icon( + Icons.schedule, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + l.storeNotInstallableInline(item.status), + style: + theme.textTheme.bodySmall?.copyWith( + color: + theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), ), ), + ], ], ), ], @@ -2395,12 +2521,10 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { size: 32, ), const SizedBox(height: FaiSpace.md), - SelectableText( - snap.error.toString(), - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.error, - ), + FaiErrorBox( + error: snap.error, + isError: true, + maxHeight: 200, ), ], ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index ce8a7a1..94c34f5 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1041,7 +1041,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), const SizedBox(height: FaiSpace.sm), FaiErrorBox( - text: snap.error.toString(), + error: snap.error, isError: true, maxHeight: 200, ), diff --git a/lib/widgets/fai_en_badge.dart b/lib/widgets/fai_en_badge.dart new file mode 100644 index 0000000..42b837d --- /dev/null +++ b/lib/widgets/fai_en_badge.dart @@ -0,0 +1,61 @@ +// FaiEnBadge — small "[EN]" pill shown next to text that came +// from outside Studio's localization pipeline (MCP server tool +// names, n8n endpoint descriptions, native LLM responses). +// +// The honest move: when the active locale isn't English and we +// show a piece of text that we know is English, mark it so the +// operator doesn't blame Studio for a half-translated page. +// `studio.translate` plugin will eventually rewrite these in +// place — until then, the badge is the right amount of +// transparency. + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class FaiEnBadge extends StatelessWidget { + /// When true, the badge renders. When false (e.g. the active + /// locale already is English), it returns + /// `SizedBox.shrink()` so callers can drop it inline without + /// guarding visibility themselves. + final bool visible; + + const FaiEnBadge({super.key, required this.visible}); + + /// Convenience constructor: derives `visible` from the active + /// Localizations locale. Use this from inside a Build method + /// where you already have a BuildContext. + factory FaiEnBadge.forContext(BuildContext context) { + final lang = Localizations.localeOf(context).languageCode; + return FaiEnBadge(visible: lang != 'en'); + } + + @override + Widget build(BuildContext context) { + if (!visible) return const SizedBox.shrink(); + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Tooltip( + message: l.mcpServerEnLanguageBadge, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: Text( + 'EN', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 9, + letterSpacing: 0.4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_error_box.dart b/lib/widgets/fai_error_box.dart index 1212702..c413ce0 100644 --- a/lib/widgets/fai_error_box.dart +++ b/lib/widgets/fai_error_box.dart @@ -10,13 +10,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../data/friendly_error.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. + /// monospace, selectable, multi-line. Ignored when [error] is + /// supplied. final String text; /// When true, the box border + foreground colour come from the /// error palette. False renders neutral chrome — useful for @@ -27,13 +29,25 @@ class FaiErrorBox extends StatefulWidget { /// box. Falls back to no constraint when null so short /// messages don't get a useless scrollbar. final double? maxHeight; + /// When non-null, the box renders the [friendlyError] mapping + /// of this error: a one-line headline, a recovery-hint line + /// (when one applies), and the verbatim original message + /// collapsed behind a "Details" expander. Use this instead of + /// [text] anywhere we'd otherwise show a raw + /// `e.toString()` — gRPC-Errors come out as walls of code + + /// trailers otherwise. + final Object? error; const FaiErrorBox({ super.key, - required this.text, + this.text = '', this.isError = false, this.maxHeight, - }); + this.error, + }) : assert( + text != '' || error != null, + 'FaiErrorBox needs either text or error', + ); @override State createState() => _FaiErrorBoxState(); @@ -41,9 +55,10 @@ class FaiErrorBox extends StatefulWidget { class _FaiErrorBoxState extends State { bool _justCopied = false; + bool _detailExpanded = false; - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.text)); + Future _copy(String what) async { + await Clipboard.setData(ClipboardData(text: what)); if (!mounted) return; setState(() => _justCopied = true); Future.delayed(const Duration(seconds: 2), () { @@ -61,6 +76,10 @@ class _FaiErrorBoxState extends State { final fg = widget.isError ? theme.colorScheme.error : theme.colorScheme.onSurface; + final friendly = widget.error == null + ? null + : friendlyError(widget.error!, l); + final copyText = friendly?.detail ?? widget.text; return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB( @@ -75,37 +94,101 @@ class _FaiErrorBoxState extends State { border: Border.all(color: accent.withValues(alpha: 0.4)), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, 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, + Align( + alignment: Alignment.centerRight, + child: 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(copyText), ), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), - onPressed: _copy, ), ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: widget.maxHeight ?? double.infinity, + if (friendly != null) ...[ + SelectableText( + friendly.headline, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w600, + ), ), - child: Scrollbar( - child: SingleChildScrollView( - child: SelectableText( - widget.text, - style: FaiTheme.mono(size: 11, color: fg), + if (friendly.hint != null) ...[ + const SizedBox(height: 4), + Text( + friendly.hint!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (friendly.detail.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + InkWell( + onTap: () => setState(() { + _detailExpanded = !_detailExpanded; + }), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _detailExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + _detailExpanded ? l.buttonHideDetails : l.buttonShowDetails, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (_detailExpanded) ...[ + const SizedBox(height: FaiSpace.xs), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: widget.maxHeight ?? double.infinity, + ), + child: Scrollbar( + child: SingleChildScrollView( + child: SelectableText( + friendly.detail, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ], + ], + ] else + 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 439453b..9d007d6 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -1023,11 +1023,12 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> { /// Pick a suggestion → fill the form. Operators still hit /// "Add + discover" so nothing happens unprompted. void _applySuggestion(_McpSuggestion s) { + final l = AppLocalizations.of(context)!; setState(() { _name.text = s.name; _endpoint.text = s.endpoint; _apiKey.text = s.apiKeyEnv; - _desc.text = s.description; + _desc.text = s.resolveDescription(l); }); } @@ -1770,36 +1771,73 @@ class _McpSuggestion { final IconData icon; final String endpoint; final String apiKeyEnv; - final String description; + /// Localizable description. Use [resolveDescription] to fetch + /// the actual text in the active locale — the const list + /// can't hold a closure that takes [AppLocalizations], and + /// hardcoding English here is what got us into the + /// untranslated-helper-text bug. + String resolveDescription(AppLocalizations l) => + _suggestionDescription(l, name); const _McpSuggestion({ required this.name, required this.icon, required this.endpoint, required this.apiKeyEnv, - required this.description, }); } +/// Localizable lookup of an MCP-suggestion description. Keyed +/// on the same suggestion `name` field as the list below. New +/// suggestions need a matching .arb entry; the default-case +/// returns an empty string so an unconfigured suggestion fails +/// silently instead of leaking a key into the UI. +String _suggestionDescription(AppLocalizations l, String name) { + switch (name) { + case 'deepwiki': + return l.mcpSuggestionDeepwikiDesc; + case 'semgrep': + return l.mcpSuggestionSemgrepDesc; + case 'filesystem': + return l.mcpSuggestionFilesystemDesc; + case 'fetch': + return l.mcpSuggestionFetchDesc; + case 'github': + return l.mcpSuggestionGithubDesc; + case 'puppeteer': + return l.mcpSuggestionPuppeteerDesc; + case 'postgres': + return l.mcpSuggestionPostgresDesc; + case 'sqlite': + return l.mcpSuggestionSqliteDesc; + case 'brave-search': + return l.mcpSuggestionBraveSearchDesc; + case 'memory': + return l.mcpSuggestionMemoryDesc; + case 'time': + return l.mcpSuggestionTimeDesc; + default: + return ''; + } +} + const _kMcpSuggestions = <_McpSuggestion>[ // ── HTTPS / streamable-HTTP servers (no Node, no API key) ── - // Same set Studio promotes as one-click cards in the Today - // hero — listed here so the operator finds them even after - // the hero gets dismissed. + // Descriptions are resolved via [_McpSuggestion.resolveDescription] + // against the active locale — see `mcpSuggestion*Desc` keys in + // app_en.arb / app_de.arb. Adding a suggestion here means + // adding a matching key in both .arb files plus a case to + // `_suggestionDescription` above. _McpSuggestion( name: 'deepwiki', icon: Icons.menu_book_outlined, endpoint: 'https://mcp.deepwiki.com/mcp', apiKeyEnv: '', - description: - 'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.', ), _McpSuggestion( name: 'semgrep', icon: Icons.security, endpoint: 'https://mcp.semgrep.ai/mcp', apiKeyEnv: '', - description: - 'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.', ), // ── stdio servers (require Node + npx) ─────────────────── _McpSuggestion( @@ -1807,30 +1845,24 @@ const _kMcpSuggestions = <_McpSuggestion>[ icon: Icons.folder_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp', apiKeyEnv: '', - description: - 'Anthropic — read/write files in /tmp. Edit the path before saving.', ), _McpSuggestion( name: 'fetch', icon: Icons.cloud_download_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch', apiKeyEnv: '', - description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.', ), _McpSuggestion( name: 'github', icon: Icons.code, endpoint: 'stdio://npx -y @modelcontextprotocol/server-github', apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN', - description: - 'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.', ), _McpSuggestion( name: 'puppeteer', icon: Icons.web, endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer', apiKeyEnv: '', - description: 'Anthropic — headless browser automation for screenshots / scraping.', ), _McpSuggestion( name: 'postgres', @@ -1838,8 +1870,6 @@ const _kMcpSuggestions = <_McpSuggestion>[ endpoint: 'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db', apiKeyEnv: '', - description: - 'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.', ), _McpSuggestion( name: 'sqlite', @@ -1847,27 +1877,23 @@ const _kMcpSuggestions = <_McpSuggestion>[ endpoint: 'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite', apiKeyEnv: '', - description: 'Anthropic — query a local SQLite file. Edit the path.', ), _McpSuggestion( name: 'brave-search', icon: Icons.search, endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search', apiKeyEnv: 'BRAVE_API_KEY', - description: 'Anthropic — web search via Brave. API key required.', ), _McpSuggestion( name: 'memory', icon: Icons.psychology_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory', apiKeyEnv: '', - description: 'Anthropic — persistent knowledge graph for an agent\'s memory.', ), _McpSuggestion( name: 'time', icon: Icons.schedule, endpoint: 'stdio://npx -y @modelcontextprotocol/server-time', apiKeyEnv: '', - description: 'Anthropic — current time + timezone conversion.', ), ]; diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 22dc088..ac2402a 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_en_badge.dart'; export 'fai_error_box.dart'; export 'fai_flow_output.dart'; export 'fai_module_sheet.dart'; diff --git a/test/friendly_error_test.dart b/test/friendly_error_test.dart new file mode 100644 index 0000000..18364ad --- /dev/null +++ b/test/friendly_error_test.dart @@ -0,0 +1,84 @@ +// Unit tests for the `friendlyError` mapper. These check the +// duck-typed shape contract — `GrpcError` from package:grpc is +// expected to expose `.code` (int), `.message` (String?), and +// `.codeName` (String) — without taking grpc as a Studio +// dependency. The fakes here mimic that shape. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fai_studio/data/friendly_error.dart'; +import 'package:fai_studio/l10n/app_localizations.dart'; + +class _FakeGrpcError { + final int code; + final String? message; + final String codeName; + _FakeGrpcError(this.code, this.codeName, [this.message]); +} + +Future _loadL10n(Locale locale) async { + return await AppLocalizations.delegate.load(locale); +} + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // Force loading the gen-l10n delegate before tests so each + // call to `_loadL10n` is fast. + await _loadL10n(const Locale('en')); + }); + + test('UNAVAILABLE maps to hub-not-reachable copy', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), l); + expect(r.headline, l.errUnavailable); + expect(r.hint, l.errUnavailableHint); + expect(r.detail, 'connection refused'); + }); + + test('FAILED_PRECONDITION carries module detail in body', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError(9, 'FAILED_PRECONDITION', + 'install error: no store entry for system.approval'), + l, + ); + expect(r.headline, l.errFailedPrecondition); + expect(r.hint, l.errFailedPreconditionHint); + expect(r.detail, contains('system.approval')); + }); + + test('NOT_FOUND has a recovery hint', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(5, 'NOT_FOUND', 'no such module'), l); + expect(r.headline, l.errNotFound); + expect(r.hint, isNotNull); + }); + + test('Unknown code falls back to codeName: message', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(99, 'CUSTOM_CODE', 'something'), l); + expect(r.headline, contains('CUSTOM_CODE')); + expect(r.headline, contains('something')); + expect(r.hint, isNull); + }); + + test('Non-gRPC error renders toString as headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(FormatException('bad url'), l); + expect(r.headline, contains('bad url')); + }); + + test('Locale switches the headline language', () async { + final lDe = await _loadL10n(const Locale('de')); + final r = + friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe); + expect(r.headline, lDe.errUnavailable); + // Sanity: the DE headline really is different from the EN + // one — guards against the test silently passing if l10n + // regen returned EN for both. + final lEn = await _loadL10n(const Locale('en')); + expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable))); + }); +} + From 69b54629d35fd1bde41608acd5c85fcd01c50506 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 13:22:04 +0200 Subject: [PATCH 3/7] chore(studio): flutter_markdown_plus migration + integration-test scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the May-2026 trust pass. flutter_markdown_plus migration: - pubspec swaps `flutter_markdown ^0.7.7` (discontinued upstream) for `flutter_markdown_plus ^1.0.3`, the actively-maintained fork. API surface (MarkdownStyleSheet, Markdown, MarkdownBody) is unchanged — the four import sites in welcome, store, flow_output, and theme.dart get an updated package string and that's it. - All Studio analyzer + unit-test suites stay green. Integration test scaffold: - New `test/integration/hub_fixture.dart` boots a real `fai serve` subprocess on a free port against a temp FAI_DATA_DIR, polls until Healthy, exposes a ready HubClient. Idempotent teardown wipes the temp dir. - Resolves the `fai` binary from PATH first, then from `../fai_platform/target/release/fai`. When neither exists, the fixture calls `markTestSkipped` with a clear message — fresh checkouts don't fail. - One canonical test in `capabilities_test.dart` asserts on the bug class the May trust pass surfaced: that `system.approval` appears in `list_capabilities` with `kind=builtin` so Studio's missing-deps check never tries to install it. Plus a contract-shape test that every cap's `kind` is one of the three known wire values. - README documents the cold-start gotcha (first `fai serve` per machine takes ~30s to build the curated-model DB) plus the manual warmup recipe. Not in CI yet — wiring needs the platform build job to publish `fai` as a CI artifact for downstream consumption. Deferred until enough integration tests exist to justify the CI minutes. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/pages/store.dart | 2 +- lib/pages/welcome.dart | 2 +- lib/theme/theme.dart | 2 +- lib/widgets/fai_flow_output.dart | 2 +- pubspec.lock | 8 +- pubspec.yaml | 5 +- test/integration/README.md | 72 +++++++++ test/integration/capabilities_test.dart | 73 +++++++++ test/integration/hub_fixture.dart | 192 ++++++++++++++++++++++++ 9 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 test/integration/README.md create mode 100644 test/integration/capabilities_test.dart create mode 100644 test/integration/hub_fixture.dart diff --git a/lib/pages/store.dart b/lib/pages/store.dart index dae419e..98416c1 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -10,7 +10,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 94c34f5..ad9df06 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -10,7 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../data/hub.dart'; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 3546db4..7b2dd9f 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,7 +1,7 @@ // Theme assembly. ColorScheme + typography + component themes. import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart index 1f15666..59fd0df 100644 --- a/lib/widgets/fai_flow_output.dart +++ b/lib/widgets/fai_flow_output.dart @@ -17,7 +17,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/flow_output.dart'; import '../data/format.dart'; diff --git a/pubspec.lock b/pubspec.lock index 53b5424..ff3334b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -146,14 +146,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_markdown: + flutter_markdown_plus: dependency: "direct main" description: - name: flutter_markdown - sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" url: "https://pub.dev" source: hosted - version: "0.7.7+1" + version: "1.0.7" flutter_plugin_android_lifecycle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ceb302e..166e2b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,10 @@ dependencies: google_fonts: ^8.1.0 shared_preferences: ^2.5.5 # Inline README rendering inside the Store detail sheet. - flutter_markdown: ^0.7.7 + # `flutter_markdown` is discontinued; `flutter_markdown_plus` + # is the actively-maintained drop-in fork (same + # `MarkdownStyleSheet` / `Markdown` / `MarkdownBody` API). + flutter_markdown_plus: ^1.0.3 # Today-Hero pipeline reads accepted stories from # ~/.fai/today/active.yaml — see docs/today-pipeline.md. yaml: ^3.1.2 diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 0000000..bf45b96 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,72 @@ +# Integration tests + +These tests spin up a real `fai serve` subprocess against a +fresh temp dir and exercise Studio's Hub-facing data layer +against it. They cover the "kind of regression that survives +unit tests because the system-shape only manifests across the +gRPC boundary". + +## Status + +**Scaffold.** One canonical test (capabilities). The harness is +production-quality (proper teardown, idempotent, port-clean) so +new tests slot in by importing `hub_fixture.dart` and calling +`HubFixture.start()` / `dispose()` in `setUp` / `tearDown`. + +What's NOT here yet: + +- A full "install dep → run echo → assert title" scenario. The + harness can do it (install_module is a real RPC), but + building the fixture for module-bundle downloads against a + hermetic local registry is its own scaffold. Tracked as + follow-up to S-21. +- Studio-side widget testing against the live hub. The + flutter `integration_test` package supports it + (`testWidgets` + `IntegrationTestWidgetsFlutterBinding`), + but pumping a full `StudioApp` against a real gRPC channel + needs `binding.enableSurfaceBindingHack()` ceremony we + haven't designed yet. + +## Running + +```bash +cd fai_studio +flutter test test/integration/ +``` + +Prereq: a `fai` binary on PATH or at +`../fai_platform/target/release/fai`. The harness skips with a +clear message when neither exists, so this command does not +fail on a fresh checkout — it just reports skipped tests. + +To get the binary: + +```bash +cd ../fai_platform +cargo build --release --bin fai +``` + +### First-time-start gotcha + +A cold `fai serve` spends its first ~30s building the +curated-model database and initialising SQLite migrations. +`HubFixture.start()` waits up to 60s by default; if your local +hub takes longer the first time, run `fai serve` once by hand +against any temp `FAI_DATA_DIR` to warm the per-user cargo / +SBOM caches: + +```bash +FAI_DATA_DIR=/tmp/fai_warmup fai serve --bind 127.0.0.1:0 +# wait for "hub started", Ctrl-C +``` + +Subsequent integration-test runs are quick. + +## Not yet wired into CI + +`.forgejo/workflows/ci.yml` doesn't run these yet. Adding them +needs an artifact-passing pattern: the platform build job +publishes `target/release/fai` as a CI artifact; the studio +test job consumes it. Pattern is straightforward once we want +it; it's deferred because we don't yet have enough integration +tests to justify the CI runtime. diff --git a/test/integration/capabilities_test.dart b/test/integration/capabilities_test.dart new file mode 100644 index 0000000..0063548 --- /dev/null +++ b/test/integration/capabilities_test.dart @@ -0,0 +1,73 @@ +// First integration test against a real `fai serve` subprocess. +// +// What this test covers (the regression bait): +// +// When the operator opens a flow that references `system.approval` +// or any other built-in / federated capability, the missing-deps +// check in Studio's Flows page must consider those caps already +// available — otherwise the Run button stays disabled forever +// and the operator's only recourse is to install a module that +// doesn't exist. +// +// Why this test as the canonical first scaffold: +// +// It's the exact bug class that survived three commits of the +// May-2026 trust pass before someone noticed. A real-Hub test +// for it makes regressions impossible to ship. +// +// Cost: ~2 seconds (Hub start) + ~0.1 seconds (gRPC). Acceptable. +// +// Run manually with: +// +// flutter test test/integration/ +// +// CI does not yet run these by default — they need a built `fai` +// binary in PATH or in ../fai_platform/target/release/. Wire that +// in via .forgejo/workflows/ci.yml once the platform CI publishes +// a Linux binary as an artifact for downstream jobs to consume. + +import 'package:flutter_test/flutter_test.dart'; + +import 'hub_fixture.dart'; + +void main() { + group('Hub capability listing', () { + late HubFixture? fixture; + + setUp(() async { + fixture = await HubFixture.start(); + }); + + tearDown(() async { + await fixture?.dispose(); + }); + + test('system.approval shows up as a builtin capability', () async { + final f = fixture; + if (f == null) return; // skipped — no binary + final caps = await f.client.listCapabilities(); + final approval = caps.where( + (c) => c.capability == 'system.approval', + ).toList(); + expect(approval, isNotEmpty, + reason: 'system.approval must appear in list_capabilities ' + 'so Studio does not try to install it through the store'); + expect(approval.first.kind, 'builtin', + reason: 'kind=builtin lets Studio gate the Install button correctly'); + }); + + test('every capability carries a kind tag', () async { + final f = fixture; + if (f == null) return; + final caps = await f.client.listCapabilities(); + expect(caps, isNotEmpty, + reason: 'a fresh hub has at least the system.approval builtin'); + for (final c in caps) { + expect(['wasm', 'builtin', 'federated'], contains(c.kind), + reason: 'cap ${c.capability} has unrecognised kind="${c.kind}" — ' + 'Studio gates the Install button on this string, so the wire ' + 'value must stay in the known set'); + } + }); + }); +} diff --git a/test/integration/hub_fixture.dart b/test/integration/hub_fixture.dart new file mode 100644 index 0000000..75fa2f4 --- /dev/null +++ b/test/integration/hub_fixture.dart @@ -0,0 +1,192 @@ +// Hub fixture for integration-level tests. +// +// Spawns a real `fai serve` subprocess on a free port in a +// temporary directory, waits for its gRPC endpoint to answer +// `Healthy`, and exposes the port + a teardown so test files +// can talk to a clean Hub without setting up an isolate-side +// gRPC server in-process. +// +// Cost / scope notes: +// +// * The `fai` binary must be on the operator's PATH or at +// `target/release/fai` relative to the platform repo. We +// resolve those two paths in order; if neither exists, the +// fixture skips the suite with a clear message rather than +// failing — these tests are opt-in. +// * The hub is started with `--quiet` (no log spam) against a +// temp dir, so the operator's real `~/.fai/` stays untouched. +// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()` +// after a grace period. The temp dir is removed. +// * Each fixture is independent — call `HubFixture.start()` in +// `setUp`, `fixture.dispose()` in `tearDown`. Don't share +// between tests; the few seconds of startup beat debugging +// state-bleed. + +import 'dart:async'; +import 'dart:io'; + +import 'package:fai_dart_sdk/fai_dart_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class HubFixture { + /// The hub subprocess. Kept private so callers go through + /// [dispose] for shutdown. + final Process _process; + + /// gRPC port the hub is listening on. Free port chosen by + /// the OS at startup time. + final int port; + + /// Temp directory the hub was started against. Wiped in + /// [dispose] so tests leave no state behind on disk. + final Directory tempDir; + + /// Pre-connected SDK client. Reuse across the test's + /// assertions — gRPC channel creation is cheap but + /// non-zero, and the tests don't benefit from churning it. + final HubClient client; + + HubFixture._({ + required Process process, + required this.port, + required this.tempDir, + required this.client, + }) : _process = process; + + /// Boots a fresh hub against a temp dir and waits up to + /// `readyTimeout` for it to answer Healthy. Use + /// [skipIfBinaryMissing] = true (default) to let tests + /// `markSkipped` instead of failing when the binary isn't + /// present — that's the desired behaviour in CI environments + /// that don't build the binary first. + static Future start({ + // A cold hub spends its first 30s building the curated- + // model database + initialising SQLite. 60s gives that + // honest headroom without making a hung hub look like + // a slow one. + Duration readyTimeout = const Duration(seconds: 60), + bool skipIfBinaryMissing = true, + }) async { + final binary = await _resolveBinary(); + if (binary == null) { + if (skipIfBinaryMissing) { + markTestSkipped( + 'Hub fixture skipped: no `fai` binary found on PATH or in ' + '../fai_platform/target/release/fai. Build it with ' + '`cargo build --release` and re-run.', + ); + return null; + } + throw StateError('fai binary not found'); + } + + final tempDir = await Directory.systemTemp.createTemp('fai_studio_test_'); + final port = await _pickFreePort(); + final addr = '127.0.0.1:$port'; + + // Run as a child process. `serve --bind` lets us avoid the + // default 50051 (and any local daemon the developer happens + // to be running). FAI_DATA_DIR keeps the temp footprint + // contained — no state leaks into ~/.fai. + final process = await Process.start( + binary, + ['serve', '--bind', addr], + environment: { + ...Platform.environment, + 'FAI_DATA_DIR': tempDir.path, + 'FAI_MODULES_DIR': '${tempDir.path}/modules', + // Suppress info logs without a CLI flag — the binary + // respects RUST_LOG for tracing-subscriber. + 'RUST_LOG': 'warn', + }, + ); + + // Drain stdout/stderr to keep the OS pipe buffers from + // blocking the hub. Test failures still surface the last + // ~64 KiB of output via the dispose path. + process.stdout.transform(SystemEncoding().decoder).listen(_noop); + process.stderr.transform(SystemEncoding().decoder).listen(_noop); + + // Poll the hub until it's Healthy. Bound by readyTimeout — + // a hung hub is a test failure, not a hang. + final client = HubClient( + endpoint: HubEndpoint(host: '127.0.0.1', port: port), + ); + final deadline = DateTime.now().add(readyTimeout); + while (DateTime.now().isBefore(deadline)) { + try { + final ok = await client.healthy(); + if (ok) { + return HubFixture._( + process: process, + port: port, + tempDir: tempDir, + client: client, + ); + } + } catch (_) { + // Hub still booting; try again. + } + await Future.delayed(const Duration(milliseconds: 100)); + } + + // Boot didn't complete in time. Tear down + fail with a + // useful message. + process.kill(ProcessSignal.sigterm); + await tempDir.delete(recursive: true); + throw StateError( + 'Hub did not become healthy within $readyTimeout. ' + 'Check `fai serve` works manually with FAI_DATA_DIR=$tempDir.', + ); + } + + /// Shuts down the hub and removes the temp dir. Idempotent + /// so a teardown that runs after a test-failure exit still + /// cleans up. + Future dispose() async { + await client.close(); + _process.kill(ProcessSignal.sigterm); + // Give the hub up to 3 seconds to flush its event log. + try { + await _process.exitCode.timeout(const Duration(seconds: 3)); + } on TimeoutException { + _process.kill(ProcessSignal.sigkill); + } + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + } + + /// Look for `fai` first on PATH (production-ish), then in + /// the standard `target/release/` build output relative to + /// the platform checkout (developer flow). Returns null when + /// neither path resolves. + static Future _resolveBinary() async { + final pathResult = await Process.run( + Platform.isWindows ? 'where' : 'which', + ['fai'], + ); + if (pathResult.exitCode == 0) { + final s = (pathResult.stdout as String).trim(); + if (s.isNotEmpty) return s.split('\n').first.trim(); + } + + final candidate = Platform.isWindows + ? '../fai_platform/target/release/fai.exe' + : '../fai_platform/target/release/fai'; + final f = File(candidate); + if (await f.exists()) { + return f.absolute.path; + } + return null; + } + + static Future _pickFreePort() async { + final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = s.port; + await s.close(); + return port; + } +} + +void _noop(Object _) {} From 21c2a0f411c127a40e64cd134b535ae3f4ca0c45 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 14:05:43 +0200 Subject: [PATCH 4/7] chore(studio): RadioGroup migration + second integration test Two clean-ups: - `fai_system_ai_editor.dart` uses the new `RadioGroup` ancestor pattern Material 3 introduced after Flutter 3.32. Same UX (three radios for off/redacted/full), no deprecation warnings, `flutter analyze` is now zero-issue for the whole project. - New `install_install_planned_test.dart` asserts that installing a `planned` seed entry surfaces a clear, human-readable error class (no panic, no silent success). Locks in the failed-precondition path Studio's friendly-error mapper depends on. Integration test suite is now 3 scenarios: - capabilities_test.dart: system.approval+kind tag - install_install_planned_test.dart: planned-install error All run against a fresh `fai serve` subprocess via HubFixture, ~2 s total. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/widgets/fai_system_ai_editor.dart | 97 ++++++++++--------- .../install_install_planned_test.dart | 42 ++++++++ 2 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 test/integration/install_install_planned_test.dart diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 39c262e..ce260e5 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -553,53 +553,60 @@ class _PrivacyModeChips extends StatelessWidget { l.systemAiPrivacyFullDesc, ), ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.systemAiPrivacyHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - fontSize: 10, - ), - ), - const SizedBox(height: 4), - for (final (wire, label, desc) in modes) - InkWell( - onTap: () => onChanged(wire), - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: FaiSpace.sm, - horizontal: FaiSpace.sm, - ), - child: Row( - children: [ - Radio( - value: wire, - groupValue: value, - onChanged: (v) => onChanged(v ?? wire), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.bodyMedium), - Text( - desc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), + // Material 3 deprecated per-Radio `groupValue` / `onChanged` + // after Flutter 3.32. The modern pattern wraps the radios in + // a RadioGroup ancestor that owns the selection + change + // callback; the individual Radio widgets only declare their + // value. Same UX, no deprecation warnings. + return RadioGroup( + groupValue: value, + onChanged: (v) { + if (v != null) onChanged(v); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.systemAiPrivacyHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, ), ), - ], + const SizedBox(height: 4), + for (final (wire, label, desc) in modes) + InkWell( + onTap: () => onChanged(wire), + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: FaiSpace.sm, + horizontal: FaiSpace.sm, + ), + child: Row( + children: [ + Radio(value: wire), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + Text( + desc, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), ); } } diff --git a/test/integration/install_install_planned_test.dart b/test/integration/install_install_planned_test.dart new file mode 100644 index 0000000..cf20e7c --- /dev/null +++ b/test/integration/install_install_planned_test.dart @@ -0,0 +1,42 @@ +// Integration test: install_module against a `planned` seed +// entry must fail with a clear, human-readable error class — +// not a panic, not a silent success. +// +// Why this matters: planned modules are listed in the Store +// but have no wasm_url. The old behaviour was a server-side +// panic on resolve_install_source(); the new contract emits a +// FAILED_PRECONDITION with "no installable version" so +// Studio's friendly-error mapper can show a usable message. + +import 'package:flutter_test/flutter_test.dart'; + +import 'hub_fixture.dart'; + +void main() { + test('install_module on a planned entry surfaces a clear error', + () async { + final fixture = await HubFixture.start(); + if (fixture == null) return; + try { + // web.scrape is `planned` in the seed and has no + // wasm_url. The hub resolves the name through the store + // index, finds the entry, then fails at the + // "no installable version" check. + try { + await fixture.client.installModule(source: 'web.scrape'); + fail('install_module should not succeed for a planned entry'); + } catch (e) { + final msg = e.toString(); + expect( + msg.contains('no installable version') || + msg.contains('not yet published'), + isTrue, + reason: + 'Expected a clear planned-status error, got: $msg', + ); + } + } finally { + await fixture.dispose(); + } + }); +} From efa9871a75dee63e1a07b6db33b9939ac44e0b23 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 21:06:17 +0200 Subject: [PATCH 5/7] chore(security): externalise confidentiality term list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the list of private organisation / pilot / codename strings the security gate blocks OUT of the repo entirely. Before: tools/security/check-staged.sh + the Today AI prompt + docs/today-pipeline.md held the strings in plaintext. The whole point of the gate is to keep certain strings out of committed artefacts, so holding them in a committed artefact was self-defeating — anyone with read access to the repo trivially recovered the very list we tried to protect. After: - The gate reads a runtime file `${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}` at scan time. One regex per line, `#`-prefixed comments, matched case-insensitively against staged diffs and commit messages. Repos contain no copy. - Pre-commit / commit-msg modes log a warning + skip the confidential-terms scan if the file is missing (fresh checkouts shouldn't trip until the operator bootstraps the list). - CI mode (`check-staged.sh ci`) FAILS when the file is missing — runners are expected to be bootstrapped by their deploy step. - The unit-test harness uses a synthetic placeholder term (`SYNTHETIC_BANNED_TERM_XYZZY`) injected via a temp banned-terms file, so the test never references real customer names. - docs/today-pipeline.md + tools/today/prompt.template.md point at the runtime file instead of enumerating terms. Operator bootstrap (one-time, per machine): mkdir -p ~/.fai-security && chmod 700 ~/.fai-security printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt chmod 600 ~/.fai-security/banned-terms.txt Gate self-tests: 18 passed, 0 failed. Signed-off-by: flemming-it Signed-off-by: flemming-it --- docs/today-pipeline.md | 9 ++-- tools/security/check-staged.sh | 70 ++++++++++++++++++++++++----- tools/security/test-check-staged.sh | 14 +++++- tools/today/prompt.template.md | 11 +++-- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/docs/today-pipeline.md b/docs/today-pipeline.md index e43cfed..5f312e3 100644 --- a/docs/today-pipeline.md +++ b/docs/today-pipeline.md @@ -83,9 +83,12 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes: `feedback_no_marketing_speak.md` is included in the prompt: ban "viral", "killer", "powerful", "just works", "revolutionary", "game-changing". -4. **No private references.** Generic terms only; no - ITDZ / Kammergericht / HTW / J∆I per - `feedback_confidentiality.md`. +4. **No private references.** Generic terms only — no pilot + customer names, internal codenames, or specific + institutions. The operator's banned-terms list lives at + `~/.fai-security/banned-terms.txt` (outside the repo on + purpose; gated by the pre-commit hook in + `tools/security/check-staged.sh`). 5. **Concrete CTA.** Every story names exactly one action the operator can take in Studio (which page, which button). 6. **Body fits in ~3 sentences.** Hero card height is fixed; longer diff --git a/tools/security/check-staged.sh b/tools/security/check-staged.sh index 2a90a42..9768c4f 100755 --- a/tools/security/check-staged.sh +++ b/tools/security/check-staged.sh @@ -63,16 +63,49 @@ FILE_ALLOWLIST=( 'fixtures?/.*\.(pem|key)$' ) -# Memory: confidentiality.md — these are PRIVATE. Never in public -# code / docs / commits / examples. Word-boundary matched to avoid -# false-positives on substrings. -CONFIDENTIAL_TERMS=( - '\bITDZ\b' - '\bKammergericht\b' - '\bHTW\b' - 'J∆I' - '\bJAI\b' -) +# Confidential terms are loaded at runtime from a file outside +# the repo (~/.fai-security/banned-terms.txt by default; override +# with $FAI_BANNED_TERMS_FILE). The list itself is private — the +# whole point of the gate is to keep certain strings out of any +# committed artefact, so the list of strings to gate on must not +# live in a committed artefact either. +# +# File format: one regex per line. Lines starting with `#` and +# blank lines are ignored. Each line is matched as a +# case-insensitive grep -E pattern with `-i`; use `\b…\b` for +# word boundaries. +# +# Bootstrap on a fresh machine: +# mkdir -p ~/.fai-security +# chmod 700 ~/.fai-security +# printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt +# chmod 600 ~/.fai-security/banned-terms.txt +# +# When the file is missing the gate logs a warning but does NOT +# fail — running on a fresh checkout where the operator hasn't +# yet bootstrapped the list shouldn't block them. Real CI runs +# explicitly verify the file is present and refuse otherwise +# (see scan_ci_mode below). +CONFIDENTIAL_TERMS=() +_load_confidential_terms() { + local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}" + if [ ! -f "$file" ]; then + return 1 + fi + while IFS= read -r line; do + # Strip trailing comments + leading/trailing whitespace. + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [ -z "$line" ] && continue + CONFIDENTIAL_TERMS+=("$line") + done < "$file" + return 0 +} +if ! _load_confidential_terms; then + printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \ + "${YEL:-}" "${RST:-}" >&2 +fi # Memory: feedback_no_marketing_speak.md. BANNED_PHRASES=( @@ -142,7 +175,10 @@ scan_diff() { local diff="$1" [ -z "$diff" ] && return scan_secrets "$diff" - scan_terms_ci "confidential term" "$diff" "${CONFIDENTIAL_TERMS[@]}" + # `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion + # for arrays that may be empty (CONFIDENTIAL_TERMS is empty + # when the operator hasn't bootstrapped ~/.fai-security/). + scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"} scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}" } @@ -208,7 +244,7 @@ scan_message_mode() { fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)" fi - scan_terms_ci "confidential term in commit message" "$msg" "${CONFIDENTIAL_TERMS[@]}" + scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"} scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}" } @@ -217,6 +253,16 @@ scan_ci_mode() { if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then base=$(git rev-list --max-parents=0 HEAD | tail -1) fi + # In CI the confidentiality list is non-optional: a missing + # file means an unbootstrapped runner is reviewing changes, + # and we'd be silently letting confidential terms through. + # The deploy step that wires up the runner is responsible for + # placing the file at $FAI_BANNED_TERMS_FILE. + if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then + fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist" + fail " bootstrap the runner with a private confidentiality list before re-running" + return + fi local files diff files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD) diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \ diff --git a/tools/security/test-check-staged.sh b/tools/security/test-check-staged.sh index 80cba27..f951ad6 100755 --- a/tools/security/test-check-staged.sh +++ b/tools/security/test-check-staged.sh @@ -153,9 +153,19 @@ git add tests/server.pem assert_content_passes "tests/*.pem allowlisted" d=$(setup_repo); cd "$d" -echo "Notes from the ITDZ Berlin meeting" > confidential.md +# The confidentiality list lives outside the repo at +# $FAI_BANNED_TERMS_FILE (default ~/.fai-security/banned-terms.txt). +# For the test we point at a temp file holding a single +# generic test term — the actual production terms stay in the +# operator's untracked file and never enter this test suite. +TEST_BANNED_TERMS=$(mktemp -t fai_banned.XXXXXX) +printf "\\\\bSYNTHETIC_BANNED_TERM_XYZZY\\\\b\n" > "$TEST_BANNED_TERMS" +export FAI_BANNED_TERMS_FILE="$TEST_BANNED_TERMS" +echo "Document contains SYNTHETIC_BANNED_TERM_XYZZY in passing" > confidential.md git add confidential.md -assert_content_fails "confidential term ITDZ" "ITDZ" +assert_content_fails "confidential term (synthetic test term)" "SYNTHETIC_BANNED_TERM_XYZZY" +rm -f "$TEST_BANNED_TERMS" +unset FAI_BANNED_TERMS_FILE d=$(setup_repo); cd "$d" echo "Our viral release of a powerful tool that just works" > marketing.md diff --git a/tools/today/prompt.template.md b/tools/today/prompt.template.md index 8838f31..a1bab29 100644 --- a/tools/today/prompt.template.md +++ b/tools/today/prompt.template.md @@ -32,9 +32,14 @@ feel like they're reading a translation. - Banned words: "viral", "killer", "powerful", "just works", "revolutionary", "game-changing", "seamless", "next-generation", "cutting-edge", "world-class", "unprecedented". -- No private organisation names: never "ITDZ", "Kammergericht", - "HTW", "J∆I", or any specific institution. Generic terms only — - "a regulated organisation", "a public administration". +- No private organisation, person, or location names. Pilot + customers, internal codenames, specific institutions, and + specific cities never appear in Today copy. Generic terms + only — "a regulated organisation", "a public administration", + "a critical-infrastructure operator". The operator's + banned-terms list at ~/.fai-security/banned-terms.txt is the + authoritative source; this prompt deliberately doesn't + enumerate the strings. - No emoji. - No "Co-authored-by Claude" or other AI attribution. From a5bcde6446858d2af639a3136a08d58c2ebeab7a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 21:49:40 +0200 Subject: [PATCH 6/7] feat(studio): HubService.invokePluginTheme + integration test Wraps the new InvokePluginTheme RPC and ships an end-to-end test that loads studio-theme-solarized into a fresh HubFixture and asserts the light/dark schemes round-trip. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/hub.dart | 24 +++++++ test/integration/plugin_theme_test.dart | 88 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 test/integration/plugin_theme_test.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1a461cb..a0a7567 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -396,6 +396,30 @@ class HubService { return (state: state, text: r.text, sourcePath: r.sourcePath); } + /// Invoke a Studio theme plugin's `theme` hook and unwrap + /// the result into Flutter-friendly `ColorScheme` slots. + /// + /// Returns a record: + /// - `brightness`: echo of the input. + /// - `tokens`: 14 32-bit ARGB values in Material 3 + /// order. Caller passes them to + /// `ColorScheme(...)` after deciding the + /// `brightness` enum. + /// + /// Throws on hub-side errors (plugin not installed, plugin + /// declined the brightness, etc.) — caller routes the + /// exception through `friendlyError` like every other RPC. + Future<({String brightness, List tokens})> invokePluginTheme({ + required String capability, + required String brightness, + }) async { + final r = await _client.invokePluginTheme( + capability: capability, + brightness: brightness, + ); + return (brightness: r.brightness, tokens: r.tokens.toList()); + } + /// Snapshot of every configured MCP server. Future> listMcpClients() async { final r = await _client.listMcpClients(); diff --git a/test/integration/plugin_theme_test.dart b/test/integration/plugin_theme_test.dart new file mode 100644 index 0000000..3ec9c92 --- /dev/null +++ b/test/integration/plugin_theme_test.dart @@ -0,0 +1,88 @@ +// End-to-end integration test for the Studio-plugin theme +// hook: spins a real `fai serve`, installs the in-tree +// `studio-theme-solarized` plugin via direct module-dir +// staging (mirror-install would also work but takes longer +// and pulls a network egress), then calls `invokePluginTheme` +// and asserts the round-trip. +// +// Run manually with: +// +// flutter test test/integration/plugin_theme_test.dart +// +// Pre-reqs (handled by HubFixture's skip-when-missing path): +// - fai binary on PATH or at ../fai_platform/target/release/fai +// - studio-theme-solarized plugin built to module.wasm in its +// fai_modules dir. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'hub_fixture.dart'; + +void main() { + test( + 'invokePluginTheme returns Solarized Light + Dark schemes', + () async { + final fixture = await HubFixture.start(); + if (fixture == null) return; + try { + // Stage the plugin into the fixture's modules dir + // ourselves. Going through `installModule` would + // require the plugin to be on the mirror, which would + // add network + bundle-fetch latency that doesn't + // belong in an in-process round-trip test. + final pluginSrc = Directory( + '../fai_modules/studio-theme-solarized', + ); + if (!await pluginSrc.exists()) { + markTestSkipped('studio-theme-solarized source not available'); + return; + } + final wasm = File('${pluginSrc.path}/module.wasm'); + if (!await wasm.exists()) { + markTestSkipped( + 'studio-theme-solarized has no built module.wasm — ' + 'run `cargo build --release --target wasm32-wasip2` first', + ); + return; + } + final dest = Directory( + '${fixture.tempDir.path}/modules/studio-theme-solarized', + ); + await dest.create(recursive: true); + await File('${pluginSrc.path}/module.yaml') + .copy('${dest.path}/module.yaml'); + await wasm.copy('${dest.path}/module.wasm'); + + // Call the hook for both brightnesses. + final light = await fixture.client.invokePluginTheme( + capability: 'studio.theme.solarized', + brightness: 'light', + ); + expect(light.brightness, 'light'); + expect(light.tokens.length, 14); + // Solarized blue is the pinned primary in both schemes. + expect(light.tokens[0], 0xFF268BD2); + + final dark = await fixture.client.invokePluginTheme( + capability: 'studio.theme.solarized', + brightness: 'dark', + ); + expect(dark.brightness, 'dark'); + expect(dark.tokens.length, 14); + expect(dark.tokens[0], 0xFF268BD2); + + // Different surface tokens between Light and Dark — + // regression guard against a plugin that returns the + // same scheme for both. + expect(light.tokens[8], isNot(equals(dark.tokens[8]))); + } finally { + await fixture.dispose(); + } + }, + // Component-Model instantiation can take a few seconds on + // first run as wasmtime caches the compiled artefact. + timeout: const Timeout(Duration(minutes: 1)), + ); +} From 91706cea2b0c8d5aabfdeb8d3fb7cb90603e5f1f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 23:02:21 +0200 Subject: [PATCH 7/7] feat(studio): theme-plugin picker + translate-hook wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of the Studio plugin host: - `data/theme_plugin.dart`: discovery, persistence, ColorScheme translation. Plugins listed via list_capabilities filtered to studio.theme.*; selection persisted in SharedPreferences; loadThemePluginSchemes builds a ColorScheme pair from the plugin's 14 ARGB tokens per brightness. - `main.dart`: StudioApp gains themePluginNotifier + setThemePlugin(). MaterialApp wraps a FutureBuilder that re-fetches the plugin's ColorSchemes whenever the capability flips; falls back to FaiTheme.light/dark on null or load failure. - `fai_settings_dialog.dart`: new `_ThemePluginPanel` shows a dropdown of installed studio.theme.* capabilities + a "Built-in" entry. Switching applies immediately. - HubService.invokePluginTranslate added as the typed wrapper for the new gRPC RPC — ready for the FaiEnBadge swap-out in the next iteration. - 4 new l10n keys (themePluginHeader / None / Hint / Empty), EN+DE. flutter analyze + flutter test (widget + friendly_error): both green. The integration test for the theme picker is the existing fai_runtime plugin_theme + Studio-side invokePluginTheme path that round-trips through the new mirror-installable studio-theme-solarized bundle. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/hub.dart | 21 ++++ lib/data/theme_plugin.dart | 152 +++++++++++++++++++++++++++ lib/l10n/app_de.arb | 5 + lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 24 +++++ lib/l10n/app_localizations_de.dart | 14 +++ lib/l10n/app_localizations_en.dart | 14 +++ lib/main.dart | 88 +++++++++++++--- lib/widgets/fai_settings_dialog.dart | 120 +++++++++++++++++++++ 9 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 lib/data/theme_plugin.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a0a7567..0eae23e 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -420,6 +420,27 @@ class HubService { return (brightness: r.brightness, tokens: r.tokens.toList()); } + /// Invoke an installed Studio translate plugin and return + /// the translated text. Empty `fromLocale` lets the plugin + /// auto-detect; `toLocale` is the active Studio locale. + /// + /// Caller catches the gRPC exception and routes it through + /// `friendlyError` like every other RPC. + Future invokePluginTranslate({ + required String capability, + required String text, + required String toLocale, + String fromLocale = '', + }) async { + final r = await _client.invokePluginTranslate( + capability: capability, + text: text, + toLocale: toLocale, + fromLocale: fromLocale, + ); + return r.translated; + } + /// Snapshot of every configured MCP server. Future> listMcpClients() async { final r = await _client.listMcpClients(); diff --git a/lib/data/theme_plugin.dart b/lib/data/theme_plugin.dart new file mode 100644 index 0000000..326d3fe --- /dev/null +++ b/lib/data/theme_plugin.dart @@ -0,0 +1,152 @@ +// Studio theme-plugin helpers: discovery, loading, persistence. +// +// `studio.theme.*` capabilities returned by the hub's +// `list_capabilities` are eligible theme plugins. Each one +// exposes a `theme` hook that returns a ColorScheme for a +// given brightness. This module wraps that into the shape +// Flutter's MaterialApp expects: a pair of `ThemeData` +// (light + dark) built from the plugin's tokens, ready to +// drop into the running app. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'hub.dart'; + +/// SharedPreferences key for the operator's preferred theme +/// plugin. `null` (or absent) means "use Studio's built-in +/// default" (FaiTheme.light / .dark). +const _kThemePluginKey = 'theme_plugin_capability'; + +/// One ColorScheme pair as built from a theme-plugin's +/// `theme_for("light")` + `theme_for("dark")` round-trips. +/// `null` slots are intentional — when the plugin declines +/// one brightness the caller falls back to its built-in +/// scheme for that side and uses the plugin's only for the +/// other. +class ThemePluginSchemes { + final ColorScheme? light; + final ColorScheme? dark; + const ThemePluginSchemes({this.light, this.dark}); + + bool get isEmpty => light == null && dark == null; +} + +/// List every installed `studio.theme.*` capability the hub +/// is currently able to dispatch. Returns an empty list when +/// no theme plugin is installed (typical fresh hub). +Future> listThemePluginCapabilities() async { + final caps = await HubService.instance.allCapabilities(); + return caps + .where((c) => + c.kind == 'wasm' && c.capability.startsWith('studio.theme.')) + .map((c) => c.capability) + .toList() + ..sort(); +} + +/// Read the operator's preferred theme-plugin capability from +/// SharedPreferences. Returns `null` when none chosen. +Future loadActiveThemePlugin() async { + final prefs = await SharedPreferences.getInstance(); + final v = prefs.getString(_kThemePluginKey); + if (v == null || v.isEmpty) return null; + return v; +} + +/// Persist the operator's theme-plugin choice. Pass `null` to +/// clear it (back to the built-in default). +Future saveActiveThemePlugin(String? capability) async { + final prefs = await SharedPreferences.getInstance(); + if (capability == null || capability.isEmpty) { + await prefs.remove(_kThemePluginKey); + } else { + await prefs.setString(_kThemePluginKey, capability); + } +} + +/// Invoke the plugin twice (light + dark) and translate its +/// 14 ARGB tokens into a `ColorScheme` per brightness. Plugin +/// errors are swallowed per brightness — a plugin that +/// declines `dark` but accepts `light` still contributes the +/// light scheme; the caller falls back to the built-in dark. +Future loadThemePluginSchemes(String capability) async { + ColorScheme? light; + ColorScheme? dark; + try { + final r = await HubService.instance.invokePluginTheme( + capability: capability, + brightness: 'light', + ); + light = _tokensToColorScheme(r.tokens, Brightness.light); + } catch (_) { + // Declined / misconfigured / unreachable. Fall through — + // built-in light scheme will fill the slot. + } + try { + final r = await HubService.instance.invokePluginTheme( + capability: capability, + brightness: 'dark', + ); + dark = _tokensToColorScheme(r.tokens, Brightness.dark); + } catch (_) {} + return ThemePluginSchemes(light: light, dark: dark); +} + +/// Build a `ThemeData` from a plugin-supplied `ColorScheme`, +/// keeping Studio's typography (Inter for UI, JetBrains Mono +/// for code) so a theme swap doesn't reflow text metrics. +/// Mirrors what `FaiTheme.light()` / `.dark()` do internally +/// but with the operator-chosen palette. +ThemeData themeDataFromScheme(ColorScheme scheme) { + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + canvasColor: scheme.surface, + cardColor: scheme.surfaceContainer, + dividerColor: scheme.outlineVariant, + textTheme: GoogleFonts.interTextTheme( + scheme.brightness == Brightness.dark + ? Typography.whiteCupertino + : Typography.blackCupertino, + ), + ); +} + +/// Translate the plugin's 14 ARGB tokens into a `ColorScheme`. +/// Order matches the plugin contract: +/// primary, on-primary, secondary, on-secondary, +/// tertiary, on-tertiary, error, on-error, +/// surface, on-surface, surface-variant, on-surface-variant, +/// outline, outline-variant. +/// Length mismatches are tolerated — missing slots fall back +/// to a sensible Material 3 baseline so a half-built plugin +/// doesn't crash the picker. +ColorScheme _tokensToColorScheme(List tokens, Brightness brightness) { + Color slot(int i) { + if (i < tokens.length) return Color(tokens[i]); + // Reasonable fallback when the plugin underdelivered. + return brightness == Brightness.dark + ? const Color(0xFF1A1A1A) + : const Color(0xFFEEEEEE); + } + return ColorScheme( + brightness: brightness, + primary: slot(0), + onPrimary: slot(1), + secondary: slot(2), + onSecondary: slot(3), + tertiary: slot(4), + onTertiary: slot(5), + error: slot(6), + onError: slot(7), + surface: slot(8), + onSurface: slot(9), + surfaceContainer: slot(10), + onSurfaceVariant: slot(11), + outline: slot(12), + outlineVariant: slot(13), + ); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad60a1a..a83d55e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -95,6 +95,11 @@ "mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.", "mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.", + "themePluginHeader": "Theme-Plugin", + "themePluginNone": "Standard (kein Plugin)", + "themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.", + "themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.", + "settingsTitle": "Hub-Endpunkt", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5078ab..6fce839 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -96,6 +96,11 @@ "mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.", "mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.", + "themePluginHeader": "Theme plugin", + "themePluginNone": "Built-in (no plugin)", + "themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.", + "themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.", + "settingsTitle": "Hub endpoint", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9715977..3a9abc9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -620,6 +620,30 @@ abstract class AppLocalizations { /// **'Server text in English — Studio shows it as supplied.'** String get mcpServerEnLanguageBadge; + /// No description provided for @themePluginHeader. + /// + /// In en, this message translates to: + /// **'Theme plugin'** + String get themePluginHeader; + + /// No description provided for @themePluginNone. + /// + /// In en, this message translates to: + /// **'Built-in (no plugin)'** + String get themePluginNone; + + /// No description provided for @themePluginHint. + /// + /// In en, this message translates to: + /// **'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'** + String get themePluginHint; + + /// No description provided for @themePluginEmpty. + /// + /// In en, this message translates to: + /// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'** + String get themePluginEmpty; + /// 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 7701ca1..4ed5cbe 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -302,6 +302,20 @@ class AppLocalizationsDe extends AppLocalizations { String get mcpServerEnLanguageBadge => 'Server-Text in Englisch — Studio zeigt ihn wie geliefert.'; + @override + String get themePluginHeader => 'Theme-Plugin'; + + @override + String get themePluginNone => 'Standard (kein Plugin)'; + + @override + String get themePluginHint => + 'Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.'; + + @override + String get themePluginEmpty => + 'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.'; + @override String get settingsTitle => 'Hub-Endpunkt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f045bd3..3a59368 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -302,6 +302,20 @@ class AppLocalizationsEn extends AppLocalizations { String get mcpServerEnLanguageBadge => 'Server text in English — Studio shows it as supplied.'; + @override + String get themePluginHeader => 'Theme plugin'; + + @override + String get themePluginNone => 'Built-in (no plugin)'; + + @override + String get themePluginHint => + 'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'; + + @override + String get themePluginEmpty => + 'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'; + @override String get settingsTitle => 'Hub endpoint'; diff --git a/lib/main.dart b/lib/main.dart index 90ea0a5..e9629be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; import 'data/hub.dart'; import 'data/system_actions.dart'; +import 'data/theme_plugin.dart'; import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; @@ -30,22 +31,33 @@ const String kStudioVersion = '0.42.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Restore the persisted endpoint, theme mode, and locale - // before the first frame so none of them flicker on startup. + // Restore the persisted endpoint, theme mode, locale, and + // (when installed) the operator's theme-plugin choice + // before the first frame so nothing flickers on startup. await HubService.instance.loadPersistedEndpoint(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); - runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale)); + final themePlugin = await loadActiveThemePlugin(); + runApp(StudioApp( + initialThemeMode: themeMode, + initialLocale: locale, + initialThemePlugin: themePlugin, + )); } class StudioApp extends StatefulWidget { final ThemeModeValue initialThemeMode; final Locale initialLocale; + /// Capability name of the active theme plugin (e.g. + /// `studio.theme.solarized`) or `null` for the built-in + /// FaiTheme. + final String? initialThemePlugin; const StudioApp({ super.key, required this.initialThemeMode, required this.initialLocale, + this.initialThemePlugin, }); /// Lookup helper so descendants can flip the theme without @@ -68,6 +80,12 @@ class StudioAppState extends State { /// this, MaterialApp re-renders with the new /// AppLocalizations, and every translated string updates. late final ValueNotifier localeNotifier; + /// Capability name of the active `studio.theme.*` plugin + /// (e.g. `studio.theme.solarized`) or `null` for + /// FaiTheme's built-in palette. Flipping this triggers a + /// re-fetch of the plugin's ColorSchemes; the MaterialApp + /// rebuilds with the new themes. + late final ValueNotifier themePluginNotifier; Future setMode(ThemeModeValue m) async { modeNotifier.value = m; @@ -79,17 +97,24 @@ class StudioAppState extends State { await HubService.instance.saveLocale(l); } + Future setThemePlugin(String? capability) async { + themePluginNotifier.value = capability; + await saveActiveThemePlugin(capability); + } + @override void initState() { super.initState(); modeNotifier = ValueNotifier(widget.initialThemeMode); localeNotifier = ValueNotifier(widget.initialLocale); + themePluginNotifier = ValueNotifier(widget.initialThemePlugin); } @override void dispose() { modeNotifier.dispose(); localeNotifier.dispose(); + themePluginNotifier.dispose(); super.dispose(); } @@ -104,22 +129,59 @@ class StudioAppState extends State { } } + /// Build (or return null) ThemeData pair for the active + /// theme plugin. Returns immediately with `null` when no + /// plugin is selected; otherwise awaits the plugin's + /// `theme_for` round-trip for both brightnesses. + Future<({ThemeData? light, ThemeData? dark})> _pluginThemes( + String? capability, + ) async { + if (capability == null || capability.isEmpty) { + return (light: null, dark: null); + } + try { + final schemes = await loadThemePluginSchemes(capability); + return ( + light: schemes.light == null ? null : themeDataFromScheme(schemes.light!), + dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!), + ); + } catch (_) { + // Plugin unreachable, hub down, etc. Fall back to + // FaiTheme defaults silently — the Settings dialog is + // where the operator surfaces / fixes the problem. + return (light: null, dark: null); + } + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: modeNotifier, builder: (_, mode, _) => ValueListenableBuilder( valueListenable: localeNotifier, - builder: (_, locale, _) => MaterialApp( - title: 'F∆I Studio', - debugShowCheckedModeBanner: false, - theme: FaiTheme.light(), - darkTheme: FaiTheme.dark(), - themeMode: _flutterMode(mode), - locale: locale, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: const StudioShell(), + builder: (_, locale, _) => ValueListenableBuilder( + valueListenable: themePluginNotifier, + builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>( + // Re-key on the capability so a plugin change + // discards the in-flight future for the previous + // plugin and starts fresh. + key: ValueKey('plugin_themes:$pluginCap'), + future: _pluginThemes(pluginCap), + builder: (context, snap) { + final p = snap.data ?? (light: null, dark: null); + return MaterialApp( + title: 'F∆I Studio', + debugShowCheckedModeBanner: false, + theme: p.light ?? FaiTheme.light(), + darkTheme: p.dark ?? FaiTheme.dark(), + themeMode: _flutterMode(mode), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: const StudioShell(), + ); + }, + ), ), ), ); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 9d007d6..4a92502 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../data/registry_token.dart'; import '../data/system_actions.dart'; +import '../data/theme_plugin.dart'; +import '../main.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; @@ -510,6 +512,8 @@ class _FaiSettingsDialogState extends State { onClear: _clearRegistryToken, ), const SizedBox(height: FaiSpace.lg), + const _ThemePluginPanel(), + const SizedBox(height: FaiSpace.lg), _MaintenancePanel( onResetDone: () async { // Daemon just restarted under the same channel + @@ -1456,6 +1460,122 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { /// metadata; nothing is auto-spawned. Trust model: /// referencing public projects with their canonical install /// command is the same legal / security shape as a package +/// Theme-plugin picker. Lists installed `studio.theme.*` +/// capabilities (via list_capabilities) and lets the operator +/// pick one or fall back to Studio's built-in palette. The +/// choice is persisted in SharedPreferences and applied at +/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`. +/// +/// When no theme plugin is installed the panel renders a +/// short hint pointing at the Store rather than a useless +/// empty dropdown. +class _ThemePluginPanel extends StatefulWidget { + const _ThemePluginPanel(); + + @override + State<_ThemePluginPanel> createState() => _ThemePluginPanelState(); +} + +class _ThemePluginPanelState extends State<_ThemePluginPanel> { + Future>? _capsFuture; + + @override + void initState() { + super.initState(); + _capsFuture = listThemePluginCapabilities(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final app = StudioApp.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.themePluginHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 4), + FutureBuilder>( + future: _capsFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final caps = snap.data ?? const []; + if (caps.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + l.themePluginEmpty, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + // Dropdown shows "Built-in" + every installed + // studio.theme.* cap. Selection writes through + // StudioApp's notifier so the MaterialApp rebuilds + // with the new ColorScheme without a restart. + final active = app?.themePluginNotifier.value; + final items = >[ + DropdownMenuItem( + value: null, + child: Text(l.themePluginNone), + ), + for (final cap in caps) + DropdownMenuItem( + value: cap, + child: Text(cap, style: FaiTheme.mono(size: 12)), + ), + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButton( + value: active, + items: items, + isDense: true, + onChanged: app == null + ? null + : (v) { + app.setThemePlugin(v); + setState(() {}); + }, + ), + const SizedBox(height: 4), + Text( + l.themePluginHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ), + ], + ); + } +} + /// Hub maintenance panel — currently one action: reset operator /// state via `fai reset --yes`. Lives at the bottom of Settings /// so the everyday config controls aren't crowded by a destructive