chain-studio/lib/main.dart
flemming-it d2a6ed32e2 feat(studio): n8n-endpoints editor in Settings (v0.23.0)
Mirror of the MCP-clients panel: Add / Remove / Refresh
with last-discovery health (workflow count or error_kind),
sister Add-dialog with name + base_url + optional
api_key_env + notes. HubService gains the four passthroughs
(`listN8nEndpoints`, `refreshN8nEndpoints`,
`addN8nEndpoint`, `removeN8nEndpoint`) and a
`N8nEndpointInfo` data class.

Settings dialog now hosts: hub endpoint, channels,
system AI, MCP clients, n8n endpoints — all behind the
existing 520×640 SingleChildScrollView so the layout
doesn't crowd.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 01:00:17 +02:00

832 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// F∆I Studio — desktop GUI for the F∆I hub.
//
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/hub.dart';
import 'data/system_actions.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/flows.dart';
import 'pages/modules.dart';
import 'pages/store.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/fai_search_palette.dart';
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.23.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint and theme mode before the
// first frame so neither flicker on startup.
await HubService.instance.loadPersistedEndpoint();
final themeMode = await HubService.instance.loadThemeMode();
runApp(StudioApp(initialThemeMode: themeMode));
}
class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode;
const StudioApp({super.key, required this.initialThemeMode});
/// Lookup helper so descendants can flip the theme without
/// passing callbacks down through every level.
static StudioAppState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioAppState>();
@override
State<StudioApp> createState() => StudioAppState();
}
class StudioAppState extends State<StudioApp> {
/// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
/// theme flips. setState alone wouldn't propagate through
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
await HubService.instance.saveThemeMode(m);
}
@override
void initState() {
super.initState();
modeNotifier = ValueNotifier(widget.initialThemeMode);
}
@override
void dispose() {
modeNotifier.dispose();
super.dispose();
}
ThemeMode _flutterMode(ThemeModeValue v) {
switch (v) {
case ThemeModeValue.system:
return ThemeMode.system;
case ThemeModeValue.light:
return ThemeMode.light;
case ThemeModeValue.dark:
return ThemeMode.dark;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
themeMode: _flutterMode(mode),
home: const StudioShell(),
),
);
}
}
class StudioShell extends StatefulWidget {
const StudioShell({super.key});
@override
State<StudioShell> createState() => _StudioShellState();
}
class _StudioShellState extends State<StudioShell> {
int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
String? _activeChannel; // local / dev / beta / production
Timer? _healthPoll;
static const _pages = <_NavPage>[
_NavPage(
label: 'Doctor',
icon: Icons.health_and_safety_outlined,
selectedIcon: Icons.health_and_safety,
page: DoctorPage(),
),
_NavPage(
label: 'Modules',
icon: Icons.extension_outlined,
selectedIcon: Icons.extension,
page: ModulesPage(),
),
_NavPage(
label: 'Store',
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
page: StorePage(),
),
_NavPage(
label: 'Flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
label: 'Audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
label: 'Approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
];
@override
void initState() {
super.initState();
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkHealth(),
);
}
Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy();
if (!mounted) return;
if (_connected != ok) setState(() => _connected = ok);
// Refresh the active channel pointer alongside the health
// probe — cheap, and keeps the sidebar pill honest if the
// operator switched channels from the CLI.
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {
// Best-effort; pill simply doesn't update this tick.
}
}
}
@override
void dispose() {
_healthPoll?.cancel();
super.dispose();
}
void _openSearchPalette() {
final hits = <FaiSearchHit>[
for (var i = 0; i < _pages.length; i++)
FaiSearchHit(
label: _pages[i].label,
hint: 'page · ⌘${i + 1}',
icon: _pages[i].icon,
group: 'Pages',
onSelect: () {
Navigator.of(context).pop();
setState(() => _selectedIndex = i);
},
),
FaiSearchHit(
label: 'Settings',
hint: 'hub endpoint, channels, system AI · ⌘;',
icon: Icons.settings_outlined,
group: 'Pages',
onSelect: () {
Navigator.of(context).pop();
FaiSettingsDialog.show(context);
},
),
];
FaiSearchPalette.show(context, staticHits: hits);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..6 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar.
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true,
): _GoToPageIntent(i),
// Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS
// before it reaches our Shortcuts widget. Cmd+; is the
// working alternative; we register Cmd+, too for
// completeness in case a future Flutter version (or a
// native-menu setup) lets it through.
const SingleActivator(LogicalKeyboardKey.semicolon, meta: true):
const _OpenSettingsIntent(),
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const _OpenSettingsIntent(),
// Universal command palette. Mirrors Cmd+K from VSCode /
// Linear / 1Password — operators jump anywhere by typing.
const SingleActivator(LogicalKeyboardKey.keyK, meta: true):
const _OpenSearchIntent(),
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
const _OpenSearchIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_GoToPageIntent: CallbackAction<_GoToPageIntent>(
onInvoke: (intent) {
setState(() => _selectedIndex = intent.index);
return null;
},
),
_OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
onInvoke: (_) {
FaiSettingsDialog.show(context);
return null;
},
),
_OpenSearchIntent: CallbackAction<_OpenSearchIntent>(
onInvoke: (_) {
_openSearchPalette();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Row(
children: [
_Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
),
Container(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
),
),
),
],
),
),
),
),
);
}
}
/// Intents driven by the Shortcuts/Actions pair on the shell.
class _GoToPageIntent extends Intent {
final int index;
const _GoToPageIntent(this.index);
}
class _OpenSettingsIntent extends Intent {
const _OpenSettingsIntent();
}
class _OpenSearchIntent extends Intent {
const _OpenSearchIntent();
}
class _Sidebar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onSelect;
final List<_NavPage> pages;
final bool? connected;
final String endpointLabel;
/// Active channel name from the running daemon
/// (`local`/`dev`/`beta`/`production`). Null while the
/// initial fetch is in flight or the hub is unreachable.
final String? activeChannel;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
required this.pages,
required this.connected,
required this.endpointLabel,
required this.activeChannel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
return Container(
width: 220,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: Column(
children: [
// Brand mark.
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
const SizedBox(height: FaiSpace.md),
Text(
'F∆I Studio',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: FaiSpace.md),
// Connection pill.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ConnectionPill(
connected: connected,
label: endpointLabel,
onStartRequested: () async {
final r = await SystemActions.faiDaemon(['start']);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
r.ok
? 'Daemon start requested. Reconnecting…'
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
),
),
);
},
),
),
if (activeChannel != null && activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ChannelPill(channel: activeChannel!),
),
],
const SizedBox(height: FaiSpace.xxl),
// Destinations.
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
children: [
for (var i = 0; i < pages.length; i++)
_SidebarItem(
page: pages[i],
selected: i == selectedIndex,
onTap: () => onSelect(i),
),
],
),
),
// Footer: theme toggle · live wall-clock · settings.
// Clock sits between the two icons so it reads as a
// status line — gives the operator a wall-clock
// reference to cross-check audit-log timestamps
// without taking sidebar real estate from anything
// load-bearing.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: Row(
children: [
_ThemeToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () async {
final saved = await FaiSettingsDialog.show(context);
if (saved && context.mounted) {
// Force a sidebar rebuild so the new endpoint
// shows in the connection pill.
(context as Element).markNeedsBuild();
}
},
),
],
),
),
],
),
);
}
}
/// Active-channel indicator. Click to open Settings (where the
/// operator can switch). Color-coded so production deployments
/// look visibly different from local-dev — the kind of thing
/// you want to see before clicking "Clear log".
class _ChannelPill extends StatelessWidget {
final String channel;
const _ChannelPill({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
return cs.error;
case 'beta':
return FaiColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
IconData get _icon {
switch (channel) {
case 'production':
return Icons.shield_outlined;
case 'beta':
return Icons.science_outlined;
case 'dev':
return Icons.bolt_outlined;
case 'local':
default:
return Icons.home_work_outlined;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
return Tooltip(
message:
'Active channel — click to switch in Settings (⌘;).\n'
'production = stable · beta = pre-release · dev = rolling · local = workspace',
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(FaiRadius.sm),
onTap: () => FaiSettingsDialog.show(context),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon, size: 11, color: accent),
const SizedBox(width: 4),
Text(
channel,
style: FaiTheme.mono(
size: 10,
weight: FontWeight.w600,
color: accent,
),
),
],
),
),
),
),
);
}
}
class _ConnectionPill extends StatelessWidget {
final bool? connected;
final String label;
/// Triggered by the inline "Start hub" button when the
/// daemon is unreachable. Runs `fai daemon start` server-side
/// (well, locally — Studio shells out). The shell-level
/// health poll picks the daemon up on its next tick.
final Future<void> Function() onStartRequested;
const _ConnectionPill({
required this.connected,
required this.label,
required this.onStartRequested,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dotColor = connected == true
? FaiColors.success
: connected == false
? FaiColors.danger
: FaiColors.muted;
final caption = connected == true
? 'connected'
: connected == false
? 'unreachable'
: 'connecting…';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
FaiStatusDot(color: dotColor, pulsing: connected == true),
const SizedBox(width: 6),
Text(
caption,
style: theme.textTheme.labelSmall?.copyWith(
color: dotColor == FaiColors.danger
? theme.colorScheme.error
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 2),
Text(
label,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
// Inline "Start hub" affordance when the daemon
// isn't there. Resolves the chicken-and-egg for
// Windows operators who can't run `fai daemon start`
// from a shell.
if (connected == false) ...[
const SizedBox(height: FaiSpace.sm),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: onStartRequested,
icon: const Icon(Icons.play_arrow, size: 14),
label: const Text(
'Start hub',
style: TextStyle(fontSize: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
),
),
),
),
],
],
),
);
}
}
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
final VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.onTap,
});
@override
State<_SidebarItem> createState() => _SidebarItemState();
}
class _SidebarItemState extends State<_SidebarItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.md,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
),
const SizedBox(width: FaiSpace.md),
Text(
widget.page.label,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
),
),
),
),
);
}
}
class _NavPage {
final String label;
final IconData icon;
final IconData selectedIcon;
final Widget page;
const _NavPage({
required this.label,
required this.icon,
required this.selectedIcon,
required this.page,
});
}
class _ThemeToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: app.modeNotifier,
builder: (_, mode, _) {
final icon = switch (mode) {
ThemeModeValue.system => Icons.brightness_auto_outlined,
ThemeModeValue.light => Icons.light_mode_outlined,
ThemeModeValue.dark => Icons.dark_mode_outlined,
};
final tooltip = switch (mode) {
ThemeModeValue.system => 'theme: follow system',
ThemeModeValue.light => 'theme: light',
ThemeModeValue.dark => 'theme: dark',
};
return IconButton(
icon: Icon(icon, size: 16),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: () {
final next = switch (mode) {
ThemeModeValue.system => ThemeModeValue.light,
ThemeModeValue.light => ThemeModeValue.dark,
ThemeModeValue.dark => ThemeModeValue.system,
};
app.setMode(next);
},
);
},
);
}
}
/// Small ticking wall-clock for the sidebar. Aligns the on-screen
/// time with the timestamps the audit log writes — operators
/// glance here when an event timestamp looks off and want a
/// reference point. Updates on the second; tooltip exposes the
/// full ISO + UTC offset for unambiguous comparison.
class _SidebarClock extends StatefulWidget {
const _SidebarClock();
@override
State<_SidebarClock> createState() => _SidebarClockState();
}
class _SidebarClockState extends State<_SidebarClock> {
late DateTime _now;
Timer? _ticker;
@override
void initState() {
super.initState();
_now = DateTime.now();
_scheduleNextTick();
}
void _scheduleNextTick() {
// Align ticks on the second boundary so the seconds digit
// changes when the wall-clock second changes, not 0999 ms
// late depending on when the widget mounted.
final delay = Duration(milliseconds: 1000 - _now.millisecond);
_ticker = Timer(delay, () {
if (!mounted) return;
setState(() => _now = DateTime.now());
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _now = DateTime.now());
});
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hh = _now.hour.toString().padLeft(2, '0');
final mm = _now.minute.toString().padLeft(2, '0');
final ss = _now.second.toString().padLeft(2, '0');
final yyyy = _now.year.toString().padLeft(4, '0');
final mo = _now.month.toString().padLeft(2, '0');
final dd = _now.day.toString().padLeft(2, '0');
final offsetMin = _now.timeZoneOffset.inMinutes;
final sign = offsetMin >= 0 ? '+' : '-';
final absMin = offsetMin.abs();
final offH = (absMin ~/ 60).toString().padLeft(2, '0');
final offM = (absMin % 60).toString().padLeft(2, '0');
final tooltip =
'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
'UTC: ${_now.toUtc().toIso8601String()}';
return Tooltip(
message: tooltip,
child: Text(
'$hh:$mm:$ss',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
),
),
);
}
}