diff --git a/lib/main.dart b/lib/main.dart index d9943b3..44b4bc8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index c52e597..685f842 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -34,6 +34,11 @@ class FaiSettingsDialog extends StatefulWidget { State 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 { late final TextEditingController _host; late final TextEditingController _port; @@ -45,6 +50,7 @@ class _FaiSettingsDialogState extends State { SystemAiStatus? _aiStatus; List? _mcpClients; List? _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 { } } + 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 _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 _appearancePanel(AppLocalizations l, ThemeData theme) { + return [ + _panelTitle( + l.themePluginHeader, + l.themePluginHint, + theme, + ), + const _ThemePluginPanel(), + ]; + } + + List _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 _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 _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 _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 { 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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7adad09..19c1545 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 11bad1f..947942a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: