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
+49 -44
View File
@@ -5,11 +5,8 @@
/// can refresh.
library;
import 'dart:io';
import '../git/diff.dart';
import '../git/operations.dart';
import '../git/status.dart';
import '../git/client.dart';
import '../git/operations.dart' show GitException;
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
@@ -17,22 +14,30 @@ import 'dispatcher.dart';
void registerGitCommands(
DaemonDispatcher d,
Directory workDir,
GitClient git,
DaemonEventSink events,
) {
d.register('git.status', (req) async {
final status = await gitStatus(workDir);
return IpcResponse.ok(id: req.id, data: status.toJson());
try {
final status = await git.status();
return IpcResponse.ok(id: req.id, data: status.toJson());
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.diff', (req) async {
final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']);
final diffs = await gitDiff(workDir, staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: {
'staged': staged,
'diffs': [for (final d in diffs) d.toJson()],
});
try {
final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']);
final diffs = await git.diff(staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: {
'staged': staged,
'diffs': [for (final d in diffs) d.toJson()],
});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.stage', (req) async {
@@ -49,7 +54,7 @@ void registerGitCommands(
);
}
try {
await gitStage(workDir, paths);
await git.stage(paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'staged': paths});
} on GitException catch (e) {
@@ -59,7 +64,7 @@ void registerGitCommands(
d.register('git.stage-all', (req) async {
try {
await gitStage(workDir, const []);
await git.stage(const []);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
} on GitException catch (e) {
@@ -70,7 +75,7 @@ void registerGitCommands(
d.register('git.unstage', (req) async {
final paths = _pathList(req.args['paths']);
try {
await gitUnstage(workDir, paths);
await git.unstage(paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
} on GitException catch (e) {
@@ -91,7 +96,7 @@ void registerGitCommands(
);
}
try {
await gitStageHunk(workDir, patch);
await git.stageHunk(patch);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'applied': true});
} on GitException catch (e) {
@@ -112,7 +117,7 @@ void registerGitCommands(
);
}
try {
await gitUnstageHunk(workDir, patch);
await git.unstageHunk(patch);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'applied': true});
} on GitException catch (e) {
@@ -133,7 +138,7 @@ void registerGitCommands(
);
}
try {
await gitDiscard(workDir, paths);
await git.discard(paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
} on GitException catch (e) {
@@ -154,7 +159,7 @@ void registerGitCommands(
);
}
try {
final hash = await gitCommit(workDir, message);
final hash = await git.commit(message);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'hash': hash});
} on GitException catch (e) {
@@ -166,7 +171,7 @@ void registerGitCommands(
final message = req.args['message'] as String?;
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
try {
await gitStash(workDir, message: message, includeUntracked: includeUntracked);
await git.stash(message: message, includeUntracked: includeUntracked);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
} on GitException catch (e) {
@@ -176,7 +181,7 @@ void registerGitCommands(
d.register('git.stash-pop', (req) async {
try {
await gitStashPop(workDir);
await git.stashPop();
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'popped': true});
} on GitException catch (e) {
@@ -185,16 +190,20 @@ void registerGitCommands(
});
d.register('git.log', (req) async {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await gitLog(workDir, count: count);
return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()],
});
try {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await git.log(count: count);
return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()],
});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.pull', (req) async {
try {
final output = await gitPull(workDir);
final output = await git.pull();
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'output': output});
} on GitException catch (e) {
@@ -207,12 +216,7 @@ void registerGitCommands(
final branch = req.args['branch'] as String?;
final setUpstream = req.args['setUpstream'] as bool? ?? false;
try {
final output = await gitPush(
workDir,
remote: remote,
branch: branch,
setUpstream: setUpstream,
);
final output = await git.push(remote: remote, branch: branch, setUpstream: setUpstream);
return IpcResponse.ok(id: req.id, data: {'output': output});
} on GitException catch (e) {
return _gitError(req.id, e);
@@ -220,13 +224,14 @@ void registerGitCommands(
});
d.register('git.branches', (req) async {
final branches = await gitBranches(workDir);
return IpcResponse.ok(id: req.id, data: {
'branches': [
for (final b in branches)
{'name': b.name, 'current': b.current},
],
});
try {
final b = await git.branches();
return IpcResponse.ok(id: req.id, data: {
'branches': [for (final e in b) {'name': e.name, 'current': e.current}],
});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.checkout', (req) async {
@@ -242,7 +247,7 @@ void registerGitCommands(
);
}
try {
await gitCheckout(workDir, branch);
await git.checkout(branch);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'branch': branch});
} on GitException catch (e) {
+12 -4
View File
@@ -15,10 +15,11 @@ import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/pane.dart';
import '../panes/registry.dart';
import '../../kernel/src/toolchain.dart';
import 'dispatcher.dart';
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {String defaultPtycPath = 'ptyc'}) {
d.register('pane.spawn', (req) => _spawn(req, registry, defaultPtycPath));
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
d.register('pane.list', (req) => _list(req, registry));
d.register('pane.close', (req) => _close(req, registry));
d.register('pane.write', (req) => _write(req, registry));
@@ -47,7 +48,14 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
),
);
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String defaultPtycPath) async {
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain toolchain) async {
// Wait for toolchain resolution if it hasn't completed yet.
if (!toolchain.resolved) {
await Future.any([
toolchain.waitForResolution(),
Future.delayed(const Duration(seconds: 5)),
]);
}
final args = req.args;
final rawArgv = args['argv'];
if (rawArgv is! List || rawArgv.isEmpty) {
@@ -84,7 +92,7 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String default
cols: (args['cols'] as num?)?.toInt() ?? 80,
rows: (args['rows'] as num?)?.toInt() ?? 24,
title: args['title'] as String?,
ptycPath: (args['ptyc_path'] as String?) ?? defaultPtycPath,
ptycPath: (args['ptyc_path'] as String?) ?? toolchain.ptyc,
);
return IpcResponse.ok(id: req.id, data: pane.toJson());
} catch (e) {