chain-studio/lib/widgets/fai_settings_dialog.dart
flemming-it 28fafce7dc
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): localise Settings sidebar + add inline help docs + Approvals doc
Three operator-UX gaps closed:

  - Settings dialog's six-category sidebar (General / Appearance /
    System AI / Integrations / Security / Maintenance) was
    hardcoded English; now flows through AppLocalizations
    ("Allgemein" / "Darstellung" / "System-KI" / "Integrationen" /
    "Sicherheit" / "Wartung"). Same for the per-panel title +
    description.
  - Per-channel daemon-action labels ('enable autostart' /
    'disable autostart' / 'daemon restart' etc.) and the
    OK / Failed result line in the toast also moved to l10n,
    so the system-action feedback reads as one language.
  - New 'Approvals' doc bundle (en + de) under assets/docs/,
    registered as a fifth doc card on Welcome plus exposed via
    the new public  helper. The
    Approvals, Audit and Doctor app-bars grow a Help icon button
    next to Refresh that opens the matching doc in the existing
    bottom-sheet reader — no extra screen, no learning curve.

Studio bumped to 0.67.0; editor path-override pulls in 0.20.1
(flow-list row polish).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-09 01:57:46 +02:00

2651 lines
80 KiB
Dart

// FaiSettingsDialog — modal for changing the hub endpoint.
// Reads current values from HubService, persists on save via
// HubService.reconnect.
import 'package:fai_client_sdk/fai_client_sdk.dart';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/registry_token.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_error_box.dart';
import 'fai_pill.dart';
import 'fai_system_ai_editor.dart';
import 'theme_picker_grid.dart';
class FaiSettingsDialog extends StatefulWidget {
const FaiSettingsDialog({super.key});
/// Convenience launcher used from the sidebar gear icon.
static Future<bool> show(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => const FaiSettingsDialog(),
);
return ok ?? false;
}
@override
State<FaiSettingsDialog> createState() => _FaiSettingsDialogState();
}
/// Sidebar categories — every Settings panel belongs to exactly
/// one. Order here drives the sidebar order and the
/// first-opened category.
enum _Category { general, appearance, ai, integrations, security, maintenance }
class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
late final TextEditingController _host;
late final TextEditingController _port;
bool _secure = false;
bool _saving = false;
String? _error;
String? _channelToast;
ChannelStatusSnapshot? _channels;
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints;
_Category _category = _Category.general;
/// Length of the configured registry token, or null when the
/// `~/.fai/registry-token` file is missing / empty. Reloaded
/// after save / clear.
int? _registryTokenChars;
/// Length of the configured hub gRPC bearer token, or null
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
/// after save / clear; UI only ever sees the trimmed length.
int? _hubAuthTokenChars;
@override
void initState() {
super.initState();
final ep = HubService.instance.currentEndpoint;
_host = TextEditingController(text: ep.host);
_port = TextEditingController(text: ep.port.toString());
_secure = ep.secure;
_loadChannels();
_loadAiStatus();
_loadMcpClients();
_loadN8nEndpoints();
_loadRegistryToken();
_loadHubAuthToken();
}
Future<void> _loadHubAuthToken() async {
try {
final n = await HubAuthToken.charCount();
if (!mounted) return;
setState(() => _hubAuthTokenChars = n);
} catch (_) {
/* fail-quiet */
}
}
Future<void> _saveHubAuthToken(String token) async {
try {
await HubAuthToken.write(token);
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'auth.hub-token.save', e);
}
}
Future<void> _clearHubAuthToken() async {
try {
await HubAuthToken.clear();
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'auth.hub-token.clear', e);
}
}
Future<void> _loadRegistryToken() async {
try {
final n = await RegistryToken.charCount();
if (!mounted) return;
setState(() => _registryTokenChars = n);
} catch (_) {
/* fail-quiet */
}
}
Future<void> _saveRegistryToken(String token) async {
try {
await RegistryToken.write(token);
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'auth.registry-token.save', e);
}
}
Future<void> _clearRegistryToken() async {
try {
await RegistryToken.delete();
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'auth.registry-token.clear', e);
}
}
Future<void> _loadN8nEndpoints() async {
try {
final list = await HubService.instance.listN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (_) {
/* fail-quiet */
}
}
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
try {
final list = await HubService.instance.addN8nEndpoint(
name: draft.name,
baseUrl: draft.baseUrl,
apiKeyEnv: draft.apiKeyEnv,
description: draft.description,
);
if (!mounted) return;
setState(() => _n8nEndpoints = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.n8n.add', e);
}
}
Future<void> _removeN8nEndpoint(String name) async {
try {
final list = await HubService.instance.removeN8nEndpoint(name);
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.n8n.remove', e);
}
}
Future<void> _refreshN8nEndpoints() async {
try {
final list = await HubService.instance.refreshN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.n8n.refresh', e);
}
}
Future<void> _loadMcpClients() async {
try {
final list = await HubService.instance.listMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _addMcpClient(McpClientDraft draft) async {
try {
final list = await HubService.instance.addMcpClient(
name: draft.name,
endpoint: draft.endpoint,
apiKeyEnv: draft.apiKeyEnv,
description: draft.description,
);
if (!mounted) return;
setState(() => _mcpClients = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.mcp.add', e);
}
}
Future<void> _removeMcpClient(String name) async {
try {
final list = await HubService.instance.removeMcpClient(name);
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.mcp.remove', e);
}
}
Future<void> _refreshMcpClients() async {
try {
final list = await HubService.instance.refreshMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(context, 'settings.mcp.refresh', e);
}
}
Future<void> _loadChannels() async {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
setState(() => _channels = snap);
} catch (_) {
// Silent: channels block stays hidden when the hub is
// unreachable (the endpoint section is the operator's
// path back to a working connection).
}
}
Future<void> _loadAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _aiStatus = s);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _switchChannel(String name) async {
setState(() {
_saving = true;
_channelToast = null;
});
final r = await SystemActions.faiChannelSwitch(name);
if (!mounted) return;
setState(() {
_saving = false;
_channelToast = r.ok
? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}'
: 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}';
});
if (r.ok) await _loadChannels();
}
Future<void> _runDaemon(
String label,
Future<({bool ok, String stdout, String stderr})> Function() action,
) async {
setState(() {
_saving = true;
_channelToast = null;
});
final r = await action();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() {
_saving = false;
_channelToast = r.ok
? '${l.daemonActionResultOk(label)}\n${r.stdout.trim()}'
: '${l.daemonActionResultFailed(label)}\n${(r.stderr.isEmpty ? r.stdout : r.stderr).trim()}';
});
}
Future<void> _connectToChannel(ChannelInfo ch) async {
setState(() {
_host.text = '127.0.0.1';
_port.text = ch.port.toString();
_secure = false;
});
await _save();
}
@override
void dispose() {
_host.dispose();
_port.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
final port = int.tryParse(_port.text.trim());
if (port == null || port <= 0 || port > 65535) {
final l = AppLocalizations.of(context)!;
setState(() {
_saving = false;
_error = l.settingsPortError;
});
return;
}
final endpoint = HubEndpoint(
host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(),
port: port,
secure: _secure,
);
try {
await HubService.instance.reconnect(endpoint);
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
setState(() {
_saving = false;
_error = e.toString();
});
}
}
Widget _buildCategoryPanel(
BuildContext context,
AppLocalizations l,
ThemeData theme,
) {
final children = switch (_category) {
_Category.general => _generalPanel(l, theme),
_Category.appearance => _appearancePanel(l, theme),
_Category.ai => _aiPanel(l, theme),
_Category.integrations => _integrationsPanel(l, theme),
_Category.security => _securityPanel(l, theme),
_Category.maintenance => _maintenancePanel(l, theme),
};
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
FaiSpace.xl,
FaiSpace.xl,
FaiSpace.xl,
FaiSpace.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
Widget _panelTitle(String title, String subtitle, ThemeData theme) {
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
List<Widget> _generalPanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.settingsTitle,
l.settingsHubEndpointHint,
theme,
),
TextField(
controller: _host,
decoration: InputDecoration(
labelText: l.settingsHost,
border: const OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
const SizedBox(height: FaiSpace.md),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: InputDecoration(
labelText: l.settingsPort,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l.settingsTls),
subtitle: Text(
l.settingsTlsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.md),
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
_previewUrl(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
],
),
),
if (_channels != null) ...[
const SizedBox(height: FaiSpace.xl),
Text(
l.channelsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.channelsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
for (final ch in _channels!.channels)
_ChannelRow(
channel: ch,
active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch),
onSwitch: _saving ? null : () => _switchChannel(ch.name),
onEnableAutostart: _saving
? null
: () => _runDaemon(
l.daemonActionEnableAutostart,
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving
? null
: () => _runDaemon(
l.daemonActionDisableAutostart,
() => SystemActions.faiDaemonDisable(ch.name),
),
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
const SizedBox(height: FaiSpace.xl),
const _DefaultScopePanel(),
];
}
List<Widget> _appearancePanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.themePluginHeader,
l.themePluginHint,
theme,
),
const _ThemePluginPanel(),
];
}
List<Widget> _aiPanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.settingsAiPanelTitle,
l.settingsAiPanelBody,
theme,
),
if (_aiStatus == null)
const Center(child: CircularProgressIndicator(strokeWidth: 2))
else
_SystemAiPanel(
status: _aiStatus!,
onEdit: () async {
final updated = await FaiSystemAiEditor.show(context, _aiStatus!);
if (updated != null && mounted) {
setState(() => _aiStatus = updated);
}
},
),
];
}
List<Widget> _integrationsPanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.settingsIntegrationsPanelTitle,
l.settingsIntegrationsPanelBody,
theme,
),
if (_mcpClients == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: FaiSpace.md),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else
_McpClientsPanel(
clients: _mcpClients!,
onAdd: _addMcpClient,
onRemove: _removeMcpClient,
onRefresh: _refreshMcpClients,
),
const SizedBox(height: FaiSpace.xl),
if (_n8nEndpoints != null)
_N8nEndpointsPanel(
endpoints: _n8nEndpoints!,
onAdd: _addN8nEndpoint,
onRemove: _removeN8nEndpoint,
onRefresh: _refreshN8nEndpoints,
),
];
}
List<Widget> _securityPanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.settingsSecurityPanelTitle,
l.settingsSecurityPanelBody,
theme,
),
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.xl),
_HubAuthTokenPanel(
configuredChars: _hubAuthTokenChars,
onSave: _saveHubAuthToken,
onClear: _clearHubAuthToken,
),
];
}
List<Widget> _maintenancePanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
l.settingsMaintenancePanelTitle,
l.settingsMaintenancePanelBody,
theme,
),
_MaintenancePanel(
onResetDone: () async {
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
),
];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.settingsTitle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
contentPadding: EdgeInsets.zero,
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 620),
child: SizedBox(
width: 820,
height: 620,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Sidebar(
current: _category,
onSelect: (c) => setState(() => _category = c),
),
VerticalDivider(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded(child: _buildCategoryPanel(context, l, theme)),
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l.settingsSaveAndConnect),
),
],
);
}
String _previewUrl() {
final scheme = _secure ? 'https' : 'http';
final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
return '$scheme://$host:$port';
}
}
class _ChannelRow extends StatelessWidget {
final ChannelInfo channel;
final bool active;
final VoidCallback? onConnect;
/// Switches the active channel pointer (`fai channel switch`).
/// Distinct from [onConnect], which only re-points Studio's
/// gRPC wire at a different running daemon.
final VoidCallback? onSwitch;
final VoidCallback? onEnableAutostart;
final VoidCallback? onDisableAutostart;
const _ChannelRow({
required this.channel,
required this.active,
required this.onConnect,
required this.onSwitch,
required this.onEnableAutostart,
required this.onDisableAutostart,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
channel.running ? Icons.circle : Icons.circle_outlined,
size: 10,
color: channel.running
? FaiColors.success
: theme.colorScheme.outline,
),
const SizedBox(width: FaiSpace.sm),
SizedBox(
width: 88,
child: Text(
channel.name,
style: FaiTheme.mono(
size: 11,
weight: active ? FontWeight.w600 : FontWeight.w400,
color: theme.colorScheme.onSurface,
),
),
),
SizedBox(
width: 64,
child: Text(
':${channel.port}',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
channel.running ? l.channelsRunning : l.channelsStopped,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: FaiPill(
label: l.channelsActive,
tone: FaiPillTone.success,
),
),
PopupMenuButton<String>(
tooltip: l.channelsActionsTooltip,
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) {
switch (v) {
case 'connect':
onConnect?.call();
break;
case 'switch':
onSwitch?.call();
break;
case 'enable':
onEnableAutostart?.call();
break;
case 'disable':
onDisableAutostart?.call();
break;
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'connect',
enabled: channel.running && onConnect != null,
child: _MenuRow(icon: Icons.link, text: l.channelsConnect),
),
PopupMenuItem(
value: 'switch',
enabled: !active && onSwitch != null,
child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'enable',
enabled: onEnableAutostart != null,
child: _MenuRow(
icon: Icons.play_circle_outline,
text: l.channelsEnableAutostart,
),
),
PopupMenuItem(
value: 'disable',
enabled: onDisableAutostart != null,
child: _MenuRow(
icon: Icons.pause_circle_outline,
text: l.channelsDisableAutostart,
),
),
],
),
],
),
);
}
}
class _MenuRow extends StatelessWidget {
final IconData icon;
final String text;
const _MenuRow({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 14),
const SizedBox(width: FaiSpace.sm),
Text(text),
],
);
}
}
class _SystemAiPanel extends StatelessWidget {
final SystemAiStatus status;
final VoidCallback onEdit;
const _SystemAiPanel({required this.status, required this.onEdit});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.systemAiHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: status.enabled ? l.systemAiEnabled : l.systemAiOff,
tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral,
),
if (status.enabled) ...[
const SizedBox(width: FaiSpace.xs),
FaiPill(
label: status.privacyMode,
tone: status.privacyMode == 'full'
? FaiPillTone.warning
: FaiPillTone.neutral,
),
],
const Spacer(),
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 14),
label: Text(
status.enabled ? l.systemAiEdit : l.systemAiConfigure,
),
),
],
),
const SizedBox(height: 4),
if (status.enabled) ...[
_StatRow(label: 'provider', value: status.provider),
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
_StatRow(label: 'model', value: status.model, mono: true),
if (status.apiKeyEnv.isNotEmpty)
_StatRow(
label: 'api_key_env',
value: '\$${status.apiKeyEnv}',
mono: true,
),
] else ...[
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
l.systemAiOffBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
],
);
}
}
class _StatRow extends StatelessWidget {
final String label;
final String value;
final bool mono;
const _StatRow({required this.label, required this.value, this.mono = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '' : value,
style: mono
? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface)
: theme.textTheme.bodySmall,
),
),
],
),
);
}
}
/// Draft passed back from the add-server dialog. Kept tiny so
/// the panel can stay stateless.
class McpClientDraft {
final String name;
final String endpoint;
final String apiKeyEnv;
final String description;
const McpClientDraft({
required this.name,
required this.endpoint,
required this.apiKeyEnv,
required this.description,
});
}
/// MCP-clients panel — shows every configured server with its
/// last-discovery health, plus Add / Remove / Refresh actions.
/// Configure a server here, the store gains synthetic
/// capabilities for every advertised tool. Mirrors `fai mcp …`
/// on the CLI.
class _McpClientsPanel extends StatelessWidget {
final List<McpClientInfo> clients;
final ValueChanged<McpClientDraft> onAdd;
final ValueChanged<String> onRemove;
final VoidCallback onRefresh;
const _McpClientsPanel({
required this.clients,
required this.onAdd,
required this.onRemove,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.mcpHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: l.mcpServersCount(clients.length),
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: l.mcpRediscoverTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: l.mcpAddTooltip,
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddMcpClientDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
l.mcpHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
if (clients.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Text(
l.mcpEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
for (final c in clients)
_McpClientRow(client: c, onRemove: () => onRemove(c.name)),
],
);
}
}
class _McpClientRow extends StatelessWidget {
final McpClientInfo client;
final VoidCallback onRemove;
const _McpClientRow({required this.client, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = client.healthy;
final dotColor = client.errorKind.isEmpty
? (ok ? FaiColors.success : FaiColors.muted)
: theme.colorScheme.error;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(Icons.circle, size: 8, color: dotColor),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
client.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: FaiSpace.sm),
if (client.toolCount > 0)
FaiPill(
label: l.mcpToolsCount(client.toolCount),
tone: FaiPillTone.success,
)
else if (client.errorKind.isNotEmpty)
FaiPill(
label: client.errorKind,
tone: FaiPillTone.danger,
),
],
),
Text(
client.endpoint,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (client.message.isNotEmpty)
Text(
client.message,
style: theme.textTheme.bodySmall?.copyWith(
color: client.errorKind.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 14),
tooltip: l.mcpRemoveTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
],
),
);
}
}
class _AddMcpClientDialog extends StatefulWidget {
const _AddMcpClientDialog();
static Future<McpClientDraft?> show(BuildContext context) {
return showDialog<McpClientDraft>(
context: context,
builder: (_) => const _AddMcpClientDialog(),
);
}
@override
State<_AddMcpClientDialog> createState() => _AddMcpClientDialogState();
}
class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
final _name = TextEditingController();
final _endpoint = TextEditingController();
final _apiKey = TextEditingController();
final _desc = TextEditingController();
/// 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.resolveDescription(l);
});
}
@override
void dispose() {
_name.dispose();
_endpoint.dispose();
_apiKey.dispose();
_desc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.addMcpTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.addMcpIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
// Curated suggestions from the official Anthropic
// MCP servers + a few community staples. Click a
// chip to pre-fill the form. The operator still
// confirms via "Add + discover" — no unprompted
// subprocess spawn.
Text(
l.addMcpSuggestionsLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final s in _kMcpSuggestions)
ActionChip(
avatar: Icon(s.icon, size: 14),
label: Text(s.name),
onPressed: () => _applySuggestion(s),
),
],
),
const Divider(height: FaiSpace.xl),
TextField(
controller: _name,
autofocus: true,
decoration: InputDecoration(
labelText: l.addMcpNameLabel,
hintText: l.addMcpNameHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _endpoint,
decoration: InputDecoration(
labelText: l.addMcpEndpointLabel,
hintText: l.addMcpEndpointHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: InputDecoration(
labelText: l.addMcpApiKeyLabel,
hintText: l.addMcpApiKeyHint,
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: InputDecoration(
labelText: l.addMcpNotesLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () {
if (_name.text.trim().isEmpty || _endpoint.text.trim().isEmpty) {
return;
}
Navigator.pop(
context,
McpClientDraft(
name: _name.text.trim(),
endpoint: _endpoint.text.trim(),
apiKeyEnv: _apiKey.text.trim(),
description: _desc.text.trim(),
),
);
},
child: Text(l.addMcpAddButton),
),
],
);
}
}
class N8nEndpointDraft {
final String name;
final String baseUrl;
final String apiKeyEnv;
final String description;
const N8nEndpointDraft({
required this.name,
required this.baseUrl,
required this.apiKeyEnv,
required this.description,
});
}
/// n8n-endpoints panel — sister of `_McpClientsPanel`. Shows
/// every configured n8n install with last-discovery health
/// (workflow count or error_kind), plus Add / Remove / Refresh.
class _N8nEndpointsPanel extends StatelessWidget {
final List<N8nEndpointInfo> endpoints;
final ValueChanged<N8nEndpointDraft> onAdd;
final ValueChanged<String> onRemove;
final VoidCallback onRefresh;
const _N8nEndpointsPanel({
required this.endpoints,
required this.onAdd,
required this.onRemove,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.n8nHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: l.n8nEndpointsCount(endpoints.length),
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: l.n8nRediscoverTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: l.n8nAddTooltip,
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddN8nEndpointDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
l.n8nHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
if (endpoints.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Text(
l.n8nEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
for (final e in endpoints)
_N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)),
],
);
}
}
class _N8nEndpointRow extends StatelessWidget {
final N8nEndpointInfo endpoint;
final VoidCallback onRemove;
const _N8nEndpointRow({required this.endpoint, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = endpoint.healthy;
final dotColor = endpoint.errorKind.isEmpty
? (ok ? FaiColors.success : FaiColors.muted)
: theme.colorScheme.error;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(Icons.circle, size: 8, color: dotColor),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
endpoint.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: FaiSpace.sm),
if (endpoint.workflowCount > 0)
FaiPill(
label: l.n8nWorkflowsCount(endpoint.workflowCount),
tone: FaiPillTone.success,
)
else if (endpoint.errorKind.isNotEmpty)
FaiPill(
label: endpoint.errorKind,
tone: FaiPillTone.danger,
),
],
),
Text(
endpoint.baseUrl,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (endpoint.message.isNotEmpty)
Text(
endpoint.message,
style: theme.textTheme.bodySmall?.copyWith(
color: endpoint.errorKind.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 14),
tooltip: l.n8nRemoveTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
],
),
);
}
}
class _AddN8nEndpointDialog extends StatefulWidget {
const _AddN8nEndpointDialog();
static Future<N8nEndpointDraft?> show(BuildContext context) {
return showDialog<N8nEndpointDraft>(
context: context,
builder: (_) => const _AddN8nEndpointDialog(),
);
}
@override
State<_AddN8nEndpointDialog> createState() => _AddN8nEndpointDialogState();
}
class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
final _name = TextEditingController();
final _baseUrl = TextEditingController();
final _apiKey = TextEditingController();
final _desc = TextEditingController();
@override
void dispose() {
_name.dispose();
_baseUrl.dispose();
_apiKey.dispose();
_desc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.addN8nTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.addN8nIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _name,
autofocus: true,
decoration: InputDecoration(
labelText: l.addN8nNameLabel,
hintText: l.addN8nNameHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _baseUrl,
decoration: InputDecoration(
labelText: l.addN8nBaseUrlLabel,
hintText: l.addN8nBaseUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: InputDecoration(
labelText: l.addN8nApiKeyLabel,
hintText: l.addN8nApiKeyHint,
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: InputDecoration(
labelText: l.addN8nNotesLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () {
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) {
return;
}
Navigator.pop(
context,
N8nEndpointDraft(
name: _name.text.trim(),
baseUrl: _baseUrl.text.trim(),
apiKeyEnv: _apiKey.text.trim(),
description: _desc.text.trim(),
),
);
},
child: Text(l.addN8nAddButton),
),
],
);
}
}
/// One curated MCP-server suggestion — the operator clicks
/// the chip to pre-fill the add-dialog. Strictly informational
/// 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 StatelessWidget {
const _ThemePluginPanel();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.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: 8),
// Rich grid of theme tiles. Each shows live swatches
// from the plugin's own palette so the operator can
// preview without applying. The "Custom" tile opens
// a seed-colour dialog.
const ThemePickerGrid(),
],
);
}
}
/// 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
/// button. The reset itself is gated by an interactive confirm
/// dialog before the CLI is invoked.
class _MaintenancePanel extends StatefulWidget {
/// Called after a successful reset so the parent Settings dialog
/// can bounce its gRPC channel and reload its panels.
final Future<void> Function() onResetDone;
const _MaintenancePanel({required this.onResetDone});
@override
State<_MaintenancePanel> createState() => _MaintenancePanelState();
}
class _MaintenancePanelState extends State<_MaintenancePanel> {
bool _keepModules = false;
bool _keepData = false;
bool _resetting = false;
String? _resultText;
bool _resultOk = false;
Future<void> _confirmAndReset() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.maintenanceResetConfirmTitle),
content: Text(l.maintenanceResetConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.buttonCancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.maintenanceResetConfirmButton),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_resetting = true;
_resultText = null;
});
final r = await SystemActions.faiReset(
keepModules: _keepModules,
keepData: _keepData,
);
if (!mounted) return;
setState(() {
_resetting = false;
_resultOk = r.ok;
_resultText = r.ok
? r.stdout.trim()
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
});
if (r.ok) await widget.onResetDone();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.maintenanceHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.maintenanceResetBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepModules,
onChanged: _resetting
? null
: (v) => setState(() => _keepModules = v ?? false),
title: Text(l.maintenanceKeepModulesTitle),
subtitle: Text(
l.maintenanceKeepModulesSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepData,
onChanged: _resetting
? null
: (v) => setState(() => _keepData = v ?? false),
title: Text(l.maintenanceKeepDataTitle),
subtitle: Text(
l.maintenanceKeepDataSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
FilledButton.icon(
icon: _resetting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.restart_alt, size: 16),
label: Text(
_resetting
? l.maintenanceResetInProgress
: l.maintenanceResetButton,
),
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
),
onPressed: _resetting ? null : _confirmAndReset,
),
],
),
if (_resultText != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
],
],
);
}
}
/// Credentials panel for the operator's registry auth token.
/// The hub reads the same token via `~/.fai/registry-token`
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)
/// when downloading `.fai` bundles from a registry behind a
/// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true,
/// GitHub-private releases, etc.
///
/// The token itself is never round-tripped back into Studio
/// after save: `configuredChars` is the trimmed length, used
/// only as a "Configured (40 chars)" status display so the
/// operator knows something landed without seeing the secret.
class _RegistryCredentialsPanel extends StatefulWidget {
/// Trimmed length of the persisted token, or null when none
/// is configured.
final int? configuredChars;
final ValueChanged<String> onSave;
final Future<void> Function() onClear;
const _RegistryCredentialsPanel({
required this.configuredChars,
required this.onSave,
required this.onClear,
});
@override
State<_RegistryCredentialsPanel> createState() =>
_RegistryCredentialsPanelState();
}
class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@override
void initState() {
super.initState();
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isConfigured = widget.configuredChars != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.registryCredentialsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.registryCredentialsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Icon(
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.xs),
Text(
isConfigured
? l.registryTokenStatusConfigured(widget.configuredChars!)
: l.registryTokenStatusNotSet,
style: theme.textTheme.bodySmall?.copyWith(
color: isConfigured
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isConfigured ? FontWeight.w600 : null,
),
),
const Spacer(),
if (isConfigured)
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 14),
onPressed: () async {
await widget.onClear();
_controller.clear();
},
label: Text(l.registryTokenClearButton),
),
],
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscured,
decoration: InputDecoration(
labelText: l.registryTokenFieldLabel,
hintText: l.registryTokenFieldHint,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.save_outlined, size: 14),
label: Text(l.registryTokenSaveButton),
onPressed: _controller.text.trim().isEmpty
? null
: () {
widget.onSave(_controller.text);
_controller.clear();
},
),
],
),
const SizedBox(height: 4),
Text(
l.registryTokenStorageHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
}
/// Operator panel for the hub's gRPC bearer token. When the
/// hub has `auth.tokens:` configured, Studio must present
/// `Authorization: Bearer <token>` on every call. The token
/// is stored at `~/.fai/hub-auth-token` (mode 0600) and read
/// at startup by [HubService]; this panel just lets the
/// operator paste / clear without leaving the GUI.
///
/// Same hygiene as the registry-credentials panel: the token
/// itself never round-trips back into Studio after save;
/// `configuredChars` is just the trimmed length for the
/// status display.
class _HubAuthTokenPanel extends StatefulWidget {
final int? configuredChars;
final ValueChanged<String> onSave;
final Future<void> Function() onClear;
const _HubAuthTokenPanel({
required this.configuredChars,
required this.onSave,
required this.onClear,
});
@override
State<_HubAuthTokenPanel> createState() => _HubAuthTokenPanelState();
}
class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@override
void initState() {
super.initState();
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isConfigured = widget.configuredChars != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.hubAuthTokenHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.hubAuthTokenBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Icon(
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.xs),
Text(
isConfigured
? l.hubAuthTokenStatusConfigured(widget.configuredChars!)
: l.hubAuthTokenStatusNotSet,
style: theme.textTheme.bodySmall?.copyWith(
color: isConfigured
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isConfigured ? FontWeight.w600 : null,
),
),
const Spacer(),
if (isConfigured)
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 14),
onPressed: () async {
await widget.onClear();
_controller.clear();
},
label: Text(l.hubAuthTokenClearButton),
),
],
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscured,
decoration: InputDecoration(
labelText: l.hubAuthTokenFieldLabel,
hintText: l.hubAuthTokenFieldHint,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.save_outlined, size: 14),
label: Text(l.hubAuthTokenSaveButton),
onPressed: _controller.text.trim().isEmpty
? null
: () {
widget.onSave(_controller.text);
_controller.clear();
},
),
],
),
const SizedBox(height: 4),
Text(
l.hubAuthTokenStorageHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
}
class _McpSuggestion {
final String name;
final IconData icon;
final String endpoint;
final String apiKeyEnv;
/// 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,
});
}
/// 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) ──
// 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: '',
),
_McpSuggestion(
name: 'semgrep',
icon: Icons.security,
endpoint: 'https://mcp.semgrep.ai/mcp',
apiKeyEnv: '',
),
// ── stdio servers (require Node + npx) ───────────────────
_McpSuggestion(
name: 'filesystem',
icon: Icons.folder_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'fetch',
icon: Icons.cloud_download_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'github',
icon: Icons.code,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
),
_McpSuggestion(
name: 'puppeteer',
icon: Icons.web,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'postgres',
icon: Icons.storage_outlined,
endpoint:
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'sqlite',
icon: Icons.dataset_outlined,
endpoint:
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'brave-search',
icon: Icons.search,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
apiKeyEnv: 'BRAVE_API_KEY',
),
_McpSuggestion(
name: 'memory',
icon: Icons.psychology_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'time',
icon: Icons.schedule,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
apiKeyEnv: '',
),
];
/// Operator panel for `default_scope:` — the ordered list of
/// publisher segments the bare-form capability resolver consults
/// when a flow names a capability without an explicit
/// `<provider>/` prefix (e.g. `text.extract@^1` resolves to
/// `fai/text.extract@^1` when `default_scope: [fai]`).
///
/// The hub returns the live list plus a catalog-known shortlist
/// (`knownProviders`) — the panel renders the shortlist as
/// suggestion chips and lets operators paste arbitrary entries
/// for private publisher segments.
///
/// The order matters: when more than one segment matches a bare
/// capability name, the first match wins. The panel surfaces the
/// order with simple up/down buttons so operators can reorder
/// without dragging.
class _DefaultScopePanel extends StatefulWidget {
const _DefaultScopePanel();
@override
State<_DefaultScopePanel> createState() => _DefaultScopePanelState();
}
class _DefaultScopePanelState extends State<_DefaultScopePanel> {
List<String>? _scope;
List<String> _knownProviders = const [];
bool _loading = true;
bool _saving = false;
String? _error;
final _addController = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_addController.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final r = await HubService.instance.getDefaultScope();
if (!mounted) return;
setState(() {
_scope = r.scope;
_knownProviders = r.knownProviders;
_loading = false;
_error = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.toString();
});
}
}
Future<void> _save(List<String> next) async {
setState(() {
_saving = true;
_error = null;
});
try {
final r = await HubService.instance.setDefaultScope(next);
if (!mounted) return;
setState(() {
_scope = r.scope;
_knownProviders = r.knownProviders;
_saving = false;
});
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast)));
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_error = e.toString();
});
}
}
void _addEntry(String raw) {
final entry = raw.trim();
if (entry.isEmpty) return;
final current = List<String>.from(_scope ?? const []);
if (current.contains(entry)) return;
current.add(entry);
_addController.clear();
_save(current);
}
void _removeAt(int i) {
final current = List<String>.from(_scope ?? const []);
if (i < 0 || i >= current.length) return;
if (current.length == 1) {
// Hub rejects empty; surface the constraint inline rather
// than letting the RPC fail.
setState(
() => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError,
);
return;
}
current.removeAt(i);
_save(current);
}
void _moveUp(int i) {
if (i <= 0) return;
final current = List<String>.from(_scope ?? const []);
final item = current.removeAt(i);
current.insert(i - 1, item);
_save(current);
}
void _moveDown(int i) {
final current = List<String>.from(_scope ?? const []);
if (i >= current.length - 1) return;
final item = current.removeAt(i);
current.insert(i + 1, item);
_save(current);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final scope = _scope;
final unusedSuggestions = _knownProviders
.where((p) => !(scope ?? const []).contains(p))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.defaultScopeHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.defaultScopeBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
if (_loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (scope == null)
Text(
_error ?? l.defaultScopeLoadFailed,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else ...[
for (var i = 0; i < scope.length; i++)
_ScopeRow(
index: i,
total: scope.length,
entry: scope[i],
busy: _saving,
onUp: () => _moveUp(i),
onDown: () => _moveDown(i),
onRemove: () => _removeAt(i),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _addController,
decoration: InputDecoration(
labelText: l.defaultScopeAddLabel,
hintText: l.defaultScopeAddHint,
border: const OutlineInputBorder(),
isDense: true,
),
onSubmitted: _saving ? null : _addEntry,
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.add, size: 14),
label: Text(l.defaultScopeAddButton),
onPressed: _saving
? null
: () => _addEntry(_addController.text),
),
],
),
if (unusedSuggestions.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Text(
l.defaultScopeSuggestions,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
const SizedBox(height: 4),
Wrap(
spacing: FaiSpace.xs,
runSpacing: 4,
children: [
for (final s in unusedSuggestions)
ActionChip(
label: Text(
s,
style: const TextStyle(fontFamily: 'monospace'),
),
onPressed: _saving ? null : () => _addEntry(s),
),
],
),
],
if (_error != null) ...[
const SizedBox(height: FaiSpace.sm),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
],
);
}
}
class _ScopeRow extends StatelessWidget {
final int index;
final int total;
final String entry;
final bool busy;
final VoidCallback onUp;
final VoidCallback onDown;
final VoidCallback onRemove;
const _ScopeRow({
required this.index,
required this.total,
required this.entry,
required this.busy,
required this.onUp,
required this.onDown,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 24,
child: Text(
'${index + 1}.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(entry, style: const TextStyle(fontFamily: 'monospace')),
),
IconButton(
icon: const Icon(Icons.arrow_upward, size: 16),
tooltip: AppLocalizations.of(context)!.tooltipMoveUp,
onPressed: busy || index == 0 ? null : onUp,
visualDensity: VisualDensity.compact,
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 16),
tooltip: AppLocalizations.of(context)!.tooltipMoveDown,
onPressed: busy || index == total - 1 ? null : onDown,
visualDensity: VisualDensity.compact,
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 16),
tooltip: AppLocalizations.of(context)!.tooltipRemove,
onPressed: busy ? null : onRemove,
visualDensity: VisualDensity.compact,
),
],
),
);
}
}
/// macOS-style Settings sidebar — an icon + label per category,
/// stable position, highlighted current row. Replaces the
/// previous "one long vertical scroll" with focused panels.
class _Sidebar extends StatelessWidget {
final _Category current;
final ValueChanged<_Category> onSelect;
const _Sidebar({required this.current, required this.onSelect});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final items = <(_Category, IconData, String)>[
(_Category.general, Icons.tune, l.settingsCategoryGeneral),
(
_Category.appearance,
Icons.palette_outlined,
l.settingsCategoryAppearance,
),
(_Category.ai, Icons.psychology_outlined, l.settingsCategoryAi),
(
_Category.integrations,
Icons.hub_outlined,
l.settingsCategoryIntegrations,
),
(_Category.security, Icons.shield_outlined, l.settingsCategorySecurity),
(
_Category.maintenance,
Icons.build_outlined,
l.settingsCategoryMaintenance,
),
];
return SizedBox(
width: 220,
child: Container(
color: theme.colorScheme.surfaceContainer,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.lg),
children: [
for (final (cat, icon, label) in items)
_SidebarRow(
icon: icon,
label: label,
selected: cat == current,
onTap: () => onSelect(cat),
),
],
),
),
);
}
}
class _SidebarRow extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
const _SidebarRow({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 2),
child: Material(
color: selected
? theme.colorScheme.primary.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: 8,
),
child: Row(
children: [
Icon(icon, size: 16, color: fg),
const SizedBox(width: FaiSpace.sm),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: fg,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
),
);
}
}