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
+33 -13
View File
@@ -7,6 +7,26 @@ library;
import 'dart:io';
import '../pty/env.dart';
/// Resolve git to an absolute path. On macOS the sandbox blocks bare
/// `git` calls; Homebrew's git is a symlink into Cellar so we need
/// the real resolved path.
String get gitBin {
_gitBin ??= _resolveGit();
return _gitBin!;
}
String? _gitBin;
String _resolveGit() {
for (final dir in expandedPath.split(':')) {
if (dir.isEmpty) continue;
final f = File('$dir/git');
if (f.existsSync()) return f.resolveSymbolicLinksSync();
}
return 'git';
}
class GitException implements Exception {
const GitException(this.message, {this.stderr = ''});
final String message;
@@ -52,7 +72,7 @@ Future<void> gitStage(Directory workDir, List<String> paths) async {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git add failed', stderr: r.stderr as String);
}
@@ -65,7 +85,7 @@ Future<void> gitUnstage(Directory workDir, List<String> paths) async {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git reset failed', stderr: r.stderr as String);
}
@@ -85,7 +105,7 @@ Future<void> gitUnstageHunk(Directory workDir, String patch) async {
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
if (paths.isEmpty) return;
final r = await Process.run(
'git',
gitBin,
['checkout', '--', ...paths],
workingDirectory: workDir.path,
);
@@ -102,13 +122,13 @@ Future<String> gitCommit(
}) async {
final args = ['commit', '-m', message];
if (amend) args.add('--amend');
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git commit failed', stderr: r.stderr as String);
}
// Return the new commit hash.
final hashResult = await Process.run(
'git',
gitBin,
['rev-parse', 'HEAD'],
workingDirectory: workDir.path,
);
@@ -126,7 +146,7 @@ Future<void> gitStash(
args.addAll(['-m', message]);
}
if (includeUntracked) args.add('--include-untracked');
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git stash failed', stderr: r.stderr as String);
}
@@ -135,7 +155,7 @@ Future<void> gitStash(
/// Pop the top stash entry.
Future<void> gitStashPop(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['stash', 'pop'],
workingDirectory: workDir.path,
);
@@ -150,7 +170,7 @@ Future<List<GitLogEntry>> gitLog(
int count = 20,
}) async {
final r = await Process.run(
'git',
gitBin,
[
'log',
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
@@ -166,7 +186,7 @@ Future<List<GitLogEntry>> gitLog(
/// Pull from remote.
Future<String> gitPull(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['pull'],
workingDirectory: workDir.path,
);
@@ -187,7 +207,7 @@ Future<String> gitPush(
if (setUpstream) args.add('-u');
if (remote != null) args.add(remote);
if (branch != null) args.add(branch);
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git push failed', stderr: r.stderr as String);
}
@@ -198,7 +218,7 @@ Future<String> gitPush(
Future<List<({String name, bool current})>> gitBranches(
Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['branch', '--format=%(refname:short)|%(HEAD)'],
workingDirectory: workDir.path,
);
@@ -218,7 +238,7 @@ Future<List<({String name, bool current})>> gitBranches(
/// Checkout a branch.
Future<void> gitCheckout(Directory workDir, String branch) async {
final r = await Process.run(
'git',
gitBin,
['checkout', branch],
workingDirectory: workDir.path,
);
@@ -230,7 +250,7 @@ Future<void> gitCheckout(Directory workDir, String branch) async {
/// Get the current branch name.
Future<String?> gitCurrentBranch(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['symbolic-ref', '--short', 'HEAD'],
workingDirectory: workDir.path,
);