From 6bce2621b759e599c13a19257f5471c4833755e9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 11:09:48 +0200 Subject: [PATCH] wire builtin.git sidebar panel + builtin.diff workspace tab 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 --- CHANGELOG.md | 25 + app/lib/builtin/diff/diff.dart | 2 + app/lib/builtin/diff/src/diff_controller.dart | 82 +++ app/lib/builtin/diff/src/diff_view.dart | 340 +++++++++++++ app/lib/builtin/diff/src/extension.dart | 20 +- app/lib/builtin/git/git.dart | 2 + app/lib/builtin/git/src/extension.dart | 20 +- app/lib/builtin/git/src/git_controller.dart | 169 +++++++ app/lib/builtin/git/src/git_panel_view.dart | 469 ++++++++++++++++++ app/lib/widgets/src/clide_text.dart | 7 +- 10 files changed, 1122 insertions(+), 14 deletions(-) create mode 100644 app/lib/builtin/diff/src/diff_controller.dart create mode 100644 app/lib/builtin/diff/src/diff_view.dart create mode 100644 app/lib/builtin/git/src/git_controller.dart create mode 100644 app/lib/builtin/git/src/git_panel_view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d745c2c..37fc2ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, `git stage-all`, `git unstage`, `git discard`, + `git commit ""`, `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 diff --git a/app/lib/builtin/diff/diff.dart b/app/lib/builtin/diff/diff.dart index b968b883..56b31bb6 100644 --- a/app/lib/builtin/diff/diff.dart +++ b/app/lib/builtin/diff/diff.dart @@ -1 +1,3 @@ export 'src/extension.dart'; +export 'src/diff_controller.dart'; +export 'src/diff_view.dart'; diff --git a/app/lib/builtin/diff/src/diff_controller.dart b/app/lib/builtin/diff/src/diff_controller.dart new file mode 100644 index 00000000..4dd71c74 --- /dev/null +++ b/app/lib/builtin/diff/src/diff_controller.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().listen(_onEvent); + } + + final DaemonClient ipc; + final EventBus events; + + StreamSubscription? _eventSub; + + List> _diffs = const []; + List> 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 load({ + bool staged = false, + List 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> _castList(Object? raw) { + if (raw is! List) return const []; + return [for (final e in raw) (e as Map).cast()]; + } + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + super.dispose(); + } +} diff --git a/app/lib/builtin/diff/src/diff_view.dart b/app/lib/builtin/diff/src/diff_view.dart new file mode 100644 index 00000000..7d818ffe --- /dev/null +++ b/app/lib/builtin/diff/src/diff_view.dart @@ -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 createState() => _DiffViewState(); +} + +class _DiffViewState extends State { + 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 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 = []; + 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(), + 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 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(), + ), + ], + ); + } +} + +class _DiffLineRow extends StatelessWidget { + const _DiffLineRow({required this.line}); + final Map 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, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/builtin/diff/src/extension.dart b/app/lib/builtin/diff/src/extension.dart index 7708aaa0..dd158505 100644 --- a/app/lib/builtin/diff/src/extension.dart +++ b/app/lib/builtin/diff/src/extension.dart @@ -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 get dependsOn => const []; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'diff.view', + slot: Slots.workspace, + title: 'Diff', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -70, + build: (_) => const DiffView(), + ), + ]; } diff --git a/app/lib/builtin/git/git.dart b/app/lib/builtin/git/git.dart index b968b883..b3400928 100644 --- a/app/lib/builtin/git/git.dart +++ b/app/lib/builtin/git/git.dart @@ -1 +1,3 @@ export 'src/extension.dart'; +export 'src/git_controller.dart'; +export 'src/git_panel_view.dart'; diff --git a/app/lib/builtin/git/src/extension.dart b/app/lib/builtin/git/src/extension.dart index cc70e4d8..cba94db6 100644 --- a/app/lib/builtin/git/src/extension.dart +++ b/app/lib/builtin/git/src/extension.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 get dependsOn => const ['builtin.diff']; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'git.panel', + slot: Slots.sidebar, + title: 'Git', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -80, + build: (_) => const GitPanelView(), + ), + ]; } diff --git a/app/lib/builtin/git/src/git_controller.dart b/app/lib/builtin/git/src/git_controller.dart new file mode 100644 index 00000000..000e5a8a --- /dev/null +++ b/app/lib/builtin/git/src/git_controller.dart @@ -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().listen(_onEvent); + } + + final DaemonClient ipc; + final EventBus events; + + StreamSubscription? _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> _staged = const []; + List> get staged => _staged; + + List> _unstaged = const []; + List> get unstaged => _unstaged; + + List> _untracked = const []; + List> get untracked => _untracked; + + List> _conflicted = const []; + List> get conflicted => _conflicted; + + Future 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 stage(List paths) async { + final r = await ipc.request('git.stage', args: {'paths': paths}); + return r.ok; + } + + Future stageAll() async { + final r = await ipc.request('git.stage-all'); + return r.ok; + } + + Future unstage(List paths) async { + final r = await ipc.request('git.unstage', args: {'paths': paths}); + return r.ok; + } + + Future discard(List paths) async { + final r = await ipc.request('git.discard', args: {'paths': paths}); + return r.ok; + } + + Future 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 stash({String? message}) async { + final r = await ipc.request('git.stash', args: { + if (message != null) 'message': message, + }); + return r.ok; + } + + Future pull() async { + final r = await ipc.request('git.pull'); + if (!r.ok) { + _error = r.error?.message; + notifyListeners(); + } + return r.ok; + } + + Future 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 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> _castList(Object? raw) { + if (raw is! List) return const []; + return [for (final e in raw) (e as Map).cast()]; + } + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + super.dispose(); + } +} diff --git a/app/lib/builtin/git/src/git_panel_view.dart b/app/lib/builtin/git/src/git_panel_view.dart new file mode 100644 index 00000000..66da198c --- /dev/null +++ b/app/lib/builtin/git/src/git_panel_view.dart @@ -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 createState() => _GitPanelViewState(); +} + +class _GitPanelViewState extends State { + 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 = [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> 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 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, + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/src/clide_text.dart b/app/lib/widgets/src/clide_text.dart index c1a57075..cad1f7de 100644 --- a/app/lib/widgets/src/clide_text.dart +++ b/app/lib/widgets/src/clide_text.dart @@ -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, ), );