fix branch picker: escape to dismiss, error display, backdrop click

Branch picker now handles Escape key to dismiss, shows an × close
button in the header, and displays error/empty states instead of
silently showing nothing. Dialog backdrop is click-to-dismiss (the
dialog content stops propagation so clicks inside don't close it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:57:46 +02:00
co-authored by Claude Opus 4.6
parent 2561329836
commit 8b6fdbd294
2 changed files with 87 additions and 47 deletions
+77 -43
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class GitStatusItem extends StatefulWidget { class GitStatusItem extends StatefulWidget {
@@ -116,13 +117,22 @@ class _BranchPicker extends StatefulWidget {
class _BranchPickerState extends State<_BranchPicker> { class _BranchPickerState extends State<_BranchPicker> {
List<Map<String, Object?>> _branches = const []; List<Map<String, Object?>> _branches = const [];
bool _loading = true; bool _loading = true;
String? _error;
late final FocusNode _focus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focus = FocusNode()..requestFocus();
unawaited(_load()); unawaited(_load());
} }
@override
void dispose() {
_focus.dispose();
super.dispose();
}
Future<void> _load() async { Future<void> _load() async {
final r = await widget.ipc.request('git.branches'); final r = await widget.ipc.request('git.branches');
if (!mounted) return; if (!mounted) return;
@@ -133,6 +143,8 @@ class _BranchPickerState extends State<_BranchPicker> {
for (final b in (r.data['branches'] as List? ?? const [])) for (final b in (r.data['branches'] as List? ?? const []))
(b as Map).cast<String, Object?>(), (b as Map).cast<String, Object?>(),
]; ];
} else {
_error = r.error?.message ?? 'failed to load branches';
} }
}); });
} }
@@ -142,53 +154,75 @@ class _BranchPickerState extends State<_BranchPicker> {
widget.onDismiss(); widget.onDismiss();
} }
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
return Container( return Focus(
width: 320, focusNode: _focus,
constraints: const BoxConstraints(maxHeight: 320), onKeyEvent: _onKey,
decoration: BoxDecoration( child: Container(
color: tokens.dropdownBackground, width: 320,
border: Border.all(color: tokens.dropdownBorder), constraints: const BoxConstraints(maxHeight: 320),
borderRadius: BorderRadius.circular(6), decoration: BoxDecoration(
), color: tokens.dropdownBackground,
child: Column( border: Border.all(color: tokens.dropdownBorder),
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(6),
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [ child: Column(
Padding( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.all(12), crossAxisAlignment: CrossAxisAlignment.stretch,
child: ClideText( children: [
'Switch branch', Padding(
fontSize: clideFontCaption, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: tokens.globalTextMuted, child: Row(
fontFamily: clideMonoFamily, children: [
Expanded(
child: ClideText('Switch branch', fontSize: clideFontCaption, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
GestureDetector(
onTap: () => widget.onDismiss(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: tokens.globalTextMuted),
),
),
],
),
), ),
), if (_loading)
if (_loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
const Padding( if (_error != null)
padding: EdgeInsets.all(12), Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
child: ClideText('Loading…', muted: true), if (!_loading && _error == null && _branches.isEmpty)
), const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
Flexible( if (_branches.isNotEmpty)
child: ListView.builder( Flexible(
shrinkWrap: true, child: ListView.builder(
padding: EdgeInsets.zero, shrinkWrap: true,
itemCount: _branches.length, padding: EdgeInsets.zero,
itemBuilder: (ctx, i) { itemCount: _branches.length,
final b = _branches[i]; itemBuilder: (ctx, i) {
final name = b['name'] as String? ?? ''; final b = _branches[i];
final current = b['current'] as bool? ?? false; final name = b['name'] as String? ?? '';
return _BranchRow( final current = b['current'] as bool? ?? false;
name: name, return _BranchRow(
current: current, name: name,
onTap: current ? null : () => unawaited(_checkout(name)), current: current,
); onTap: current ? null : () => unawaited(_checkout(name)),
}, );
), },
), ),
], ),
],
),
), ),
); );
} }
+10 -4
View File
@@ -93,10 +93,16 @@ class DialogHost extends StatelessWidget {
final b = router.current; final b = router.current;
if (b == null) return const SizedBox.shrink(); if (b == null) return const SizedBox.shrink();
return Positioned.fill( return Positioned.fill(
child: ColoredBox( child: GestureDetector(
color: backdropColor, onTap: () => router.dismiss(),
child: Center( child: ColoredBox(
child: b(ctx, router.dismiss), color: backdropColor,
child: Center(
child: GestureDetector(
onTap: () {},
child: b(ctx, router.dismiss),
),
),
), ),
), ),
); );