import 'dart:async'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; class GitStatusItem extends StatefulWidget { const GitStatusItem({super.key}); @override State createState() => _GitStatusItemState(); } class _GitStatusItemState extends State { String? _branch; int _ahead = 0; int _behind = 0; StreamSubscription? _sub; @override void didChangeDependencies() { super.didChangeDependencies(); if (_sub != null) return; final kernel = ClideKernel.of(context); _sub = kernel.events.on().listen(_onEvent); unawaited(_load(kernel.ipc)); } Future _load(DaemonClient ipc) async { final r = await ipc.request('git.status'); if (!r.ok || !mounted) return; setState(() { _branch = r.data['branch'] as String?; _ahead = (r.data['ahead'] as num?)?.toInt() ?? 0; _behind = (r.data['behind'] as num?)?.toInt() ?? 0; }); } void _onEvent(DaemonEvent e) { if (e.subsystem != 'git' || e.kind != 'git.changed') return; final kernel = ClideKernel.of(context); unawaited(_load(kernel.ipc)); } @override void dispose() { _sub?.cancel(); super.dispose(); } void _openBranchPicker() { final kernel = ClideKernel.of(context); kernel.dialog.show((ctx, dismiss) => _BranchPicker(ipc: kernel.ipc, currentBranch: _branch, onDismiss: dismiss)); } @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; if (_branch == null) return const SizedBox.shrink(); final parts = [_branch!]; if (_ahead > 0) parts.add('↑$_ahead'); if (_behind > 0) parts.add('↓$_behind'); return Semantics( button: true, label: ClideSettings.i18n.interpolated( context, 'branch.switch.semantics', namespace: 'builtin.git', placeholder: 'switch branch — {branch}', replacers: [I18nReplacer(from: '{branch}', replace: _branch!)], ), child: GestureDetector( onTap: _openBranchPicker, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ ClideIcon(const GitBranchIcon(), size: 12, color: tokens.statusBarForeground), const SizedBox(width: 4), ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.statusBarForeground), ], ), ), ), ), ); } } class _BranchPicker extends StatefulWidget { const _BranchPicker({required this.ipc, required this.currentBranch, required this.onDismiss}); final DaemonClient ipc; final String? currentBranch; final void Function([String?]) onDismiss; @override State<_BranchPicker> createState() => _BranchPickerState(); } class _BranchPickerState extends State<_BranchPicker> { List> _branches = const []; bool _loading = true; String? _error; late final FocusNode _focus; @override void initState() { super.initState(); _focus = FocusNode()..requestFocus(); unawaited(_load()); } @override void dispose() { _focus.dispose(); super.dispose(); } Future _load() async { final r = await widget.ipc.request('git.branches'); if (!mounted) return; setState(() { _loading = false; if (r.ok) { _branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast()]; } else { _error = r.error?.message ?? ClideSettings.i18n.string(context, 'branchPicker.loadFailed', namespace: 'builtin.git', placeholder: 'failed to load branches'); } }); } Future _checkout(String branch) async { await widget.ipc.request('git.checkout', args: {'branch': branch}); 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 Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; return Focus( focusNode: _focus, onKeyEvent: _onKey, child: Container( width: 320, constraints: const BoxConstraints(maxHeight: 320), decoration: BoxDecoration( color: tokens.dropdownBackground, border: Border.all(color: tokens.dropdownBorder), borderRadius: BorderRadius.circular(6), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Expanded( child: ClideText( ClideSettings.i18n.string(context, 'branchPicker.title', namespace: 'builtin.git', placeholder: 'Switch branch'), fontSize: clideFontCaption, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context), ), ), GestureDetector( onTap: () => widget.onDismiss(), child: MouseRegion( cursor: SystemMouseCursors.click, child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: tokens.globalTextMuted), ), ), ], ), ), if (_loading) Padding( padding: const EdgeInsets.all(12), child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.git', placeholder: 'Loading…'), muted: true), ), if (_error != null) Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)), if (!_loading && _error == null && _branches.isEmpty) Padding( padding: const EdgeInsets.all(12), child: ClideText( ClideSettings.i18n.string(context, 'branchPicker.empty', namespace: 'builtin.git', placeholder: 'No branches found.'), muted: true, ), ), if (_branches.isNotEmpty) Flexible( child: ListView.builder( shrinkWrap: true, padding: EdgeInsets.zero, itemCount: _branches.length, itemBuilder: (ctx, i) { final b = _branches[i]; final name = b['name'] as String? ?? ''; final current = b['current'] as bool? ?? false; return _BranchRow(name: name, current: current, onTap: current ? null : () => unawaited(_checkout(name))); }, ), ), ], ), ), ); } } class _BranchRow extends StatelessWidget { const _BranchRow({required this.name, required this.current, this.onTap}); final String name; final bool current; final VoidCallback? onTap; @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; return ClideTappable( onTap: onTap, cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer, builder: (context, hovered, _) => Container( color: hovered ? tokens.listItemHoverBackground : null, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ if (current) Padding( padding: const EdgeInsets.only(right: 8), child: ClideIcon(const CheckIcon(), size: 12, color: tokens.statusSuccess), ) else const SizedBox(width: 20), Expanded( child: ClideText( name, fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: current ? tokens.globalForeground : tokens.listItemForeground, ), ), ], ), ), ); } }