feat(studio): Settings dialog gets macOS-style sidebar categories
Some checks failed
Security / Security check (push) Failing after 2s

The Settings dialog was a single 800-line vertical scroll — Hub
endpoint, channels, System AI, MCP, n8n, registry credentials,
hub auth token, default scope, theme plugin, maintenance all
stacked. Stefan: 'inzwischen zu unübersichtlich als langes
feld, untergruppierungen wären toll, wie z.B. in den MacOS
Einstellungen'.

Splits the panel into six focused categories driven by a
left sidebar:

  - **General**: Hub endpoint (host/port/TLS), channels,
    default scope
  - **Appearance**: theme plugin picker (applies instantly)
  - **System AI**: operator-managed LLM endpoint for Studio
    helpers
  - **Integrations**: MCP servers + n8n endpoints
  - **Security**: registry credentials + hub auth token
  - **Maintenance**: reset / destructive ops

Sidebar uses macOS-style icon + label rows with a highlighted
selection pill. Content area is per-category, scrollable
within its own panel. The dialog widens from 520→820 px and
gains a fixed height (620 px) so the layout doesn't jump as
the operator switches categories.

Each panel opens with a title + one-sentence description so
the operator knows what they're looking at before scanning the
controls — same pattern Apple uses in System Settings.

Also adds the missing 'meta' dependency that FaiLog's
@visibleForTesting needed. Studio bumped to 0.63.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-04 10:37:19 +02:00
parent d9dca60cfb
commit d0e15ca385
4 changed files with 416 additions and 204 deletions

View file

@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.62.1';
const String kStudioVersion = '0.63.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -34,6 +34,11 @@ class FaiSettingsDialog extends StatefulWidget {
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;
@ -45,6 +50,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
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
@ -360,6 +366,301 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
}
}
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(
'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving
? null
: () => _runDaemon(
'disable autostart',
() => 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(
'System AI',
'Operator-managed LLM endpoint used by Studio for the AI '
'helpers (system-ai-edit, install-suggest, doctor-summary). '
'No flow ever calls this; modules speak to their own LLM.',
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(
'Integrations',
'External tool servers the hub federates capabilities from. '
'MCP servers expose tool-shaped endpoints; n8n endpoints '
'expose hosted workflows.',
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(
'Security & credentials',
'Tokens the hub holds on the operator\'s behalf. Stored in '
'~/.fai/ with mode 0600; never sent in telemetry.',
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(
'Maintenance',
'Destructive operations gated behind explicit confirm '
'dialogs. Use when re-pilot-testing or debugging.',
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);
@ -369,210 +670,24 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.lg,
),
contentPadding: EdgeInsets.zero,
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 640),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 620),
child: SizedBox(
width: 820,
height: 620,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.settingsHubEndpointHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
_Sidebar(
current: _category,
onSelect: (c) => setState(() => _category = c),
),
const SizedBox(height: FaiSpace.lg),
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.lg),
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(
'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving
? null
: () => _runDaemon(
'disable autostart',
() => SystemActions.faiDaemonDisable(ch.name),
),
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
if (_aiStatus != null) ...[
const SizedBox(height: FaiSpace.lg),
_SystemAiPanel(
status: _aiStatus!,
onEdit: () async {
final updated = await FaiSystemAiEditor.show(
context,
_aiStatus!,
);
if (updated != null && mounted) {
setState(() => _aiStatus = updated);
}
},
),
],
if (_mcpClients != null) ...[
const SizedBox(height: FaiSpace.lg),
_McpClientsPanel(
clients: _mcpClients!,
onAdd: _addMcpClient,
onRemove: _removeMcpClient,
onRefresh: _refreshMcpClients,
),
],
if (_n8nEndpoints != null) ...[
const SizedBox(height: FaiSpace.lg),
_N8nEndpointsPanel(
endpoints: _n8nEndpoints!,
onAdd: _addN8nEndpoint,
onRemove: _removeN8nEndpoint,
onRefresh: _refreshN8nEndpoints,
),
],
const SizedBox(height: FaiSpace.lg),
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.lg),
_HubAuthTokenPanel(
configuredChars: _hubAuthTokenChars,
onSave: _saveHubAuthToken,
onClear: _clearHubAuthToken,
),
const SizedBox(height: FaiSpace.lg),
const _DefaultScopePanel(),
const SizedBox(height: FaiSpace.lg),
const _ThemePluginPanel(),
const SizedBox(height: FaiSpace.lg),
_MaintenancePanel(
onResetDone: () async {
// Daemon just restarted under the same channel +
// port. Bouncing the gRPC connection picks up the
// fresh state without the operator having to
// close+reopen Settings.
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
VerticalDivider(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded(child: _buildCategoryPanel(context, l, theme)),
],
),
),
@ -2431,3 +2546,98 @@ class _ScopeRow extends StatelessWidget {
);
}
}
/// 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 items = <(_Category, IconData, String)>[
(_Category.general, Icons.tune, 'General'),
(_Category.appearance, Icons.palette_outlined, 'Appearance'),
(_Category.ai, Icons.psychology_outlined, 'System AI'),
(_Category.integrations, Icons.hub_outlined, 'Integrations'),
(_Category.security, Icons.shield_outlined, 'Security'),
(_Category.maintenance, Icons.build_outlined, 'Maintenance'),
];
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,
),
),
],
),
),
),
),
);
}
}