Some checks are pending
Security / Security check (push) Waiting to run
Stefan's live findings, all four addressed at the root:
- 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly
'Einrichtung starten' / 'Start setup'.
- The setup button sat permanently on the Welcome page of a running
app ('setup after the app runs is backwards' — reported twice). A
fresh install now starts INSIDE the setup: SetupGateScreen hosts
the wizard embedded as the page (new embedded/onFinished modes on
GuidedSetupDialog), with an explicit 'Später einrichten' skip.
Welcome loses the setup button entirely and stays a calm intro.
- Re-running the setup later lives in Settings → General ('Run setup
again…'), the single post-first-run home.
- 'You must grant access first and only then see what will be done':
the preview used to spawn the chain CLI, whose first run could pop
the macOS permission prompt BEFORE the plan was ever shown. The
preview now calls the new PlanSetup RPC over the live hub
connection (no subprocess, nothing granted); the CLI remains only
a fallback when no hub is reachable — and applying stays the
explicit, separate step.
Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path
tests drive the fallback through the new hub-preview test seam.
Suite 76 green, analyze clean.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2698 lines
82 KiB
Dart
2698 lines
82 KiB
Dart
// ChainSettingsDialog — modal for changing the hub endpoint.
|
|
// Reads current values from HubService, persists on save via
|
|
// HubService.reconnect.
|
|
|
|
import 'package:chain_client_sdk/chain_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 '../pages/welcome.dart' show showFaiDoc;
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
import 'chain_error_box.dart';
|
|
import 'guided_setup_dialog.dart';
|
|
import 'chain_pill.dart';
|
|
import 'chain_system_ai_editor.dart';
|
|
import 'hub_auth_policy_panel.dart';
|
|
import 'theme_picker_grid.dart';
|
|
|
|
class ChainSettingsDialog extends StatefulWidget {
|
|
const ChainSettingsDialog({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 ChainSettingsDialog(),
|
|
);
|
|
return ok ?? false;
|
|
}
|
|
|
|
@override
|
|
State<ChainSettingsDialog> 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<ChainSettingsDialog> {
|
|
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
|
|
/// `~/.chain/registry-token` file is missing / empty. Reloaded
|
|
/// after save / clear.
|
|
int? _registryTokenChars;
|
|
|
|
/// Length of the configured hub gRPC bearer token, or null
|
|
/// when `~/.chain/hub-auth-token` is missing / empty. Reloaded
|
|
/// after save / clear; UI only ever sees the trimmed length.
|
|
int? _hubAuthTokenChars;
|
|
|
|
/// Bumped whenever the hub auth token is saved or cleared so the
|
|
/// auth-policy panel below re-queries with the new credentials.
|
|
int _authPolicyReloadTick = 0;
|
|
|
|
@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;
|
|
// The connection's auth just changed — the policy panel below
|
|
// must re-query instead of keeping a stale admin-denied hint.
|
|
setState(() => _authPolicyReloadTick++);
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showChainErrorSnack(context, 'auth.hub-token.save', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearHubAuthToken() async {
|
|
try {
|
|
await HubAuthToken.clear();
|
|
await HubService.instance.reloadAuthToken();
|
|
await _loadHubAuthToken();
|
|
if (!mounted) return;
|
|
setState(() => _authPolicyReloadTick++);
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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;
|
|
showChainErrorSnack(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.chainChannelSwitch(name);
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
setState(() {
|
|
_saving = false;
|
|
_channelToast = r.ok
|
|
? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}'
|
|
: l.channelSwitchFailed(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(
|
|
ChainSpace.xl,
|
|
ChainSpace.xl,
|
|
ChainSpace.xl,
|
|
ChainSpace.lg,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: children,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _panelTitle(
|
|
String title,
|
|
String subtitle,
|
|
ThemeData theme, {
|
|
String? docSlug,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: ChainSpace.lg),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
if (docSlug != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline, size: 18),
|
|
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
|
onPressed: () => showFaiDoc(context, docSlug),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
docSlug: 'architecture',
|
|
),
|
|
TextField(
|
|
controller: _host,
|
|
decoration: InputDecoration(
|
|
labelText: l.settingsHost,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: ChainSpace.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: ChainSpace.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: ChainSpace.md),
|
|
// Copyable — errors must be selectable + one-tap copyable.
|
|
ChainErrorBox(text: _error!, isError: true, maxHeight: 200),
|
|
],
|
|
const SizedBox(height: ChainSpace.md),
|
|
Container(
|
|
padding: const EdgeInsets.all(ChainSpace.sm),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.link,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Text(
|
|
_previewUrl(),
|
|
style: ChainTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_channels != null) ...[
|
|
const SizedBox(height: ChainSpace.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: ChainSpace.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.chainDaemonEnable(ch.name),
|
|
),
|
|
onDisableAutostart: _saving
|
|
? null
|
|
: () => _runDaemon(
|
|
l.daemonActionDisableAutostart,
|
|
() => SystemActions.chainDaemonDisable(ch.name),
|
|
),
|
|
),
|
|
if (_channelToast != null) ...[
|
|
const SizedBox(height: ChainSpace.sm),
|
|
ChainErrorBox(text: _channelToast!, maxHeight: 200),
|
|
],
|
|
],
|
|
const SizedBox(height: ChainSpace.xl),
|
|
const _DefaultScopePanel(),
|
|
const SizedBox(height: ChainSpace.xl),
|
|
// Re-run the guided setup. This is the ONLY place it lives
|
|
// after first run — the first-run gate owns the fresh-install
|
|
// case, and Welcome stays a calm introduction instead of a
|
|
// permanent setup surface.
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => GuidedSetupDialog.show(context),
|
|
icon: const Icon(Icons.auto_fix_high, size: 18),
|
|
label: Text(l.settingsRunSetup),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.settingsRunSetupHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
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 ChainSystemAiEditor.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: ChainSpace.md),
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
)
|
|
else
|
|
_McpClientsPanel(
|
|
clients: _mcpClients!,
|
|
onAdd: _addMcpClient,
|
|
onRemove: _removeMcpClient,
|
|
onRefresh: _refreshMcpClients,
|
|
),
|
|
const SizedBox(height: ChainSpace.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,
|
|
docSlug: 'security',
|
|
),
|
|
_RegistryCredentialsPanel(
|
|
configuredChars: _registryTokenChars,
|
|
onSave: _saveRegistryToken,
|
|
onClear: _clearRegistryToken,
|
|
),
|
|
const SizedBox(height: ChainSpace.xl),
|
|
_HubAuthTokenPanel(
|
|
configuredChars: _hubAuthTokenChars,
|
|
onSave: _saveHubAuthToken,
|
|
onClear: _clearHubAuthToken,
|
|
),
|
|
const SizedBox(height: ChainSpace.xl),
|
|
HubAuthPolicyPanel(reloadTick: _authPolicyReloadTick),
|
|
];
|
|
}
|
|
|
|
List<Widget> _maintenancePanel(AppLocalizations l, ThemeData theme) {
|
|
return [
|
|
_panelTitle(
|
|
l.settingsMaintenancePanelTitle,
|
|
l.settingsMaintenancePanelBody,
|
|
theme,
|
|
docSlug: 'audit',
|
|
),
|
|
_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(ChainRadius.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
|
|
? ChainColors.success
|
|
: theme.colorScheme.outline,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
SizedBox(
|
|
width: 88,
|
|
child: Text(
|
|
channel.name,
|
|
style: ChainTheme.mono(
|
|
size: 11,
|
|
weight: active ? FontWeight.w600 : FontWeight.w400,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 64,
|
|
child: Text(
|
|
':${channel.port}',
|
|
style: ChainTheme.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: ChainSpace.sm),
|
|
child: ChainPill(
|
|
label: l.channelsActive,
|
|
tone: ChainPillTone.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: ChainSpace.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: ChainSpace.sm),
|
|
ChainPill(
|
|
label: status.enabled ? l.systemAiEnabled : l.systemAiOff,
|
|
tone: status.enabled ? ChainPillTone.success : ChainPillTone.neutral,
|
|
),
|
|
if (status.enabled) ...[
|
|
const SizedBox(width: ChainSpace.xs),
|
|
ChainPill(
|
|
label: status.privacyMode,
|
|
tone: status.privacyMode == 'full'
|
|
? ChainPillTone.warning
|
|
: ChainPillTone.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
|
|
? ChainTheme.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 `chain 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: ChainSpace.sm),
|
|
ChainPill(
|
|
label: l.mcpServersCount(clients.length),
|
|
tone: ChainPillTone.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: ChainSpace.sm),
|
|
if (clients.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: ChainSpace.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 ? ChainColors.success : ChainColors.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: ChainSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
client.name,
|
|
style: ChainTheme.mono(
|
|
size: 12,
|
|
weight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
if (client.toolCount > 0)
|
|
ChainPill(
|
|
label: l.mcpToolsCount(client.toolCount),
|
|
tone: ChainPillTone.success,
|
|
)
|
|
else if (client.errorKind.isNotEmpty)
|
|
ChainPill(
|
|
label: client.errorKind,
|
|
tone: ChainPillTone.danger,
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
client.endpoint,
|
|
style: ChainTheme.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: ChainSpace.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: ChainSpace.xs,
|
|
runSpacing: ChainSpace.xs,
|
|
children: [
|
|
for (final s in _kMcpSuggestions)
|
|
ActionChip(
|
|
avatar: Icon(s.icon, size: 14),
|
|
label: Text(s.name),
|
|
onPressed: () => _applySuggestion(s),
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: ChainSpace.xl),
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpNameLabel,
|
|
hintText: l.addMcpNameHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
TextField(
|
|
controller: _endpoint,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpEndpointLabel,
|
|
hintText: l.addMcpEndpointHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
TextField(
|
|
controller: _apiKey,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpApiKeyLabel,
|
|
hintText: l.addMcpApiKeyHint,
|
|
prefixText: '\$',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.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: ChainSpace.sm),
|
|
ChainPill(
|
|
label: l.n8nEndpointsCount(endpoints.length),
|
|
tone: ChainPillTone.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: ChainSpace.sm),
|
|
if (endpoints.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: ChainSpace.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 ? ChainColors.success : ChainColors.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: ChainSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
endpoint.name,
|
|
style: ChainTheme.mono(
|
|
size: 12,
|
|
weight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
if (endpoint.workflowCount > 0)
|
|
ChainPill(
|
|
label: l.n8nWorkflowsCount(endpoint.workflowCount),
|
|
tone: ChainPillTone.success,
|
|
)
|
|
else if (endpoint.errorKind.isNotEmpty)
|
|
ChainPill(
|
|
label: endpoint.errorKind,
|
|
tone: ChainPillTone.danger,
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
endpoint.baseUrl,
|
|
style: ChainTheme.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: ChainSpace.md),
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nNameLabel,
|
|
hintText: l.addN8nNameHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
TextField(
|
|
controller: _baseUrl,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nBaseUrlLabel,
|
|
hintText: l.addN8nBaseUrlHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
TextField(
|
|
controller: _apiKey,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nApiKeyLabel,
|
|
hintText: l.addN8nApiKeyHint,
|
|
prefixText: '\$',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.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 `chain 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.chainReset(
|
|
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: ChainSpace.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: ChainSpace.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: ChainSpace.sm),
|
|
ChainErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Credentials panel for the operator's registry auth token.
|
|
/// The hub reads the same token via `~/.chain/registry-token`
|
|
/// (or the `CHAIN_REGISTRY_TOKEN` env var, which still wins)
|
|
/// when downloading `.chain` 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: ChainSpace.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: ChainSpace.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: ChainSpace.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: ChainSpace.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 `~/.chain/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: ChainSpace.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: ChainSpace.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: ChainSpace.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: ChainSpace.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: ChainSpace.sm),
|
|
if (_loading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: ChainSpace.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: ChainSpace.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: ChainSpace.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: ChainSpace.sm),
|
|
Text(
|
|
l.defaultScopeSuggestions,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: ChainSpace.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: ChainSpace.sm),
|
|
// Copyable — errors must be selectable + one-tap copyable.
|
|
ChainErrorBox(text: _error!, isError: true, maxHeight: 200),
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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: ChainSpace.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: ChainSpace.sm, vertical: 2),
|
|
child: Material(
|
|
color: selected
|
|
? theme.colorScheme.primary.withValues(alpha: 0.14)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: ChainSpace.md,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: fg),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: fg,
|
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|