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
+24 -12
View File
@@ -7,6 +7,8 @@ library;
import 'dart:io';
import 'operations.dart' show gitBin;
enum GitFileState {
added,
modified,
@@ -110,11 +112,16 @@ class GitStatus {
/// Run `git status` and parse the result.
Future<GitStatus> gitStatus(Directory workDir) async {
final branchResult = await Process.run(
'git',
['status', '--porcelain=v2', '--branch', '-z'],
workingDirectory: workDir.path,
);
final ProcessResult branchResult;
try {
branchResult = await Process.run(
gitBin,
['status', '--porcelain=v2', '--branch', '-z'],
workingDirectory: workDir.path,
);
} on ProcessException {
return const GitStatus(branch: null, entries: []);
}
String? branch;
String? upstream;
@@ -138,11 +145,16 @@ Future<GitStatus> gitStatus(Directory workDir) async {
}
}
final result = await Process.run(
'git',
['status', '--porcelain=v1', '-z'],
workingDirectory: workDir.path,
);
final ProcessResult result;
try {
result = await Process.run(
gitBin,
['status', '--porcelain=v1', '-z'],
workingDirectory: workDir.path,
);
} on ProcessException {
return GitStatus(branch: branch, entries: const [], upstream: upstream, ahead: ahead, behind: behind);
}
if (result.exitCode != 0) {
return GitStatus(
@@ -154,7 +166,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
);
}
final entries = _parsePorcelainV1(result.stdout as String);
final entries = parsePorcelainV1(result.stdout as String);
return GitStatus(
branch: branch,
upstream: upstream,
@@ -164,7 +176,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
);
}
List<GitFileStatus> _parsePorcelainV1(String output) {
List<GitFileStatus> parsePorcelainV1(String output) {
if (output.isEmpty) return const [];
final entries = <GitFileStatus>[];
final parts = output.split('\x00');