diff --git a/app/lib/builtin/git/src/extension.dart b/app/lib/builtin/git/src/extension.dart index cba94db6..606778f2 100644 --- a/app/lib/builtin/git/src/extension.dart +++ b/app/lib/builtin/git/src/extension.dart @@ -1,4 +1,5 @@ import 'package:clide_app/builtin/git/src/git_panel_view.dart'; +import 'package:clide_app/builtin/git/src/git_status_item.dart'; import 'package:clide_app/extension/extension.dart'; import 'package:clide_app/kernel/kernel.dart'; @@ -23,5 +24,10 @@ class GitExtension extends ClideExtension { priority: -80, build: (_) => const GitPanelView(), ), + StatusItemContribution( + id: 'git.branch', + priority: 10, + build: (_) => const GitStatusItem(), + ), ]; } diff --git a/app/lib/builtin/git/src/git_status_item.dart b/app/lib/builtin/git/src/git_status_item.dart new file mode 100644 index 00000000..a86ba36d --- /dev/null +++ b/app/lib/builtin/git/src/git_status_item.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.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 = ClideTheme.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: 'switch branch — $_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; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + 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(), + ]; + } + }); + } + + Future _checkout(String branch) async { + await widget.ipc.request('git.checkout', args: {'branch': branch}); + widget.onDismiss(); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return 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.all(12), + child: ClideText( + 'Switch branch', + fontSize: clideFontCaption, + color: tokens.globalTextMuted, + fontFamily: clideMonoFamily, + ), + ), + if (_loading) + const Padding( + padding: EdgeInsets.all(12), + child: ClideText('Loading…', muted: true), + ), + 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 StatefulWidget { + const _BranchRow({ + required this.name, + required this.current, + this.onTap, + }); + + final String name; + final bool current; + final VoidCallback? onTap; + + @override + State<_BranchRow> createState() => _BranchRowState(); +} + +class _BranchRowState extends State<_BranchRow> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return MouseRegion( + cursor: + widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + color: _hover ? tokens.listItemHoverBackground : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + if (widget.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( + widget.name, + fontFamily: clideMonoFamily, + fontSize: clideFontMono, + color: widget.current + ? tokens.globalForeground + : tokens.listItemForeground, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/daemon/git_commands.dart b/lib/src/daemon/git_commands.dart index b7903a81..0c4fca61 100644 --- a/lib/src/daemon/git_commands.dart +++ b/lib/src/daemon/git_commands.dart @@ -218,6 +218,37 @@ void registerGitCommands( return _gitError(req.id, e); } }); + + d.register('git.branches', (req) async { + final branches = await gitBranches(workDir); + return IpcResponse.ok(id: req.id, data: { + 'branches': [ + for (final b in branches) + {'name': b.name, 'current': b.current}, + ], + }); + }); + + d.register('git.checkout', (req) async { + final branch = req.args['branch'] as String?; + if (branch == null || branch.isEmpty) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'git.checkout requires a branch', + ), + ); + } + try { + await gitCheckout(workDir, branch); + _emitChanged(events); + return IpcResponse.ok(id: req.id, data: {'branch': branch}); + } on GitException catch (e) { + return _gitError(req.id, e); + } + }); } List _pathList(Object? raw) { diff --git a/lib/src/git/operations.dart b/lib/src/git/operations.dart index c14a0e8d..9965285a 100644 --- a/lib/src/git/operations.dart +++ b/lib/src/git/operations.dart @@ -194,6 +194,37 @@ Future gitPush( return ((r.stdout as String) + (r.stderr as String)).trim(); } +/// List local branches. Returns (name, isCurrent) pairs. +Future> gitBranches( + Directory workDir) async { + final r = await Process.run( + 'git', + ['branch', '--format=%(refname:short)\x00%(HEAD)'], + workingDirectory: workDir.path, + ); + if (r.exitCode != 0) return const []; + final out = <({String name, bool current})>[]; + for (final line in (r.stdout as String).split('\n')) { + if (line.trim().isEmpty) continue; + final parts = line.split('\x00'); + if (parts.length < 2) continue; + out.add((name: parts[0], current: parts[1].trim() == '*')); + } + return out; +} + +/// Checkout a branch. +Future gitCheckout(Directory workDir, String branch) async { + final r = await Process.run( + 'git', + ['checkout', branch], + workingDirectory: workDir.path, + ); + if (r.exitCode != 0) { + throw GitException('git checkout failed', stderr: r.stderr as String); + } +} + /// Get the current branch name. Future gitCurrentBranch(Directory workDir) async { final r = await Process.run(