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, ), );