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
+7 -5
View File
@@ -27,7 +27,7 @@ import 'package:clide/kernel/src/secrets.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/loader.dart';
import 'package:clide/kernel/src/tool_check.dart';
import 'package:clide/kernel/src/toolchain.dart';
import 'package:clide/kernel/src/tray.dart';
import 'package:clide/kernel/src/window_controls.dart';
import 'package:flutter/widgets.dart';
@@ -61,7 +61,7 @@ class KernelServices {
required this.project,
required this.extensions,
required this.window,
required this.toolCheck,
required this.toolchain,
required this.scheduler,
});
@@ -89,7 +89,7 @@ class KernelServices {
final ProjectManager project;
final ExtensionManager extensions;
final WindowControls window;
final ToolCheck toolCheck;
final Toolchain toolchain;
final SchedulerService scheduler;
static Future<KernelServices> boot({
@@ -103,6 +103,7 @@ class KernelServices {
String? socketPath,
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
bool autoStartDaemonClient = true,
Toolchain? toolchain,
}) async {
final log = Logger();
final events = DaemonBus();
@@ -138,13 +139,14 @@ class KernelServices {
final net = NetworkStatus();
final focus = FocusTracker();
final window = WindowControls();
final toolCheck = ToolCheck();
final tc = toolchain ?? Toolchain();
final scheduler = SchedulerService(events);
scheduler.start();
final project = ProjectManager(
log: log,
events: events,
settings: settings,
toolchain: tc,
);
final ipc = daemonClientFactory != null
? daemonClientFactory(log, events)
@@ -207,7 +209,7 @@ class KernelServices {
project: project,
extensions: extensions,
window: window,
toolCheck: toolCheck,
toolchain: tc,
scheduler: scheduler,
);
}
+7 -3
View File
@@ -5,6 +5,7 @@ import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/toolchain.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
@@ -47,13 +48,16 @@ class ProjectManager extends ChangeNotifier {
required Logger log,
required DaemonBus events,
required SettingsStore settings,
required Toolchain toolchain,
}) : _log = log,
_events = events,
_settings = settings;
_settings = settings,
_toolchain = toolchain;
final Logger _log;
final DaemonBus _events;
final SettingsStore _settings;
final Toolchain _toolchain;
Directory? _current;
Directory? get current => _current;
@@ -116,7 +120,7 @@ class ProjectManager extends ChangeNotifier {
Future<String?> resolveWorkspace(String path) async {
try {
final r = await Process.run('git', ['rev-parse', '--show-toplevel'], workingDirectory: path, runInShell: false);
final r = await Process.run(_toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: _toolchain.gitEnv);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
@@ -128,7 +132,7 @@ class ProjectManager extends ChangeNotifier {
Future<String?> _currentBranch(String root) async {
try {
final r = await Process.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, runInShell: false);
final r = await Process.run(_toolchain.git, ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, environment: _toolchain.gitEnv);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
+22 -13
View File
@@ -2,6 +2,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../src/pty/env.dart';
class ToolCheck extends ChangeNotifier {
bool ptycOk = false;
bool pqlOk = false;
@@ -18,24 +20,31 @@ class ToolCheck extends ChangeNotifier {
if (!gitOk) 'git not found',
];
/// Workspace root, set by the app at boot. Falls back to cwd.
static String? workspaceRoot;
Future<void> check() async {
final cwd = Directory.current.path;
ptycOk = File('$cwd/native/linux-x64/ptyc').existsSync() ||
File('$cwd/ptyc/bin/ptyc').existsSync() ||
await _which('ptyc');
pqlOk = await _which('pql');
tmuxOk = await _which('tmux');
gitOk = await _which('git');
final root = workspaceRoot ?? Directory.current.path;
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
File('$root/native/macos-arm64/ptyc').existsSync() ||
File('$root/native/macos-x64/ptyc').existsSync() ||
File('$root/ptyc/bin/ptyc').existsSync() ||
_existsOnPath('ptyc');
pqlOk = _existsOnPath('pql');
tmuxOk = _existsOnPath('tmux');
gitOk = _existsOnPath('git');
checked = true;
notifyListeners();
}
static Future<bool> _which(String name) async {
try {
final r = await Process.run('which', [name]);
return r.exitCode == 0;
} catch (_) {
return false;
/// Check if [name] exists as an executable in any PATH directory.
/// Uses direct file-existence checks — works inside a macOS sandbox
/// without needing to exec `which`.
static bool _existsOnPath(String name) {
for (final dir in expandedPath.split(':')) {
if (dir.isEmpty) continue;
if (File('$dir/$name').existsSync()) return true;
}
return false;
}
}
+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;
}
}
+8
View File
@@ -65,4 +65,12 @@ class WindowControls extends ChangeNotifier {
return false;
}
}
/// Opens the native OS directory picker.
/// Returns the selected path, or null if the user cancelled.
/// Throws [MissingPluginException] if the platform has no handler,
/// so callers can fall back to a text-input dialog.
Future<String?> pickDirectory() {
return _channel.invokeMethod<String>('pickDirectory');
}
}