fix(studio): live-search exception, daemon-card width, featured tile size; add Start-hub button (v0.20.1)
Three reported bugs and one onramp gap: - Store live-search threw `setState() callback returned a Future`. The `=>` body of `_runSearch` evaluated to the Future returned by `_load()`. Switched to a block body so the closure resolves to void; the future itself is still awaited by the FutureBuilder. - Doctor's Daemon-control card was visibly narrower than its siblings — its only stretching child was a Wrap of small buttons. Wrapped the content in `SizedBox(width: infinity)` so the card fills the section's width like every other panel. Card now leads with the live `running on local: 127.0.0.1:50051` status row + status pill so operators see state at a glance, then offers Restart / Start (when stopped) / Stop / Status. - Featured tiles were 320×152 against the regular grid's ~360×168, so the editorial picks looked subordinate to the dense grid. Bumped to 480×220 with bigger icon, larger title, larger tagline so the strip reads as a hero band. - ConnectionPill in the sidebar now offers an inline "Start hub" button when the daemon is unreachable. Spawns `fai daemon start` via SystemActions; the shell-level health poll picks the daemon up on its next tick. Closes the chicken-and-egg for Windows users who can't open a shell to bootstrap. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
26691b7471
commit
fb7352d182
4 changed files with 256 additions and 96 deletions
|
|
@ -424,6 +424,23 @@ class _DaemonActionsCard extends StatefulWidget {
|
|||
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||
bool _busy = false;
|
||||
String? _output;
|
||||
ChannelStatusSnapshot? _channels;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshStatus();
|
||||
}
|
||||
|
||||
Future<void> _refreshStatus() async {
|
||||
try {
|
||||
final snap = await HubService.instance.channelStatus();
|
||||
if (!mounted) return;
|
||||
setState(() => _channels = snap);
|
||||
} catch (_) {
|
||||
// Best-effort; the status pill simply renders "unknown".
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
String label,
|
||||
|
|
@ -442,94 +459,168 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
||||
.trim();
|
||||
});
|
||||
// Refresh the status pill — restart / stop / start all
|
||||
// change the running flag.
|
||||
await _refreshStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
ChannelInfo? active;
|
||||
if (_channels != null) {
|
||||
for (final c in _channels!.channels) {
|
||||
if (c.name == _channels!.active) {
|
||||
active = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 + Expanded forces the card to take the full width
|
||||
// its parent offers. Without this, the card hugs the
|
||||
// intrinsic width of the longest button row and ends up
|
||||
// visibly narrower than its siblings.
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
Icon(
|
||||
active?.running == true
|
||||
? Icons.check_circle_outline
|
||||
: Icons.power_off_outlined,
|
||||
size: 18,
|
||||
color: active?.running == true
|
||||
? FaiColors.success
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'Working…',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Expanded(
|
||||
child: Text(
|
||||
active == null
|
||||
? 'Daemon status: …'
|
||||
: active.running
|
||||
? 'Running on ${active.name}: ${active.endpoint}'
|
||||
: '${active.name} daemon stopped',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (active != null) ...[
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Tooltip(
|
||||
message: 'PID file at the channel\'s run/<name>.pid',
|
||||
child: FaiPill(
|
||||
label: active.running ? 'running' : 'stopped',
|
||||
tone: active.running
|
||||
? FaiPillTone.success
|
||||
: FaiPillTone.neutral,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
'Studio shells out to the platform binary — mirrors '
|
||||
'`fai daemon …` exactly so the CLI and UI stay in lockstep.',
|
||||
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'),
|
||||
),
|
||||
if (active?.running != true)
|
||||
FilledButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon start',
|
||||
() => SystemActions.faiDaemon(['start']),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: const Text('Start'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon stop',
|
||||
() => SystemActions.faiDaemon(['stop']),
|
||||
),
|
||||
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
||||
label: const Text('Stop'),
|
||||
),
|
||||
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 (_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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SelectableText(
|
||||
_output!,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,15 @@ class _StorePageState extends State<StorePage> {
|
|||
return _installedOnly ? all.where((e) => e.installed).toList() : all;
|
||||
}
|
||||
|
||||
void _runSearch() => setState(() => _future = _load());
|
||||
void _runSearch() {
|
||||
// Block body, so the closure's return type is `void`
|
||||
// rather than `Future`. setState() rejects closures that
|
||||
// resolve to a Future — the assignment must be synchronous
|
||||
// even though the future itself is awaited later.
|
||||
setState(() {
|
||||
_future = _load();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openDetail(StoreItem item) async {
|
||||
final changed = await _StoreDetailSheet.show(context, item, _locale);
|
||||
|
|
@ -533,7 +541,11 @@ class _FeaturedStrip extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
SizedBox(
|
||||
height: 152,
|
||||
// Tiles render larger than regular grid cards
|
||||
// (which are 168 × ~360) so the editorial picks
|
||||
// visibly stand out. Same hierarchy you see on app
|
||||
// stores: hero band before the dense list.
|
||||
height: 220,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
|
|
@ -541,7 +553,7 @@ class _FeaturedStrip extends StatelessWidget {
|
|||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
return SizedBox(
|
||||
width: 320,
|
||||
width: 480,
|
||||
child: _FeaturedTile(
|
||||
item: item,
|
||||
locale: locale,
|
||||
|
|
@ -584,12 +596,12 @@ class _FeaturedTile extends StatelessWidget {
|
|||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(alpha: 0.10),
|
||||
theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
|
|
@ -602,34 +614,46 @@ class _FeaturedTile extends StatelessWidget {
|
|||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
radius: 24,
|
||||
backgroundColor:
|
||||
theme.colorScheme.primary.withValues(alpha: 0.16),
|
||||
theme.colorScheme.primary.withValues(alpha: 0.18),
|
||||
child: Icon(
|
||||
_iconForCategory(item.category),
|
||||
size: 22,
|
||||
size: 26,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.category.isNotEmpty)
|
||||
Text(
|
||||
item.category,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.star, size: 14, color: FaiColors.success),
|
||||
Icon(Icons.star, size: 18, color: FaiColors.success),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tagline.isEmpty ? '(no tagline)' : tagline,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -659,11 +683,8 @@ class _FeaturedTile extends StatelessWidget {
|
|||
else if (installable)
|
||||
FilledButton.icon(
|
||||
onPressed: onInstall,
|
||||
icon: const Icon(Icons.download, size: 14),
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('Install'),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue