feat(studio): Settings dialog gets macOS-style sidebar categories
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
d9dca60cfb
commit
d0e15ca385
4 changed files with 416 additions and 204 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,33 +366,64 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
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,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.xl,
|
||||
vertical: FaiSpace.lg,
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 640),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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(
|
||||
l.settingsHubEndpointHint,
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _generalPanel(AppLocalizations l, ThemeData theme) {
|
||||
return [
|
||||
_panelTitle(
|
||||
l.settingsTitle,
|
||||
l.settingsHubEndpointHint,
|
||||
theme,
|
||||
),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: InputDecoration(
|
||||
|
|
@ -465,7 +502,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
),
|
||||
),
|
||||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const SizedBox(height: FaiSpace.xl),
|
||||
Text(
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
|
|
@ -506,62 +543,110 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
||||
],
|
||||
],
|
||||
if (_aiStatus != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
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!,
|
||||
);
|
||||
final updated = await FaiSystemAiEditor.show(context, _aiStatus!);
|
||||
if (updated != null && mounted) {
|
||||
setState(() => _aiStatus = updated);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_mcpClients != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
if (_n8nEndpoints != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const SizedBox(height: FaiSpace.xl),
|
||||
if (_n8nEndpoints != null)
|
||||
_N8nEndpointsPanel(
|
||||
endpoints: _n8nEndpoints!,
|
||||
onAdd: _addN8nEndpoint,
|
||||
onRemove: _removeN8nEndpoint,
|
||||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
];
|
||||
}
|
||||
|
||||
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.lg),
|
||||
const SizedBox(height: FaiSpace.xl),
|
||||
_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),
|
||||
];
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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,
|
||||
);
|
||||
|
|
@ -573,6 +658,36 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ packages:
|
|||
path: "../fai_studio_flow_editor"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.15.1"
|
||||
version: "0.16.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -412,7 +412,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: fai_studio
|
||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||
publish_to: 'none'
|
||||
version: 0.62.1
|
||||
version: 0.63.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0-200.1.beta
|
||||
|
|
@ -13,6 +13,8 @@ dependencies:
|
|||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
intl: any
|
||||
# `@visibleForTesting` annotation on FaiLog's test seam.
|
||||
meta: any
|
||||
|
||||
# Sibling package; will move to Forgejo path once published.
|
||||
fai_client_sdk:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue