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
+146
View File
@@ -0,0 +1,146 @@
/// Centralized binary resolution for external tools.
///
/// Resolution runs in a background isolate via [resolvePaths] to avoid
/// blocking the merged UI/platform thread on macOS. The result is
/// applied on the main thread via [applyResolved].
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../src/pty/env.dart' show expandedPath;
/// Serializable result of tool resolution (crosses isolate boundary).
class ResolvedPaths {
const ResolvedPaths({
this.git,
this.pql,
this.tmux,
this.ptyc,
this.shell,
this.gitEnv,
});
final String? git;
final String? pql;
final String? tmux;
final String? ptyc;
final String? shell;
final Map<String, String>? gitEnv;
}
class Toolchain extends ChangeNotifier {
String? _git;
String? _pql;
String? _tmux;
String? _ptyc;
String? _shell;
Map<String, String>? _gitEnv;
bool _resolved = false;
String get git => _git ?? 'git';
String get pql => _pql ?? 'pql';
String get tmux => _tmux ?? 'tmux';
String get ptyc => _ptyc ?? 'ptyc';
String get shell => _shell ?? '/bin/bash';
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
Map<String, String>? get gitEnv => _gitEnv;
bool get resolved => _resolved;
bool get allOk => _resolved && missing.isEmpty;
List<String> get missing => [
if (_git == null) 'git',
if (_pql == null) 'pql',
if (_tmux == null) 'tmux',
if (_ptyc == null) 'ptyc',
];
/// Returns a Future that completes when resolution finishes.
Future<void> waitForResolution() {
if (_resolved) return Future.value();
final c = Completer<void>();
void listener() {
if (_resolved) {
removeListener(listener);
if (!c.isCompleted) c.complete();
}
}
addListener(listener);
return c.future;
}
/// Apply paths resolved in a background isolate.
void applyResolved(ResolvedPaths p) {
_git = p.git;
_pql = p.pql;
_tmux = p.tmux;
_ptyc = p.ptyc;
_shell = p.shell;
_gitEnv = p.gitEnv;
_resolved = true;
notifyListeners();
}
/// Pure function — runs in a background isolate. All file I/O happens
/// here, off the main thread.
static ResolvedPaths resolvePaths({required String workspaceRoot}) {
final dugite = '$workspaceRoot/native/dugite/bin';
String? git;
Map<String, String>? gitEnv;
final dugiteGit = _firstExisting(['$dugite/git']);
if (dugiteGit != null) {
git = dugiteGit;
final dugiteRoot = File(dugiteGit).parent.parent.path;
gitEnv = {
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
};
} else {
git = _findOnPath('git');
}
final pql = _findOnPath('pql');
final tmux = _findOnPath('tmux');
final shell = _findOnPath(
Platform.environment['SHELL']?.split('/').last ?? 'bash');
final ptyc = _firstExisting([
'$workspaceRoot/ptyc/bin/ptyc',
'$workspaceRoot/native/linux-x64/ptyc',
'$workspaceRoot/native/macos-arm64/ptyc',
'$workspaceRoot/native/macos-x64/ptyc',
if (Platform.environment['HOME'] case final home?)
'$home/.local/bin/ptyc',
]) ?? _findOnPath('ptyc');
return ResolvedPaths(
git: git,
pql: pql,
tmux: tmux,
ptyc: ptyc,
shell: shell,
gitEnv: gitEnv,
);
}
static String? _findOnPath(String name) {
for (final dir in expandedPath.split(':')) {
if (dir.isEmpty) continue;
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
return null;
}
static String? _firstExisting(List<String> candidates) {
for (final c in candidates) {
if (File(c).existsSync()) return c;
}
return null;
}
}