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:
co-authored by
Claude Opus 4.6
parent
8edcc78bfe
commit
73e80a55a6
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user