diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 2689ae5..1208ee4 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -303,6 +303,29 @@ class HubService { return (purged: r.purged.toInt(), channel: r.channel); } + /// Filesystem paths the daemon owns. Used by Doctor to wire + /// "Open log file" / "Open config file" buttons. + Future daemonPaths() async { + final r = await _client.daemonPaths(); + return DaemonPathsSnapshot( + logPath: r.logPath, + dbPath: r.dbPath, + modulesDir: r.modulesDir, + flowsDir: r.flowsDir, + configPath: r.configPath, + pidPath: r.pidPath, + ); + } + + /// Remove an installed module by name. Returns the version + /// that was removed so the caller can confirm. + Future<({String name, String version})> uninstallModule( + String name, + ) async { + final r = await _client.uninstallModule(name); + return (name: r.name, version: r.version); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); @@ -521,6 +544,7 @@ class HubService { _client.checkUpdate().catchError( (_) => CheckUpdateResponse(), ), + _client.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); final caps = results[0] as List; @@ -528,6 +552,7 @@ class HubService { final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; final update = results[4] as CheckUpdateResponse; + final paths = results[5] as DaemonPathsResponse; // Module count = distinct module_name across capabilities. final moduleNames = {for (final c in caps) c.moduleName}; @@ -559,6 +584,14 @@ class HubService { update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl, reason: update.reason.isEmpty ? null : update.reason, ), + paths: DaemonPathsSnapshot( + logPath: paths.logPath, + dbPath: paths.dbPath, + modulesDir: paths.modulesDir, + flowsDir: paths.flowsDir, + configPath: paths.configPath, + pidPath: paths.pidPath, + ), ); } } @@ -592,6 +625,9 @@ class DoctorSnapshot { final String? eventChainTamperedAt; final List services; final UpdateStatus update; + /// Filesystem paths the daemon owns. Empty fields when the + /// hub couldn't resolve them (older daemon, no $HOME). + final DaemonPathsSnapshot paths; const DoctorSnapshot({ required this.moduleCount, @@ -602,6 +638,7 @@ class DoctorSnapshot { required this.eventChainTamperedAt, required this.services, required this.update, + required this.paths, }); bool get chainHealthy => eventChainTamperedAt == null; @@ -868,6 +905,28 @@ class ChannelStatusSnapshot { }); } +/// Filesystem paths the daemon owns. Studio's Doctor page +/// surfaces these as "Open" buttons so Windows operators who +/// never touch a shell can still inspect everything via the OS +/// file manager / default editor. +class DaemonPathsSnapshot { + final String logPath; + final String dbPath; + final String modulesDir; + final String flowsDir; + final String configPath; + final String pidPath; + + const DaemonPathsSnapshot({ + required this.logPath, + required this.dbPath, + required this.modulesDir, + required this.flowsDir, + required this.configPath, + required this.pidPath, + }); +} + /// Detected host hardware — see /// docs/architecture/system-ai.md → "Hardware tiers". class HardwareSnapshot { diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart new file mode 100644 index 0000000..6beb08a --- /dev/null +++ b/lib/data/system_actions.dart @@ -0,0 +1,122 @@ +// System-level actions Studio invokes by spawning the `fai` +// binary or asking the OS to open a file. Used by Doctor's +// daemon-control + open-paths affordances so Windows operators +// who never touch a shell can restart the daemon, apply an +// update, or open a log file from inside the GUI. +// +// Cross-platform notes: +// - macOS uses `open` for files + URLs. +// - Linux uses `xdg-open`. +// - Windows uses `explorer` (file manager) and a quoted CLI +// call for processes. +// +// The `fai` binary is resolved from PATH first; falls back to +// `~/.fai/bin/fai` (Unix) or `%USERPROFILE%\.fai\bin\fai.exe` +// (Windows). The PowerShell installer puts both in PATH but +// some operators don't restart their shell after install — the +// fallback covers that case. + +import 'dart:io'; + +class SystemActions { + SystemActions._(); + + /// Ask the OS to open [path] in the default handler. On macOS + /// this opens text files in TextEdit, configs in the registered + /// editor, etc. Returns true on a clean spawn (process exited + /// non-zero is still surfaced as false). + static Future<({bool ok, String stderr})> openInOs(String path) async { + if (path.isEmpty) { + return (ok: false, stderr: 'empty path'); + } + final cmd = _openCommand(); + try { + final r = await Process.run(cmd.executable, [...cmd.args, path]); + if (r.exitCode == 0) return (ok: true, stderr: ''); + return (ok: false, stderr: r.stderr.toString().trim()); + } catch (e) { + return (ok: false, stderr: e.toString()); + } + } + + /// Run a `fai daemon ...` subcommand and surface the captured + /// output. Used by Doctor's "Restart daemon" button. + static Future<({bool ok, String stdout, String stderr})> faiDaemon( + List args, + ) async { + return _runFai(['daemon', ...args]); + } + + /// Run `fai update apply --channel `. Long-running on a slow + /// network — caller should show a spinner. + static Future<({bool ok, String stdout, String stderr})> faiUpdateApply( + String channel, + ) async { + return _runFai(['update', 'apply', '--channel', channel]); + } + + static Future<({bool ok, String stdout, String stderr})> _runFai( + List args, + ) async { + final exe = _faiExecutable(); + if (exe == null) { + return ( + ok: false, + stdout: '', + stderr: 'Could not locate the `fai` binary. Install via the ' + 'platform installer or set FAI_BIN to its full path.', + ); + } + try { + final r = await Process.run(exe, args); + return ( + ok: r.exitCode == 0, + stdout: r.stdout.toString(), + stderr: r.stderr.toString(), + ); + } catch (e) { + return (ok: false, stdout: '', stderr: e.toString()); + } + } + + static ({String executable, List args}) _openCommand() { + if (Platform.isMacOS) return (executable: 'open', args: const []); + if (Platform.isWindows) return (executable: 'explorer', args: const []); + return (executable: 'xdg-open', args: const []); + } + + /// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to + /// the canonical install location under the user's home dir. + static String? _faiExecutable() { + final fromEnv = Platform.environment['FAI_BIN']; + if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { + return fromEnv; + } + + final isWindows = Platform.isWindows; + final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai'); + if (fromPath != null) return fromPath; + + final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME']; + if (home == null || home.isEmpty) return null; + final fallback = isWindows + ? '$home\\.fai\\bin\\fai.exe' + : '$home/.fai/bin/fai'; + return File(fallback).existsSync() ? fallback : null; + } + + /// Tiny PATH walker — we cannot rely on `which`/`where` being + /// reachable via Process.run on every platform. + static String? _whichFai(String name) { + final pathEnv = Platform.environment['PATH']; + if (pathEnv == null || pathEnv.isEmpty) return null; + final sep = Platform.isWindows ? ';' : ':'; + final slash = Platform.isWindows ? '\\' : '/'; + for (final dir in pathEnv.split(sep)) { + if (dir.isEmpty) continue; + final candidate = '$dir$slash$name'; + if (File(candidate).existsSync()) return candidate; + } + return null; + } +} diff --git a/lib/main.dart b/lib/main.dart index eeba970..c86f0e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,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.16.0'; +const String kStudioVersion = '0.17.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 04eb1f8..5eadfd0 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/system_actions.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -71,7 +72,10 @@ class _DoctorPageState extends State { const SizedBox(height: FaiSpace.xl), _Section( title: 'Event log', - child: _EventLogPanel(snapshot: s), + child: _EventLogPanel( + snapshot: s, + onRefresh: _refresh, + ), ), const SizedBox(height: FaiSpace.lg), _Section( @@ -83,6 +87,16 @@ class _DoctorPageState extends State { title: 'Host services', child: _ServicesPanel(snapshot: s), ), + const SizedBox(height: FaiSpace.lg), + _Section( + title: 'Daemon files', + child: _DaemonPathsPanel(paths: s.paths), + ), + const SizedBox(height: FaiSpace.lg), + _Section( + title: 'Daemon control', + child: _DaemonActionsCard(), + ), ], ); }, @@ -240,8 +254,12 @@ class _StatTile extends StatelessWidget { class _EventLogPanel extends StatelessWidget { final DoctorSnapshot snapshot; + /// Re-runs the doctor() call which re-verifies the chain. + /// Wired to a "Verify now" button so operators can re-check + /// after import / restore without restarting the daemon. + final VoidCallback onRefresh; - const _EventLogPanel({required this.snapshot}); + const _EventLogPanel({required this.snapshot, required this.onRefresh}); @override Widget build(BuildContext context) { @@ -280,6 +298,12 @@ class _EventLogPanel extends StatelessWidget { ], ), ), + OutlinedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.fact_check_outlined, size: 16), + label: const Text('Verify now'), + ), + const SizedBox(width: FaiSpace.sm), FaiPill( label: healthy ? 'WORM-1' : 'TAMPER', tone: healthy ? FaiPillTone.success : FaiPillTone.danger, @@ -290,6 +314,227 @@ class _EventLogPanel extends StatelessWidget { } } +/// Lists the daemon's filesystem paths with one "Open" button +/// per row. Windows operators who never touch a shell still +/// need a way to inspect the audit DB or edit the operator +/// config; this panel is that escape hatch. +class _DaemonPathsPanel extends StatelessWidget { + final DaemonPathsSnapshot paths; + + const _DaemonPathsPanel({required this.paths}); + + @override + Widget build(BuildContext context) { + final entries = <(String, String, IconData)>[ + ('Log', paths.logPath, Icons.description_outlined), + ('Config', paths.configPath, Icons.settings_outlined), + ('Audit DB', paths.dbPath, Icons.storage_outlined), + ('Modules dir', paths.modulesDir, Icons.extension_outlined), + ('Flows dir', paths.flowsDir, Icons.account_tree_outlined), + ('PID file', paths.pidPath, Icons.fingerprint), + ].where((e) => e.$2.isNotEmpty).toList(); + + if (entries.isEmpty) { + return FaiCard( + child: Text( + 'Daemon did not report file paths. ' + 'Update the running hub via `fai daemon restart`.', + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } + + return FaiCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3), + ], + ), + ); + } +} + +class _PathRow extends StatelessWidget { + final String label; + final String path; + final IconData icon; + + const _PathRow({required this.label, required this.path, required this.icon}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: FaiSpace.sm), + SizedBox( + width: 90, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText( + path, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: FaiSpace.sm), + OutlinedButton.icon( + onPressed: () async { + final r = await SystemActions.openInOs(path); + if (!context.mounted) return; + if (!r.ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open: ${r.stderr}')), + ); + } + }, + icon: const Icon(Icons.open_in_new, size: 14), + label: const Text('Open'), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ); + } +} + +/// Daemon control affordances: Restart, Apply update, Stop. +/// Each shells out to the `fai` binary (resolved via PATH or +/// `~/.fai/bin/fai`). Studio is the GUI shell; the binary owns +/// the supervisor logic so the daemon and CLI stay in lockstep. +class _DaemonActionsCard extends StatefulWidget { + @override + State<_DaemonActionsCard> createState() => _DaemonActionsCardState(); +} + +class _DaemonActionsCardState extends State<_DaemonActionsCard> { + bool _busy = false; + String? _output; + + Future _run( + String label, + Future<({bool ok, String stdout, String stderr})> Function() action, + ) async { + setState(() { + _busy = true; + _output = null; + }); + final r = await action(); + if (!mounted) return; + setState(() { + _busy = false; + _output = (r.ok + ? 'OK · $label\n${r.stdout}' + : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') + .trim(); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FaiCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Spawn the platform binary to manage the running daemon. ' + 'Mirrors the CLI: `fai daemon …`.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.sm, + runSpacing: FaiSpace.sm, + children: [ + FilledButton.tonalIcon( + onPressed: _busy + ? null + : () => + _run('daemon restart', () => SystemActions.faiDaemon(['restart'])), + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text('Restart daemon'), + ), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => + _run('daemon stop', () => SystemActions.faiDaemon(['stop'])), + icon: const Icon(Icons.stop_circle_outlined, size: 16), + label: const Text('Stop daemon'), + ), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => _run( + 'daemon status', + () => SystemActions.faiDaemon(['status']), + ), + icon: const Icon(Icons.health_and_safety_outlined, size: 16), + label: const Text('Status'), + ), + ], + ), + if (_busy) ...[ + const SizedBox(height: FaiSpace.md), + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: FaiSpace.sm), + Text( + 'Working…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + if (_output != null) ...[ + const SizedBox(height: FaiSpace.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + _output!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ], + ), + ); + } +} + class _ModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; @@ -437,14 +682,38 @@ class _ServicesPanel extends StatelessWidget { } } -class _UpdateBanner extends StatelessWidget { +class _UpdateBanner extends StatefulWidget { final UpdateStatus status; const _UpdateBanner({required this.status}); + @override + State<_UpdateBanner> createState() => _UpdateBannerState(); +} + +class _UpdateBannerState extends State<_UpdateBanner> { + bool _applying = false; + String? _applyOutput; + + Future _applyUpdate() async { + setState(() { + _applying = true; + _applyOutput = null; + }); + final r = await SystemActions.faiUpdateApply(widget.status.channel); + if (!mounted) return; + setState(() { + _applying = false; + _applyOutput = r.ok + ? 'Update applied. Restart Studio to see the new daemon version.' + : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final status = widget.status; final available = status.updateAvailable; final iconData = available ? Icons.system_update_alt_outlined @@ -457,49 +726,87 @@ class _UpdateBanner extends StatelessWidget { : 'Release host unreachable'; final body = available ? 'Channel ${status.channel} now offers ${status.latestVersion}. ' - 'Apply with: fai update apply --channel ${status.channel}' + 'Apply via the button — Studio shells out to ' + '`fai update apply --channel ${status.channel}`.' : (status.reason ?? 'Could not contact the release feed for ${status.channel}.'); return FaiCard( - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(iconData, size: 20, color: iconColor), - const SizedBox(width: FaiSpace.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - body, - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (available && status.releaseNotesUrl != null) ...[ - const SizedBox(height: 4), - Text( - 'notes: ${status.releaseNotesUrl}', - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.primary, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(iconData, size: 20, color: iconColor), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - ], - ], + const SizedBox(height: 2), + Text( + body, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (available && status.releaseNotesUrl != null) ...[ + const SizedBox(height: 4), + Text( + 'notes: ${status.releaseNotesUrl}', + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ), + ), + ], + ], + ), + ), + if (available) + FilledButton.icon( + onPressed: _applying ? null : _applyUpdate, + icon: _applying + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update_alt, size: 16), + label: Text(_applying ? 'Applying…' : 'Apply update'), + ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: available ? 'new' : 'offline', + tone: available ? FaiPillTone.success : FaiPillTone.neutral, + ), + ], + ), + if (_applyOutput != null) ...[ + const SizedBox(height: FaiSpace.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + _applyOutput!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), ), - ), - FaiPill( - label: available ? 'new' : 'offline', - tone: available ? FaiPillTone.success : FaiPillTone.neutral, - ), + ], ], ), ); diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index 8943413..d61871d 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -74,7 +74,11 @@ class _ModulesPageState extends State { itemBuilder: (context, i) { final m = modules[i]; return GestureDetector( - onTap: () => FaiModuleSheet.show(context, m.name), + onTap: () async { + final uninstalled = + await FaiModuleSheet.show(context, m.name); + if (uninstalled) _refresh(); + }, behavior: HitTestBehavior.opaque, child: MouseRegion( cursor: SystemMouseCursors.click, diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index a10667c..4aebd59 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -15,9 +15,11 @@ class FaiModuleSheet extends StatefulWidget { const FaiModuleSheet({super.key, required this.moduleName}); - /// Convenience launcher used from the Modules list. - static Future show(BuildContext context, String name) { - return showModalBottomSheet( + /// Convenience launcher used from the Modules list. Resolves + /// to `true` when the operator uninstalled the module — the + /// caller (Modules page) refreshes its list on that signal. + static Future show(BuildContext context, String name) async { + final r = await showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, isScrollControlled: true, @@ -28,6 +30,7 @@ class FaiModuleSheet extends StatefulWidget { ), builder: (_) => FaiModuleSheet(moduleName: name), ); + return r ?? false; } @override @@ -36,6 +39,7 @@ class FaiModuleSheet extends StatefulWidget { class _FaiModuleSheetState extends State { late final Future _future; + bool _uninstalling = false; @override void initState() { @@ -43,6 +47,50 @@ class _FaiModuleSheetState extends State { _future = HubService.instance.moduleInfo(widget.moduleName); } + Future _confirmAndUninstall(ModuleDetail detail) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Uninstall module?'), + content: Text( + 'Removes ${detail.name} v${detail.version} from this hub. ' + 'Flows that reference it will fail at the next run. ' + 'A `module.uninstalled` audit event is recorded.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), + child: const Text('Uninstall'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + setState(() => _uninstalling = true); + try { + final r = await HubService.instance.uninstallModule(detail.name); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Uninstalled ${r.name} v${r.version}.')), + ); + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + setState(() => _uninstalling = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Uninstall failed: $e')), + ); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -73,7 +121,13 @@ class _FaiModuleSheetState extends State { ), ) else - Flexible(child: _Body(detail: snapshot.data!)), + Flexible( + child: _Body( + detail: snapshot.data!, + uninstalling: _uninstalling, + onUninstall: () => _confirmAndUninstall(snapshot.data!), + ), + ), ], ); }, @@ -102,8 +156,14 @@ class _Handle extends StatelessWidget { class _Body extends StatelessWidget { final ModuleDetail detail; + final bool uninstalling; + final VoidCallback onUninstall; - const _Body({required this.detail}); + const _Body({ + required this.detail, + required this.uninstalling, + required this.onUninstall, + }); @override Widget build(BuildContext context) { @@ -196,6 +256,29 @@ class _Body extends StatelessWidget { ) .toList(), ), + const SizedBox(height: FaiSpace.xxl), + Row( + children: [ + const Spacer(), + OutlinedButton.icon( + onPressed: uninstalling ? null : onUninstall, + icon: uninstalling + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline, size: 16), + label: Text(uninstalling ? 'Uninstalling…' : 'Uninstall'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide( + color: theme.colorScheme.error.withValues(alpha: 0.5), + ), + ), + ), + ], + ), ], ); } diff --git a/pubspec.lock b/pubspec.lock index 91784c9..0370b3f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.8.0" + version: "0.9.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 23e226b..a145533 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.16.0 +version: 0.17.0 environment: sdk: ^3.11.0-200.1.beta