wire builtin.git sidebar panel + builtin.diff workspace tab
test / unit + widget + golden + a11y (push) Failing after 37s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / unit + widget + golden + a11y (push) Failing after 37s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
GitController hydrates from git.status IPC and auto-refreshes on git.changed events. Panel shows staged/unstaged/untracked/conflict groups with per-file actions and an inline commit field. DiffController renders unified diffs with line numbers, addition/ removal colouring, and a staged/unstaged toggle. Both extensions upgraded from stubs to 0.1.0. ClideText gains an optional fontFamily parameter so diff lines can render in JetBrainsMono without breaking golden tests (font still inherits from ambient DefaultTextStyle by default). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,31 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Git subsystem in the daemon (`lib/src/git/`). Status parser
|
||||
(`git status --porcelain`), unified-diff parser, and operations
|
||||
(stage, unstage, stage-hunk, discard, commit, stash, log, pull,
|
||||
push). IPC verbs `git.status | diff | stage | stage-all | unstage
|
||||
| stage-hunk | unstage-hunk | discard | commit | stash | stash-pop
|
||||
| log | pull | push` with `git.changed` events on mutations.
|
||||
42 new core tests cover parsing, operations, and dispatcher
|
||||
round-trips.
|
||||
|
||||
- `clide git …` CLI shortcuts: `git status`, `git diff [--staged]`,
|
||||
`git stage <paths>`, `git stage-all`, `git unstage`, `git discard`,
|
||||
`git commit "<msg>"`, `git log [--count N]`, `git stash`,
|
||||
`git stash-pop`, `git pull`, `git push`.
|
||||
|
||||
- `builtin.git` — sidebar panel showing staged, unstaged, untracked,
|
||||
and conflicted file groups. Per-file stage/unstage/discard on hover.
|
||||
Inline commit message input with Commit button. Branch + ahead/behind
|
||||
display with Pull/Push actions. Auto-refreshes on `git.changed`
|
||||
events.
|
||||
|
||||
- `builtin.diff` — workspace tab rendering unified diffs with
|
||||
old/new line numbers, addition/removal colouring, and binary/rename
|
||||
metadata. Staged/Unstaged toggle toolbar. Auto-refreshes on
|
||||
`git.changed` events.
|
||||
|
||||
- `builtin.editor` — Tier-2 editor tab wired up. Contributes a
|
||||
single `Editor` workspace tab that renders the daemon's active
|
||||
buffer via a new `EditorController`. Hydrates on mount
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/diff_controller.dart';
|
||||
export 'src/diff_view.dart';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/// State model for the diff workspace tab.
|
||||
///
|
||||
/// Holds the parsed diff data for a single file (or all files). Hydrates
|
||||
/// via `git.diff` IPC, subscribes to `git.changed` events to refresh.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class DiffController extends ChangeNotifier {
|
||||
DiffController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
List<Map<String, Object?>> _diffs = const [];
|
||||
List<Map<String, Object?>> get diffs => _diffs;
|
||||
|
||||
bool _staged = false;
|
||||
bool get showStaged => _staged;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
/// Load diffs. Optionally filter to [paths] and toggle [staged].
|
||||
Future<void> load({
|
||||
bool staged = false,
|
||||
List<String> paths = const [],
|
||||
}) async {
|
||||
_staged = staged;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.diff', args: {
|
||||
'staged': staged,
|
||||
if (paths.isNotEmpty) 'paths': paths,
|
||||
});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'git.diff failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_error = null;
|
||||
_diffs = _castList(r.data['diffs']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleStaged() {
|
||||
unawaited(load(staged: !_staged));
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'git') return;
|
||||
if (e.kind == 'git.changed') {
|
||||
unawaited(load(staged: _staged));
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/// Workspace tab rendering unified diffs with hunk-level
|
||||
/// stage/unstage actions.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'diff_controller.dart';
|
||||
|
||||
class DiffView extends StatefulWidget {
|
||||
const DiffView({super.key});
|
||||
|
||||
@override
|
||||
State<DiffView> createState() => _DiffViewState();
|
||||
}
|
||||
|
||||
class _DiffViewState extends State<DiffView> {
|
||||
DiffController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = DiffController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'diff view',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DiffToolbar(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true, fontSize: 12),
|
||||
),
|
||||
if (!c.loading && c.diffs.isEmpty && c.error == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged
|
||||
? 'No staged changes.'
|
||||
: 'No unstaged changes.',
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final diff in c.diffs)
|
||||
_FileDiff(diff: diff, controller: c),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffToolbar extends StatelessWidget {
|
||||
const _DiffToolbar({required this.controller});
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
toggled: !controller.showStaged,
|
||||
label: 'show unstaged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? controller.toggleStaged : null,
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: 12,
|
||||
color: controller.showStaged
|
||||
? tokens.globalTextMuted
|
||||
: tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Semantics(
|
||||
button: true,
|
||||
toggled: controller.showStaged,
|
||||
label: 'show staged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? null : controller.toggleStaged,
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: 12,
|
||||
color: controller.showStaged
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileDiff extends StatelessWidget {
|
||||
const _FileDiff({required this.diff, required this.controller});
|
||||
final Map<String, Object?> diff;
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = diff['path'] as String? ?? '';
|
||||
final isBinary = diff['binary'] as bool? ?? false;
|
||||
final isNew = diff['new'] as bool? ?? false;
|
||||
final isDeleted = diff['deleted'] as bool? ?? false;
|
||||
final isRenamed = diff['renamed'] as bool? ?? false;
|
||||
final additions = (diff['additions'] as num?)?.toInt() ?? 0;
|
||||
final removals = (diff['removals'] as num?)?.toInt() ?? 0;
|
||||
final hunks = (diff['hunks'] as List?) ?? const [];
|
||||
|
||||
final meta = <String>[];
|
||||
if (isNew) meta.add('new file');
|
||||
if (isDeleted) meta.add('deleted');
|
||||
if (isRenamed) {
|
||||
final oldPath = diff['oldPath'] as String?;
|
||||
if (oldPath != null) meta.add('renamed from $oldPath');
|
||||
}
|
||||
if (isBinary) meta.add('binary');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
color: tokens.panelHeader,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
path,
|
||||
fontSize: 12,
|
||||
color: tokens.panelHeaderForeground,
|
||||
),
|
||||
),
|
||||
if (additions > 0)
|
||||
ClideText('+$additions ', fontSize: 11,
|
||||
color: tokens.statusSuccess),
|
||||
if (removals > 0)
|
||||
ClideText('-$removals', fontSize: 11,
|
||||
color: tokens.statusError),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (meta.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(meta.join(' · '), fontSize: 11, muted: true),
|
||||
),
|
||||
if (!isBinary)
|
||||
for (final hunk in hunks)
|
||||
_HunkView(
|
||||
hunk: (hunk as Map).cast<String, Object?>(),
|
||||
filePath: path,
|
||||
controller: controller,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({
|
||||
required this.hunk,
|
||||
required this.filePath,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final Map<String, Object?> hunk;
|
||||
final String filePath;
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = hunk['header'] as String? ?? '';
|
||||
final lines = (hunk['lines'] as List?) ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(
|
||||
header,
|
||||
fontSize: 11,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
for (final lineObj in lines)
|
||||
_DiffLineRow(
|
||||
line: (lineObj as Map).cast<String, Object?>(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffLineRow extends StatelessWidget {
|
||||
const _DiffLineRow({required this.line});
|
||||
final Map<String, Object?> line;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final kind = line['kind'] as String? ?? 'context';
|
||||
final text = line['text'] as String? ?? '';
|
||||
final oldLineNo = line['oldLineNo'] as num?;
|
||||
final newLineNo = line['newLineNo'] as num?;
|
||||
|
||||
final (Color bg, Color fg) = switch (kind) {
|
||||
'addition' => (
|
||||
tokens.statusSuccess.withValues(alpha: 0.15),
|
||||
tokens.statusSuccess,
|
||||
),
|
||||
'removal' => (
|
||||
tokens.statusError.withValues(alpha: 0.15),
|
||||
tokens.statusError,
|
||||
),
|
||||
_ => (
|
||||
const Color(0x00000000),
|
||||
tokens.globalForeground,
|
||||
),
|
||||
};
|
||||
|
||||
final prefix = switch (kind) {
|
||||
'addition' => '+',
|
||||
'removal' => '-',
|
||||
'header' => '',
|
||||
_ => ' ',
|
||||
};
|
||||
|
||||
return Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(
|
||||
oldLineNo != null ? '${oldLineNo.toInt()}' : '',
|
||||
fontSize: 11,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(
|
||||
newLineNo != null ? '${newLineNo.toInt()}' : '',
|
||||
fontSize: 11,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
prefix,
|
||||
fontSize: 11,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: 11,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
import 'package:clide_app/builtin/diff/src/diff_view.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class DiffExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.diff';
|
||||
@override
|
||||
String get title => 'Diff';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'diff.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Diff',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -70,
|
||||
build: (_) => const DiffView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/git_controller.dart';
|
||||
export 'src/git_panel_view.dart';
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import 'package:clide_app/builtin/git/src/git_panel_view.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class GitExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.git';
|
||||
@override
|
||||
String get title => 'Git';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.diff'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'git.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Git',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/// State model for the git sidebar panel.
|
||||
///
|
||||
/// Hydrates from `git.status` IPC on load, subscribes to `git.changed`
|
||||
/// events to auto-refresh. Exposes stage/unstage/discard/commit actions
|
||||
/// that call git.* IPC verbs and let the event-driven refresh handle
|
||||
/// state reconciliation.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class GitController extends ChangeNotifier {
|
||||
GitController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _branch;
|
||||
String? get branch => _branch;
|
||||
|
||||
String? _upstream;
|
||||
String? get upstream => _upstream;
|
||||
|
||||
int _ahead = 0;
|
||||
int get ahead => _ahead;
|
||||
|
||||
int _behind = 0;
|
||||
int get behind => _behind;
|
||||
|
||||
bool _clean = true;
|
||||
bool get isClean => _clean;
|
||||
|
||||
bool _hasConflicts = false;
|
||||
bool get hasConflicts => _hasConflicts;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
List<Map<String, Object?>> _staged = const [];
|
||||
List<Map<String, Object?>> get staged => _staged;
|
||||
|
||||
List<Map<String, Object?>> _unstaged = const [];
|
||||
List<Map<String, Object?>> get unstaged => _unstaged;
|
||||
|
||||
List<Map<String, Object?>> _untracked = const [];
|
||||
List<Map<String, Object?>> get untracked => _untracked;
|
||||
|
||||
List<Map<String, Object?>> _conflicted = const [];
|
||||
List<Map<String, Object?>> get conflicted => _conflicted;
|
||||
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.status');
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'git.status failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_applyStatus(r.data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> stage(List<String> paths) async {
|
||||
final r = await ipc.request('git.stage', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> stageAll() async {
|
||||
final r = await ipc.request('git.stage-all');
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> unstage(List<String> paths) async {
|
||||
final r = await ipc.request('git.unstage', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> discard(List<String> paths) async {
|
||||
final r = await ipc.request('git.discard', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<String?> commit(String message) async {
|
||||
final r = await ipc.request('git.commit', args: {'message': message});
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
return r.data['hash'] as String?;
|
||||
}
|
||||
|
||||
Future<bool> stash({String? message}) async {
|
||||
final r = await ipc.request('git.stash', args: {
|
||||
if (message != null) 'message': message,
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> pull() async {
|
||||
final r = await ipc.request('git.pull');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
}
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> push() async {
|
||||
final r = await ipc.request('git.push');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
}
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (_error == null) return;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'git') return;
|
||||
if (e.kind == 'git.changed') {
|
||||
unawaited(load());
|
||||
}
|
||||
}
|
||||
|
||||
void _applyStatus(Map<String, Object?> data) {
|
||||
_branch = data['branch'] as String?;
|
||||
_upstream = data['upstream'] as String?;
|
||||
_ahead = (data['ahead'] as num?)?.toInt() ?? 0;
|
||||
_behind = (data['behind'] as num?)?.toInt() ?? 0;
|
||||
_clean = data['clean'] as bool? ?? true;
|
||||
_hasConflicts = data['hasConflicts'] as bool? ?? false;
|
||||
_staged = _castList(data['staged']);
|
||||
_unstaged = _castList(data['unstaged']);
|
||||
_untracked = _castList(data['untracked']);
|
||||
_conflicted = _castList(data['conflicted']);
|
||||
_error = null;
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/// Sidebar panel for git status — staged, unstaged, untracked,
|
||||
/// conflicted file groups with stage/unstage/discard actions and an
|
||||
/// inline commit message field.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'git_controller.dart';
|
||||
|
||||
class GitPanelView extends StatefulWidget {
|
||||
const GitPanelView({super.key});
|
||||
|
||||
@override
|
||||
State<GitPanelView> createState() => _GitPanelViewState();
|
||||
}
|
||||
|
||||
class _GitPanelViewState extends State<GitPanelView> {
|
||||
GitController? _controller;
|
||||
final TextEditingController _commitMsg = TextEditingController();
|
||||
final FocusNode _commitFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = GitController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_commitMsg.dispose();
|
||||
_commitFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'git panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: 11,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true, fontSize: 12),
|
||||
),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.',
|
||||
muted: true, fontSize: 12),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: c.conflicted,
|
||||
actions: const [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: c.staged,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
],
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: c.unstaged,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => unawaited(c.discard([path])),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: c.untracked,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchHeader extends StatelessWidget {
|
||||
const _BranchHeader({required this.controller});
|
||||
final GitController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final branch = controller.branch ?? '(detached)';
|
||||
final parts = <String>[branch];
|
||||
if (controller.ahead > 0) parts.add('↑${controller.ahead}');
|
||||
if (controller.behind > 0) parts.add('↓${controller.behind}');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: 12,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
_SmallAction(
|
||||
label: 'Pull',
|
||||
semanticsLabel: 'git pull',
|
||||
onTap: () => unawaited(controller.pull()),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_SmallAction(
|
||||
label: 'Push',
|
||||
semanticsLabel: 'git push',
|
||||
onTap: () => unawaited(controller.push()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommitInput extends StatelessWidget {
|
||||
const _CommitInput({
|
||||
required this.commitMsg,
|
||||
required this.commitFocus,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final TextEditingController commitMsg;
|
||||
final FocusNode commitFocus;
|
||||
final GitController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
label: 'commit message',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: commitMsg,
|
||||
focusNode: commitFocus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontSize: 12,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 3,
|
||||
onSubmitted: (_) => _doCommit(),
|
||||
inputFormatters: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideButton(
|
||||
label: 'Commit',
|
||||
onPressed: _doCommit,
|
||||
semanticLabel: 'commit staged changes',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _doCommit() {
|
||||
final msg = commitMsg.text.trim();
|
||||
if (msg.isEmpty) return;
|
||||
unawaited(controller.commit(msg).then((hash) {
|
||||
if (hash != null) commitMsg.clear();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupAction {
|
||||
const _GroupAction({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
}
|
||||
|
||||
class _FileGroup extends StatelessWidget {
|
||||
const _FileGroup({
|
||||
required this.label,
|
||||
required this.entries,
|
||||
this.actions = const [],
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> entries;
|
||||
final List<_GroupAction> actions;
|
||||
final void Function(String path)? onStage;
|
||||
final void Function(String path)? onUnstage;
|
||||
final void Function(String path)? onDiscard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'$label (${entries.length})',
|
||||
fontSize: 11,
|
||||
muted: true,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
for (final a in actions) ...[
|
||||
_SmallAction(label: a.label, onTap: a.onTap),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
_GitFileRow(
|
||||
entry: entry,
|
||||
onStage: onStage,
|
||||
onUnstage: onUnstage,
|
||||
onDiscard: onDiscard,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GitFileRow extends StatefulWidget {
|
||||
const _GitFileRow({
|
||||
required this.entry,
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
|
||||
final Map<String, Object?> entry;
|
||||
final void Function(String path)? onStage;
|
||||
final void Function(String path)? onUnstage;
|
||||
final void Function(String path)? onDiscard;
|
||||
|
||||
@override
|
||||
State<_GitFileRow> createState() => _GitFileRowState();
|
||||
}
|
||||
|
||||
class _GitFileRowState extends State<_GitFileRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = widget.entry['path'] as String? ?? '';
|
||||
final name = path.split('/').last;
|
||||
final indexState = widget.entry['indexState'] as String?;
|
||||
final workTreeState = widget.entry['workTreeState'] as String?;
|
||||
final state = indexState ?? workTreeState ?? '';
|
||||
final stateLabel = _stateLabel(state);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '$name $stateLabel',
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: 11,
|
||||
color: _stateColor(state, tokens),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
fontSize: 12,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
if (_hover) ...[
|
||||
if (widget.onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => widget.onStage!(path),
|
||||
),
|
||||
if (widget.onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => widget.onUnstage!(path),
|
||||
),
|
||||
if (widget.onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => widget.onDiscard!(path),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _stateIndicator(String state) {
|
||||
return switch (state) {
|
||||
'added' => 'A',
|
||||
'modified' => 'M',
|
||||
'deleted' => 'D',
|
||||
'renamed' => 'R',
|
||||
'copied' => 'C',
|
||||
'untracked' => '?',
|
||||
_ => ' ',
|
||||
};
|
||||
}
|
||||
|
||||
static String _stateLabel(String state) {
|
||||
return switch (state) {
|
||||
'added' => 'added',
|
||||
'modified' => 'modified',
|
||||
'deleted' => 'deleted',
|
||||
'renamed' => 'renamed',
|
||||
'copied' => 'copied',
|
||||
'untracked' => 'untracked',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
static Color _stateColor(String state, SurfaceTokens tokens) {
|
||||
return switch (state) {
|
||||
'added' || 'untracked' => tokens.statusSuccess,
|
||||
'modified' || 'renamed' || 'copied' => tokens.statusInfo,
|
||||
'deleted' => tokens.statusError,
|
||||
_ => tokens.sidebarForeground,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallAction extends StatelessWidget {
|
||||
const _SmallAction({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.semanticsLabel,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? semanticsLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticsLabel ?? label,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: 10,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class ClideText extends StatelessWidget {
|
||||
super.key,
|
||||
this.color,
|
||||
this.fontSize = 13,
|
||||
this.fontFamily,
|
||||
this.fontWeight,
|
||||
this.muted = false,
|
||||
this.maxLines,
|
||||
@@ -26,6 +27,7 @@ class ClideText extends StatelessWidget {
|
||||
final String data;
|
||||
final Color? color;
|
||||
final double fontSize;
|
||||
final String? fontFamily;
|
||||
final FontWeight? fontWeight;
|
||||
final bool muted;
|
||||
final int? maxLines;
|
||||
@@ -45,10 +47,7 @@ class ClideText extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: resolved,
|
||||
fontSize: fontSize,
|
||||
// Null means inherit from the ambient DefaultTextStyle —
|
||||
// _AppRoot installs clideUiDefaultWeight there; goldens get
|
||||
// whatever Alchemist injects. Passing an explicit weight (e.g.
|
||||
// FontWeight.bold) still wins.
|
||||
fontFamily: fontFamily,
|
||||
fontWeight: fontWeight,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user