add Toolchain, GitClient, native directory picker

Toolchain centralizes binary resolution — replaces five ad-hoc
mechanisms (expandedPath, _resolveGit, _resolve, _resolvePtyc,
_existsOnPath). Resolves via Future.delayed after runApp to avoid
blocking the merged UI/platform thread on macOS.

GitClient wraps all git operations with a typed API. Every subprocess
call goes through _run() using toolchain.git + toolchain.gitEnv.
Replaces free functions in operations.dart.

Native directory picker: NSOpenPanel on macOS (method channel in
AppDelegate), GtkFileChooserDialog on Linux. Falls back to text-input
dialog on web or MissingPluginException. Shows "No git repo found"
dialog when the selected directory is not a git repository.

PqlClient and pane commands updated to use Toolchain. ToolCheck
replaced by Toolchain.missing/allOk. All IPC handlers now catch
GitException to prevent unhandled exceptions on the merged thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-04-25 13:18:39 +02:00
co-authored by Claude Opus 4.6
parent 8edcc78bfe
commit 73e80a55a6
22 changed files with 734 additions and 147 deletions
+6 -6
View File
@@ -10,19 +10,19 @@ class ToolStatusItem extends StatelessWidget {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.toolCheck,
listenable: kernel.toolchain,
builder: (ctx, _) {
final tc = kernel.toolCheck;
if (!tc.checked) return const SizedBox.shrink();
final tc = kernel.toolchain;
if (!tc.resolved) return const SizedBox.shrink();
if (tc.allOk) {
return _chip('ok', tokens.statusSuccess, tokens);
return _chip('application ok', tokens.statusSuccess, tokens);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < tc.errors.length; i++) ...[
for (var i = 0; i < tc.missing.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
_chip(tc.errors[i], tokens.statusWarning, tokens),
_chip('${tc.missing[i]} not found', tokens.statusWarning, tokens),
],
],
);
+66 -5
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter/widgets.dart';
class WelcomeView extends StatelessWidget {
@@ -105,7 +106,25 @@ class _StartColumn extends StatelessWidget {
);
}
void _openFolder(BuildContext context) {
void _openFolder(BuildContext context) async {
try {
final picked = await kernel.window.pickDirectory();
if (picked != null) {
final ok = await kernel.project.open(picked);
if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
path: picked,
onDismiss: () => dismiss(),
));
}
}
return;
} on MissingPluginException {
// Platform has no native picker — fall through to text dialog.
}
kernel.dialog.show<String>((ctx, dismiss) {
return _OpenProjectDialog(
onOpen: (path) async {
@@ -242,20 +261,20 @@ class _StatusLine extends StatelessWidget {
Widget build(BuildContext context) {
final themeName = kernel.theme.currentName;
return ListenableBuilder(
listenable: kernel.toolCheck,
listenable: kernel.toolchain,
builder: (ctx, _) {
final tc = kernel.toolCheck;
final tc = kernel.toolchain;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(' · ', muted: true, fontSize: 12),
if (!tc.checked)
if (!tc.resolved)
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
else if (tc.allOk)
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
else
ClideText(tc.errors.join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
ClideText(tc.missing.map((t) => '$t not found').join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
ClideText(' · ', muted: true, fontSize: 12),
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
],
@@ -379,3 +398,45 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
);
}
}
class _NotARepoDialog extends StatelessWidget {
const _NotARepoDialog({required this.path, required this.onDismiss});
final String path;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8),
const ClideText(
'A clide project root requires a git repository.',
muted: true,
fontSize: 13,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
),
],
),
);
}
}