add git branch picker + status bar indicator
git.branches and git.checkout IPC verbs. Status bar shows current branch with ahead/behind count; clicking opens a branch picker dialog for checkout. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<GitStatusItem> createState() => _GitStatusItemState();
|
||||
}
|
||||
|
||||
class _GitStatusItemState extends State<GitStatusItem> {
|
||||
String? _branch;
|
||||
int _ahead = 0;
|
||||
int _behind = 0;
|
||||
StreamSubscription<DaemonEvent>? _sub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_sub != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_sub = kernel.events.on<DaemonEvent>().listen(_onEvent);
|
||||
unawaited(_load(kernel.ipc));
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
(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 = <String>[_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<Map<String, Object?>> _branches = const [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _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<String, Object?>(),
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> _pathList(Object? raw) {
|
||||
|
||||
@@ -194,6 +194,37 @@ Future<String> gitPush(
|
||||
return ((r.stdout as String) + (r.stderr as String)).trim();
|
||||
}
|
||||
|
||||
/// List local branches. Returns (name, isCurrent) pairs.
|
||||
Future<List<({String name, bool current})>> 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<void> 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<String?> gitCurrentBranch(Directory workDir) async {
|
||||
final r = await Process.run(
|
||||
|
||||
Reference in New Issue
Block a user