dissolve app/ into repo root (D-056)

Single Flutter package at the repo root. All code, tests, assets,
and platform directories moved from app/ to root. Package renamed
from clide_app to clide — all imports rewritten. Merged pubspec
combines core (ffi) and app (flutter, yaml, xterm) dependencies.
Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon
lifecycle. 317 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:37:20 +02:00
co-authored by Claude Opus 4.6
parent a526c5b9b7
commit 46329700d5
394 changed files with 978 additions and 1090 deletions
+33
View File
@@ -0,0 +1,33 @@
import 'package:clide/builtin/git/src/git_panel_view.dart';
import 'package:clide/builtin/git/src/git_status_item.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
class GitExtension extends ClideExtension {
@override
String get id => 'builtin.git';
@override
String get title => 'Git';
@override
String get version => '0.1.0';
@override
List<String> get dependsOn => const ['builtin.diff'];
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'git.panel',
slot: Slots.sidebar,
title: 'Git',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -80,
build: (_) => const GitPanelView(),
),
StatusItemContribution(
id: 'git.branch',
priority: 10,
build: (_) => const GitStatusItem(),
),
];
}
+169
View File
@@ -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/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();
}
}
+541
View File
@@ -0,0 +1,541 @@
/// 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/kernel/kernel.dart';
import 'package:clide/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();
}
void _confirmDiscard(BuildContext ctx, GitController c, String path) {
final kernel = ClideKernel.of(ctx);
kernel.dialog.show<String>(
(dialogCtx, dismiss) => _DiscardConfirmDialog(
path: path,
onConfirm: () {
unawaited(c.discard([path]));
dismiss();
},
onCancel: () => dismiss(),
),
);
}
@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: clideFontCaption,
maxLines: 3,
),
),
if (c.loading && c.isClean)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
if (!c.loading && c.isClean && c.error == null)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Nothing to commit, working tree clean.',
muted: true),
),
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) => _confirmDiscard(context, c, 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: clideFontCaption,
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: clideFontCaption,
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: clideFontCaption,
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: clideFontCaption,
color: _stateColor(state, tokens),
),
const SizedBox(width: 6),
Expanded(
child: ClideText(
name,
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: clideFontCaption,
color: tokens.sidebarForeground,
),
),
),
);
}
}
class _DiscardConfirmDialog extends StatelessWidget {
const _DiscardConfirmDialog({
required this.path,
required this.onConfirm,
required this.onCancel,
});
final String path;
final VoidCallback onConfirm;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final name = path.split('/').last;
return Container(
width: 360,
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
'Discard changes?',
color: tokens.globalForeground,
),
const SizedBox(height: 8),
ClideText(
'Unstaged changes to $name will be permanently lost.',
fontSize: clideFontCaption,
color: tokens.statusError,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: 'Cancel',
variant: ClideButtonVariant.subtle,
onPressed: onCancel,
),
const SizedBox(width: 8),
ClideButton(
label: 'Discard',
onPressed: onConfirm,
),
],
),
],
),
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class GitStatusItem extends StatefulWidget {
const GitStatusItem({super.key});
@override
State<GitStatusItem> createState() => _GitStatusItemState();
}
class _GitStatusItemState extends State<GitStatusItem> {
String? _branch;
int _ahead = 0;
int _behind = 0;
StreamSubscription<DaemonEvent>? _sub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_sub != null) return;
final kernel = ClideKernel.of(context);
_sub = kernel.events.on<DaemonEvent>().listen(_onEvent);
unawaited(_load(kernel.ipc));
}
Future<void> _load(DaemonClient ipc) async {
final r = await ipc.request('git.status');
if (!r.ok || !mounted) return;
setState(() {
_branch = r.data['branch'] as String?;
_ahead = (r.data['ahead'] as num?)?.toInt() ?? 0;
_behind = (r.data['behind'] as num?)?.toInt() ?? 0;
});
}
void _onEvent(DaemonEvent e) {
if (e.subsystem != 'git' || e.kind != 'git.changed') return;
final kernel = ClideKernel.of(context);
unawaited(_load(kernel.ipc));
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
void _openBranchPicker() {
final kernel = ClideKernel.of(context);
kernel.dialog.show<String>(
(ctx, dismiss) => _BranchPicker(
ipc: kernel.ipc,
currentBranch: _branch,
onDismiss: dismiss,
),
);
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
if (_branch == null) return const SizedBox.shrink();
final parts = <String>[_branch!];
if (_ahead > 0) parts.add('$_ahead');
if (_behind > 0) parts.add('$_behind');
return Semantics(
button: true,
label: 'switch branch — $_branch',
child: GestureDetector(
onTap: _openBranchPicker,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(
const GitBranchIcon(),
size: 12,
color: tokens.statusBarForeground,
),
const SizedBox(width: 4),
ClideText(
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
],
),
),
),
),
);
}
}
class _BranchPicker extends StatefulWidget {
const _BranchPicker({
required this.ipc,
required this.currentBranch,
required this.onDismiss,
});
final DaemonClient ipc;
final String? currentBranch;
final void Function([String?]) onDismiss;
@override
State<_BranchPicker> createState() => _BranchPickerState();
}
class _BranchPickerState extends State<_BranchPicker> {
List<Map<String, Object?>> _branches = const [];
bool _loading = true;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final r = await widget.ipc.request('git.branches');
if (!mounted) return;
setState(() {
_loading = false;
if (r.ok) {
_branches = [
for (final b in (r.data['branches'] as List? ?? const []))
(b as Map).cast<String, Object?>(),
];
}
});
}
Future<void> _checkout(String branch) async {
await widget.ipc.request('git.checkout', args: {'branch': branch});
widget.onDismiss();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 320,
constraints: const BoxConstraints(maxHeight: 320),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
'Switch branch',
fontSize: clideFontCaption,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
),
if (_loading)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _branches.length,
itemBuilder: (ctx, i) {
final b = _branches[i];
final name = b['name'] as String? ?? '';
final current = b['current'] as bool? ?? false;
return _BranchRow(
name: name,
current: current,
onTap: current ? null : () => unawaited(_checkout(name)),
);
},
),
),
],
),
);
}
}
class _BranchRow extends StatefulWidget {
const _BranchRow({
required this.name,
required this.current,
this.onTap,
});
final String name;
final bool current;
final VoidCallback? onTap;
@override
State<_BranchRow> createState() => _BranchRowState();
}
class _BranchRowState extends State<_BranchRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return MouseRegion(
cursor:
widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: _hover ? tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
if (widget.current)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(
const CheckIcon(),
size: 12,
color: tokens.statusSuccess,
),
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(
widget.name,
fontFamily: clideMonoFamily,
fontSize: clideFontMono,
color: widget.current
? tokens.globalForeground
: tokens.listItemForeground,
),
),
],
),
),
),
);
}
}