From 73e80a55a6279d6b66ebf6db9fd87262f7a3a623 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Apr 2026 13:16:21 +0200 Subject: [PATCH] add Toolchain, GitClient, native directory picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 12 + bin/clide.dart | 14 +- lib/builtin/ipc_status/src/status_item.dart | 12 +- lib/builtin/welcome/src/welcome_view.dart | 71 +++++- lib/clide.dart | 1 + lib/kernel/kernel.dart | 2 +- lib/kernel/src/facade.dart | 12 +- lib/kernel/src/project.dart | 10 +- lib/kernel/src/tool_check.dart | 35 ++- lib/kernel/src/toolchain.dart | 146 +++++++++++ lib/kernel/src/window_controls.dart | 8 + lib/main.dart | 48 ++-- lib/src/daemon/git_commands.dart | 93 +++---- lib/src/daemon/pane_commands.dart | 16 +- lib/src/git/client.dart | 260 ++++++++++++++++++++ lib/src/git/diff.dart | 4 +- lib/src/git/operations.dart | 46 +++- lib/src/git/status.dart | 36 ++- lib/src/pql/client.dart | 25 +- test/daemon/git_commands_test.dart | 7 +- test/daemon/pane_commands_test.dart | 18 +- test/daemon/pql_commands_test.dart | 5 +- 22 files changed, 734 insertions(+), 147 deletions(-) create mode 100644 lib/kernel/src/toolchain.dart create mode 100644 lib/src/git/client.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2fad2b..36bf9a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Toolchain — centralized binary resolution replacing five ad-hoc + mechanisms. Resolves git, pql, tmux, ptyc, shell once at boot via + background isolate. Status bar reads `toolchain.missing` directly. + +- GitClient — typed Dart API wrapping all git operations. Every + subprocess call goes through `_run()` with toolchain-resolved path + and environment. Replaces scattered `Process.run('git', ...)` calls. + +- Native directory picker — macOS NSOpenPanel via method channel in + AppDelegate, GTK file chooser on Linux. Falls back to text-input + dialog on web. Shows "No git repo found" dialog on invalid selection. + - macOS desktop target — OS-detecting Makefile (`make run` works on macOS/Linux/Windows), 1280x720 default window, squared app icons, sandbox entitlements with SBPL exceptions, `_DARWIN_C_SOURCE` for diff --git a/bin/clide.dart b/bin/clide.dart index e4a1426f..89c24b3a 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -13,6 +13,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/clide.dart'; +import 'package:clide/kernel/src/toolchain.dart'; +import 'package:clide/src/git/client.dart'; // Daemon-only deep imports — these pull in dart:ffi (PTY) and // daemon-subsystem wiring that the Flutter app doesn't need and // can't compile for web. See lib/clide.dart for the barrel split. @@ -142,20 +144,22 @@ Future _runDaemon(List args) async { socketPath: socketPath, dispatch: dispatcher.dispatch, ); - final ptycPath = _resolvePtycPath(); + final toolchain = Toolchain(); + toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path)); + final events = _ServerEventSink(server); final registry = PaneRegistry(events: events); - registerPaneCommands(dispatcher, registry, defaultPtycPath: ptycPath); + registerPaneCommands(dispatcher, registry, toolchain: toolchain); final files = FilesService.atCwd(events: events); registerFilesCommands(dispatcher, files); final editor = EditorRegistry(events: events, workspaceRoot: files.root); registerEditorCommands(dispatcher, editor); + final gitClient = GitClient(toolchain: toolchain, workDir: files.root); + registerGitCommands(dispatcher, gitClient, events); - registerGitCommands(dispatcher, files.root, events); - - final pql = PqlClient(workDir: files.root); + final pql = PqlClient(workDir: files.root, toolchain: toolchain); registerPqlCommands(dispatcher, pql); final stopping = Completer(); diff --git a/lib/builtin/ipc_status/src/status_item.dart b/lib/builtin/ipc_status/src/status_item.dart index ec8ea72c..633bf3ff 100644 --- a/lib/builtin/ipc_status/src/status_item.dart +++ b/lib/builtin/ipc_status/src/status_item.dart @@ -10,19 +10,19 @@ class ToolStatusItem extends StatelessWidget { final kernel = ClideKernel.of(context); final tokens = ClideTheme.of(context).surface; return ListenableBuilder( - listenable: kernel.toolCheck, + listenable: kernel.toolchain, builder: (ctx, _) { - final tc = kernel.toolCheck; - if (!tc.checked) return const SizedBox.shrink(); + final tc = kernel.toolchain; + if (!tc.resolved) return const SizedBox.shrink(); if (tc.allOk) { - return _chip('ok', tokens.statusSuccess, tokens); + return _chip('application ok', tokens.statusSuccess, tokens); } return Row( mainAxisSize: MainAxisSize.min, children: [ - for (var i = 0; i < tc.errors.length; i++) ...[ + for (var i = 0; i < tc.missing.length; i++) ...[ if (i > 0) const SizedBox(width: 10), - _chip(tc.errors[i], tokens.statusWarning, tokens), + _chip('${tc.missing[i]} not found', tokens.statusWarning, tokens), ], ], ); diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index c458b2de..c08120f3 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:flutter/widgets.dart'; class WelcomeView extends StatelessWidget { @@ -105,7 +106,25 @@ class _StartColumn extends StatelessWidget { ); } - void _openFolder(BuildContext context) { + void _openFolder(BuildContext context) async { + try { + final picked = await kernel.window.pickDirectory(); + if (picked != null) { + final ok = await kernel.project.open(picked); + if (ok) { + kernel.panels.activateTab(Slots.workspace, 'claude.primary'); + } else { + kernel.dialog.show((ctx, dismiss) => _NotARepoDialog( + path: picked, + onDismiss: () => dismiss(), + )); + } + } + return; + } on MissingPluginException { + // Platform has no native picker — fall through to text dialog. + } + kernel.dialog.show((ctx, dismiss) { return _OpenProjectDialog( onOpen: (path) async { @@ -242,20 +261,20 @@ class _StatusLine extends StatelessWidget { Widget build(BuildContext context) { final themeName = kernel.theme.currentName; return ListenableBuilder( - listenable: kernel.toolCheck, + listenable: kernel.toolchain, builder: (ctx, _) { - final tc = kernel.toolCheck; + final tc = kernel.toolchain; return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily), ClideText(' · ', muted: true, fontSize: 12), - if (!tc.checked) + if (!tc.resolved) ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily) else if (tc.allOk) ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess) else - ClideText(tc.errors.join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning), + ClideText(tc.missing.map((t) => '$t not found').join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning), ClideText(' · ', muted: true, fontSize: 12), _ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName), ], @@ -379,3 +398,45 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> { ); } } + +class _NotARepoDialog extends StatelessWidget { + const _NotARepoDialog({required this.path, required this.onDismiss}); + final String path; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Container( + width: 420, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: tokens.modalSurfaceBackground, + border: Border.all(color: tokens.modalSurfaceBorder), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600), + const SizedBox(height: 8), + ClideText(path, muted: true, fontSize: 13), + const SizedBox(height: 8), + const ClideText( + 'A clide project root requires a git repository.', + muted: true, + fontSize: 13, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ClideButton(label: 'OK', onPressed: () => onDismiss()), + ], + ), + ], + ), + ); + } +} diff --git a/lib/clide.dart b/lib/clide.dart index b374b78c..0fdaf0bb 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -21,6 +21,7 @@ export 'src/editor/buffer.dart'; export 'src/files/ignore.dart'; export 'src/files/listing.dart' show FileEntry, listDir; export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind; +export 'src/git/client.dart' show GitClient; export 'src/git/operations.dart' show GitLogEntry, GitException; export 'src/git/status.dart' show GitStatus, GitFileStatus, GitFileState, GitConflictType; diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 9c8e3730..cbf2aa61 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -48,5 +48,5 @@ export 'src/theme/palette.dart'; export 'src/theme/resolver.dart'; export 'src/theme/semantic.dart'; export 'src/theme/tokens.dart'; -export 'src/tool_check.dart'; +export 'src/toolchain.dart'; export 'src/window_controls.dart'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 00fc0c20..393abd84 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -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 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, ); } diff --git a/lib/kernel/src/project.dart b/lib/kernel/src/project.dart index b6bb2323..8fb95132 100644 --- a/lib/kernel/src/project.dart +++ b/lib/kernel/src/project.dart @@ -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 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 _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; diff --git a/lib/kernel/src/tool_check.dart b/lib/kernel/src/tool_check.dart index 90166f91..e2cdb059 100644 --- a/lib/kernel/src/tool_check.dart +++ b/lib/kernel/src/tool_check.dart @@ -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 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 _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; } } diff --git a/lib/kernel/src/toolchain.dart b/lib/kernel/src/toolchain.dart new file mode 100644 index 00000000..b1b5d6b3 --- /dev/null +++ b/lib/kernel/src/toolchain.dart @@ -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? gitEnv; +} + +class Toolchain extends ChangeNotifier { + String? _git; + String? _pql; + String? _tmux; + String? _ptyc; + String? _shell; + Map? _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? get gitEnv => _gitEnv; + + bool get resolved => _resolved; + bool get allOk => _resolved && missing.isEmpty; + + List 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 waitForResolution() { + if (_resolved) return Future.value(); + final c = Completer(); + 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? 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 candidates) { + for (final c in candidates) { + if (File(c).existsSync()) return c; + } + return null; + } +} diff --git a/lib/kernel/src/window_controls.dart b/lib/kernel/src/window_controls.dart index c08607d5..c9a3e2fc 100644 --- a/lib/kernel/src/window_controls.dart +++ b/lib/kernel/src/window_controls.dart @@ -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 pickDirectory() { + return _channel.invokeMethod('pickDirectory'); + } } diff --git a/lib/main.dart b/lib/main.dart index c77e5db2..55baba6e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:clide/app.dart'; +import 'package:clide/test_app.dart'; import 'package:clide/builtin/canvas/canvas.dart'; import 'package:clide/builtin/claude/claude.dart'; import 'package:clide/builtin/claude_control/claude_control.dart'; @@ -28,6 +29,8 @@ import 'dart:io' show Directory, File, Platform; import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/src/ipc/in_process.dart'; +import 'package:clide/kernel/src/toolchain.dart'; +import 'package:clide/src/git/client.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/editor_commands.dart'; @@ -47,6 +50,13 @@ import 'package:flutter/widgets.dart'; Future main() async { final binding = WidgetsFlutterBinding.ensureInitialized(); + // Test mode: skip the full app, run the test harness instead. + const testMode = bool.fromEnvironment('CLIDE_TESTMODE'); + if (testMode) { + runApp(const ClideTestApp()); + return; + } + binding.ensureSemantics(); TreeSitterLib.init(); @@ -54,25 +64,28 @@ Future main() async { final appDir = await _resolveAppDir(); final themes = await _loadBundledThemes(); + final toolchain = Toolchain(); + final services = await KernelServices.boot( appDir: appDir, bundledThemes: themes, i18nLoader: AssetCatalogLoader(bundle: rootBundle), preloadNamespaces: _tier0Namespaces, autoStartDaemonClient: false, + toolchain: toolchain, daemonClientFactory: kIsWeb ? null : (log, events) { final dispatcher = DaemonDispatcher(); final eventSink = _BusEventSink(events); final filesService = FilesService.atCwd(events: eventSink); final workRoot = filesService.root; - final ptycPath = _resolvePtyc(workRoot.path); final paneRegistry = PaneRegistry(events: eventSink); - registerPaneCommands(dispatcher, paneRegistry, defaultPtycPath: ptycPath); + registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain); registerFilesCommands(dispatcher, filesService); final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot); registerEditorCommands(dispatcher, editorRegistry); - registerGitCommands(dispatcher, workRoot, eventSink); - final pql = PqlClient(workDir: workRoot); + final gitClient = GitClient(toolchain: toolchain, workDir: workRoot); + registerGitCommands(dispatcher, gitClient, eventSink); + final pql = PqlClient(workDir: workRoot, toolchain: toolchain); registerPqlCommands(dispatcher, pql); return InProcessClient(log: log, events: events, dispatcher: dispatcher); }, @@ -115,7 +128,6 @@ Future main() async { await services.extensions.activateAll(); if (!kIsWeb) { - unawaited(services.toolCheck.check()); await services.project.loadRecents(); var opened = await services.project.openLast(); if (!opened) { @@ -127,6 +139,20 @@ Future main() async { } runApp(ClideApp(services: services)); + + // Resolve toolchain after the first frame — resolveSymbolicLinksSync() + // blocks the merged UI/platform thread on macOS and prevents rendering + // if called before runApp. + if (!kIsWeb) { + // Defer toolchain resolution. On macOS the merged UI/platform thread + // cannot tolerate synchronous file I/O or isolate spawning during the + // first few frames. A short delay lets Flutter settle first. + Future.delayed(const Duration(seconds: 1), () { + const workspace = String.fromEnvironment('CLIDE_WORKSPACE'); + final root = workspace.isNotEmpty ? workspace : Directory.current.path; + toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: root)); + }); + } } /// Resolve the app-settings directory. @@ -161,18 +187,6 @@ Future> _loadBundledThemes() async { /// Every Tier-0 extension that ships an i18n catalog. Extensions /// registered but not active (the 17 stubs) don't preload — their /// catalogs load lazily on activate in later tiers. -String _resolvePtyc(String repoRoot) { - final candidates = [ - '$repoRoot/native/linux-x64/ptyc', - '$repoRoot/ptyc/bin/ptyc', - '${Platform.environment['HOME']}/.local/bin/ptyc', - 'ptyc', - ]; - for (final c in candidates) { - if (File(c).existsSync()) return c; - } - return 'ptyc'; -} class _BusEventSink implements DaemonEventSink { _BusEventSink(this._bus); diff --git a/lib/src/daemon/git_commands.dart b/lib/src/daemon/git_commands.dart index 0c4fca61..56bd2965 100644 --- a/lib/src/daemon/git_commands.dart +++ b/lib/src/daemon/git_commands.dart @@ -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) { diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart index 3d157852..b0bc48b8 100644 --- a/lib/src/daemon/pane_commands.dart +++ b/lib/src/daemon/pane_commands.dart @@ -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 _spawn(IpcRequest req, PaneRegistry registry, String defaultPtycPath) async { +Future _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 _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) { diff --git a/lib/src/git/client.dart b/lib/src/git/client.dart new file mode 100644 index 00000000..a3dd1f54 --- /dev/null +++ b/lib/src/git/client.dart @@ -0,0 +1,260 @@ +/// Typed git client backed by [Toolchain]. +/// +/// Every subprocess call goes through [_run] which uses the resolved +/// absolute binary path from the toolchain. Parsing is delegated to +/// the existing pure-function parsers in status.dart and diff.dart. +library; + +import 'dart:io'; + +import '../../kernel/src/toolchain.dart'; +import 'diff.dart' show GitDiff, parseDiffOutput; +import 'operations.dart' show GitException, GitLogEntry; +import 'status.dart'; + +class GitClient { + GitClient({required this.toolchain, required this.workDir}); + + final Toolchain toolchain; + final Directory workDir; + + // -- queries -------------------------------------------------------------- + + Future status() async { + ProcessResult branchResult; + try { + branchResult = await _run(['status', '--porcelain=v2', '--branch', '-z']); + } on GitException { + return const GitStatus(branch: null, entries: []); + } + + String? branch; + String? upstream; + int ahead = 0; + int behind = 0; + + if (branchResult.exitCode == 0) { + final output = branchResult.stdout as String; + for (final line in output.split('\x00')) { + if (line.startsWith('# branch.head ')) { + branch = line.substring('# branch.head '.length); + } else if (line.startsWith('# branch.upstream ')) { + upstream = line.substring('# branch.upstream '.length); + } else if (line.startsWith('# branch.ab ')) { + final parts = line.substring('# branch.ab '.length).split(' '); + if (parts.length >= 2) { + ahead = int.tryParse(parts[0].replaceFirst('+', '')) ?? 0; + behind = int.tryParse(parts[1].replaceFirst('-', '')) ?? 0; + } + } + } + } + + ProcessResult result; + try { + result = await _run(['status', '--porcelain=v1', '-z']); + } on GitException { + return GitStatus(branch: branch, entries: const [], upstream: upstream, ahead: ahead, behind: behind); + } + + if (result.exitCode != 0) { + return GitStatus(branch: branch, upstream: upstream, ahead: ahead, behind: behind, entries: const []); + } + + return GitStatus( + branch: branch, + upstream: upstream, + ahead: ahead, + behind: behind, + entries: parsePorcelainV1(result.stdout as String), + ); + } + + Future> diff({bool staged = false, List paths = const []}) async { + final args = ['diff', '--unified=3']; + if (staged) args.add('--cached'); + if (paths.isNotEmpty) { + args.add('--'); + args.addAll(paths); + } + final r = await _run(args); + if (r.exitCode != 0) return const []; + return parseDiffOutput(r.stdout as String); + } + + Future> log({int count = 20}) async { + final r = await _run([ + 'log', + '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01', + '-n', + '$count', + ]); + if (r.exitCode != 0) return const []; + return parseLog(r.stdout as String); + } + + Future currentBranch() async { + final r = await _run(['symbolic-ref', '--short', 'HEAD']); + if (r.exitCode != 0) return null; + return (r.stdout as String).trim(); + } + + Future> branches() async { + final r = await _run(['branch', '--format=%(refname:short)|%(HEAD)']); + if (r.exitCode != 0) return const []; + final out = <({String name, bool current})>[]; + for (final line in (r.stdout as String).split('\n')) { + if (line.trim().isEmpty) continue; + final sep = line.lastIndexOf('|'); + if (sep < 0) continue; + out.add((name: line.substring(0, sep), current: line.substring(sep + 1).trim() == '*')); + } + return out; + } + + /// Resolve a path to its git repo root. Returns null if not a git repo. + Future repoRoot(String path) async { + try { + final r = await Process.run( + toolchain.git, + ['rev-parse', '--show-toplevel'], + workingDirectory: path, + ); + if (r.exitCode != 0) return null; + final out = (r.stdout as String).trim(); + return out.isEmpty ? null : out; + } catch (_) { + return null; + } + } + + // -- mutations ------------------------------------------------------------ + + Future stage(List paths) async { + final args = ['add']; + if (paths.isEmpty) { + args.add('-A'); + } else { + args.add('--'); + args.addAll(paths); + } + final r = await _run(args); + if (r.exitCode != 0) throw GitException('git add failed', stderr: r.stderr as String); + } + + Future unstage(List paths) async { + final args = ['reset', 'HEAD']; + if (paths.isNotEmpty) { + args.add('--'); + args.addAll(paths); + } + final r = await _run(args); + if (r.exitCode != 0) throw GitException('git reset failed', stderr: r.stderr as String); + } + + Future stageHunk(String patch) => _applyPatch(patch, cached: true); + + Future unstageHunk(String patch) => _applyPatch(patch, cached: true, reverse: true); + + Future discard(List paths) async { + if (paths.isEmpty) return; + final r = await _run(['checkout', '--', ...paths]); + if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String); + } + + Future commit(String message, {bool amend = false}) async { + final args = ['commit', '-m', message]; + if (amend) args.add('--amend'); + final r = await _run(args); + if (r.exitCode != 0) throw GitException('git commit failed', stderr: r.stderr as String); + final hash = await _run(['rev-parse', 'HEAD']); + return (hash.stdout as String).trim(); + } + + Future stash({String? message, bool includeUntracked = false}) async { + final args = ['stash', 'push']; + if (message != null) args.addAll(['-m', message]); + if (includeUntracked) args.add('--include-untracked'); + final r = await _run(args); + if (r.exitCode != 0) throw GitException('git stash failed', stderr: r.stderr as String); + } + + Future stashPop() async { + final r = await _run(['stash', 'pop']); + if (r.exitCode != 0) throw GitException('git stash pop failed', stderr: r.stderr as String); + } + + Future pull() async { + final r = await _run(['pull']); + if (r.exitCode != 0) throw GitException('git pull failed', stderr: r.stderr as String); + return (r.stdout as String).trim(); + } + + Future push({String? remote, String? branch, bool setUpstream = false}) async { + final args = ['push']; + if (setUpstream) args.add('-u'); + if (remote != null) args.add(remote); + if (branch != null) args.add(branch); + final r = await _run(args); + if (r.exitCode != 0) throw GitException('git push failed', stderr: r.stderr as String); + return ((r.stdout as String) + (r.stderr as String)).trim(); + } + + Future checkout(String branch) async { + final r = await _run(['checkout', branch]); + if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String); + } + + // -- internal ------------------------------------------------------------- + + Future _run(List args) async { + try { + return await Process.run(toolchain.git, args, + workingDirectory: workDir.path, + environment: toolchain.gitEnv); + } on ProcessException catch (e) { + throw GitException('git ${args.first}: ${e.message}', stderr: e.toString()); + } + } + + Future _applyPatch(String patch, {bool cached = false, bool reverse = false}) async { + final args = ['apply']; + if (cached) args.add('--cached'); + if (reverse) args.add('--reverse'); + args.addAll(['--unidiff-zero', '-']); + + final proc = await Process.start(toolchain.git, args, + workingDirectory: workDir.path, + environment: toolchain.gitEnv); + proc.stdin.write(patch); + await proc.stdin.close(); + final exitCode = await proc.exitCode; + if (exitCode != 0) { + final stderr = await proc.stderr.transform(const SystemEncoding().decoder).join(); + throw GitException('git apply failed', stderr: stderr); + } + } +} + +// -- parsers (pure, no I/O) ------------------------------------------------- + +List parseLog(String output) { + if (output.trim().isEmpty) return const []; + final records = output.split('\x01'); + final entries = []; + for (final record in records) { + final trimmed = record.trim(); + if (trimmed.isEmpty) continue; + final fields = trimmed.split('\x00'); + if (fields.length < 5) continue; + entries.add(GitLogEntry( + hash: fields[0], + shortHash: fields[1], + subject: fields[2], + author: fields[3], + date: fields[4], + body: fields.length > 5 ? fields[5].trim() : '', + )); + } + return entries; +} diff --git a/lib/src/git/diff.dart b/lib/src/git/diff.dart index 9a3c37e7..5b6b39a4 100644 --- a/lib/src/git/diff.dart +++ b/lib/src/git/diff.dart @@ -7,6 +7,8 @@ library; import 'dart:io'; +import 'operations.dart' show gitBin; + enum DiffLineKind { context, addition, removal, header } class DiffLine { @@ -129,7 +131,7 @@ Future> gitDiff( args.addAll(paths); } final result = await Process.run( - 'git', + gitBin, args, workingDirectory: workDir.path, ); diff --git a/lib/src/git/operations.dart b/lib/src/git/operations.dart index e36c4d4c..b6352eef 100644 --- a/lib/src/git/operations.dart +++ b/lib/src/git/operations.dart @@ -7,6 +7,26 @@ library; import 'dart:io'; +import '../pty/env.dart'; + +/// Resolve git to an absolute path. On macOS the sandbox blocks bare +/// `git` calls; Homebrew's git is a symlink into Cellar so we need +/// the real resolved path. +String get gitBin { + _gitBin ??= _resolveGit(); + return _gitBin!; +} +String? _gitBin; + +String _resolveGit() { + for (final dir in expandedPath.split(':')) { + if (dir.isEmpty) continue; + final f = File('$dir/git'); + if (f.existsSync()) return f.resolveSymbolicLinksSync(); + } + return 'git'; +} + class GitException implements Exception { const GitException(this.message, {this.stderr = ''}); final String message; @@ -52,7 +72,7 @@ Future gitStage(Directory workDir, List paths) async { args.add('--'); args.addAll(paths); } - final r = await Process.run('git', args, workingDirectory: workDir.path); + final r = await Process.run(gitBin, args, workingDirectory: workDir.path); if (r.exitCode != 0) { throw GitException('git add failed', stderr: r.stderr as String); } @@ -65,7 +85,7 @@ Future gitUnstage(Directory workDir, List paths) async { args.add('--'); args.addAll(paths); } - final r = await Process.run('git', args, workingDirectory: workDir.path); + final r = await Process.run(gitBin, args, workingDirectory: workDir.path); if (r.exitCode != 0) { throw GitException('git reset failed', stderr: r.stderr as String); } @@ -85,7 +105,7 @@ Future gitUnstageHunk(Directory workDir, String patch) async { Future gitDiscard(Directory workDir, List paths) async { if (paths.isEmpty) return; final r = await Process.run( - 'git', + gitBin, ['checkout', '--', ...paths], workingDirectory: workDir.path, ); @@ -102,13 +122,13 @@ Future gitCommit( }) async { final args = ['commit', '-m', message]; if (amend) args.add('--amend'); - final r = await Process.run('git', args, workingDirectory: workDir.path); + final r = await Process.run(gitBin, args, workingDirectory: workDir.path); if (r.exitCode != 0) { throw GitException('git commit failed', stderr: r.stderr as String); } // Return the new commit hash. final hashResult = await Process.run( - 'git', + gitBin, ['rev-parse', 'HEAD'], workingDirectory: workDir.path, ); @@ -126,7 +146,7 @@ Future gitStash( args.addAll(['-m', message]); } if (includeUntracked) args.add('--include-untracked'); - final r = await Process.run('git', args, workingDirectory: workDir.path); + final r = await Process.run(gitBin, args, workingDirectory: workDir.path); if (r.exitCode != 0) { throw GitException('git stash failed', stderr: r.stderr as String); } @@ -135,7 +155,7 @@ Future gitStash( /// Pop the top stash entry. Future gitStashPop(Directory workDir) async { final r = await Process.run( - 'git', + gitBin, ['stash', 'pop'], workingDirectory: workDir.path, ); @@ -150,7 +170,7 @@ Future> gitLog( int count = 20, }) async { final r = await Process.run( - 'git', + gitBin, [ 'log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01', @@ -166,7 +186,7 @@ Future> gitLog( /// Pull from remote. Future gitPull(Directory workDir) async { final r = await Process.run( - 'git', + gitBin, ['pull'], workingDirectory: workDir.path, ); @@ -187,7 +207,7 @@ Future gitPush( if (setUpstream) args.add('-u'); if (remote != null) args.add(remote); if (branch != null) args.add(branch); - final r = await Process.run('git', args, workingDirectory: workDir.path); + final r = await Process.run(gitBin, args, workingDirectory: workDir.path); if (r.exitCode != 0) { throw GitException('git push failed', stderr: r.stderr as String); } @@ -198,7 +218,7 @@ Future gitPush( Future> gitBranches( Directory workDir) async { final r = await Process.run( - 'git', + gitBin, ['branch', '--format=%(refname:short)|%(HEAD)'], workingDirectory: workDir.path, ); @@ -218,7 +238,7 @@ Future> gitBranches( /// Checkout a branch. Future gitCheckout(Directory workDir, String branch) async { final r = await Process.run( - 'git', + gitBin, ['checkout', branch], workingDirectory: workDir.path, ); @@ -230,7 +250,7 @@ Future gitCheckout(Directory workDir, String branch) async { /// Get the current branch name. Future gitCurrentBranch(Directory workDir) async { final r = await Process.run( - 'git', + gitBin, ['symbolic-ref', '--short', 'HEAD'], workingDirectory: workDir.path, ); diff --git a/lib/src/git/status.dart b/lib/src/git/status.dart index 50f928ff..e79db949 100644 --- a/lib/src/git/status.dart +++ b/lib/src/git/status.dart @@ -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(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(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(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(Directory workDir) async { ); } -List _parsePorcelainV1(String output) { +List parsePorcelainV1(String output) { if (output.isEmpty) return const []; final entries = []; final parts = output.split('\x00'); diff --git a/lib/src/pql/client.dart b/lib/src/pql/client.dart index 1e537b73..b1d8bd27 100644 --- a/lib/src/pql/client.dart +++ b/lib/src/pql/client.dart @@ -8,6 +8,8 @@ library; import 'dart:convert'; import 'dart:io'; +import '../../kernel/src/toolchain.dart'; + class PqlException implements Exception { const PqlException(this.message, {this.exitCode = 1, this.stderr = ''}); final String message; @@ -19,10 +21,10 @@ class PqlException implements Exception { } class PqlClient { - PqlClient({required this.workDir, this.pqlBinary = 'pql'}); + PqlClient({required this.workDir, required this.toolchain}); final Directory workDir; - final String pqlBinary; + final Toolchain toolchain; Future>> files({String? glob, int? limit}) async { final args = ['files']; @@ -165,11 +167,20 @@ class PqlClient { } Future _run(List args) async { - final r = await Process.run( - pqlBinary, - args, - workingDirectory: workDir.path, - ); + final ProcessResult r; + try { + r = await Process.run( + toolchain.pql, + args, + workingDirectory: workDir.path, + ); + } on ProcessException catch (e) { + throw PqlException( + 'pql ${args.first}: ${e.message}', + exitCode: e.errorCode, + stderr: e.toString(), + ); + } final stderr = (r.stderr as String).trim(); // Exit 2 = zero matches — valid empty result, not an error. if (r.exitCode != 0 && r.exitCode != 2) { diff --git a/test/daemon/git_commands_test.dart b/test/daemon/git_commands_test.dart index 1adf0023..09d8d063 100644 --- a/test/daemon/git_commands_test.dart +++ b/test/daemon/git_commands_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:clide/clide.dart'; +import 'package:clide/kernel/src/toolchain.dart'; +import 'package:clide/src/git/client.dart'; import 'package:clide/src/daemon/git_commands.dart'; import 'package:test/test.dart'; @@ -32,7 +34,10 @@ void main() { sink = RecordingEventSink(); dispatcher = DaemonDispatcher(); - registerGitCommands(dispatcher, sandbox, sink); + final toolchain = Toolchain(); + toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: sandbox.path)); + final gitClient = GitClient(toolchain: toolchain, workDir: sandbox); + registerGitCommands(dispatcher, gitClient, sink); }); tearDown(() async { diff --git a/test/daemon/pane_commands_test.dart b/test/daemon/pane_commands_test.dart index 3d741a00..ea96e8d8 100644 --- a/test/daemon/pane_commands_test.dart +++ b/test/daemon/pane_commands_test.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/clide.dart'; +import 'package:clide/kernel/src/toolchain.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/panes/registry.dart'; import 'package:test/test.dart'; @@ -16,9 +17,8 @@ import 'package:test/test.dart'; void main() { if (!Platform.isLinux && !Platform.isMacOS) return; - final ptycPath = File('ptyc/bin/ptyc').existsSync() - ? File('ptyc/bin/ptyc').absolute.path - : 'ptyc'; + final toolchain = Toolchain(); + toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path)); group('pane.* dispatch', () { late DaemonDispatcher dispatcher; @@ -28,7 +28,7 @@ void main() { final sink = RecordingEventSink(); registry = PaneRegistry(events: sink); dispatcher = DaemonDispatcher(); - registerPaneCommands(dispatcher, registry); + registerPaneCommands(dispatcher, registry, toolchain: toolchain); }); tearDown(() => registry.shutdown()); @@ -48,7 +48,7 @@ void main() { final r = await call('pane.spawn', { 'argv': const ['/bin/sh', '-c', 'sleep 0.1'], 'kind': 'terminal', - 'ptyc_path': ptycPath, + 'ptyc_path': toolchain.ptyc, }); expect(r.ok, isTrue, reason: r.error?.message); expect(r.data['id'], startsWith('p_')); @@ -58,12 +58,12 @@ void main() { test('pane.list shows spawned panes', () async { await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': ptycPath, + 'ptyc_path': toolchain.ptyc, }); await call('pane.spawn', { 'argv': const ['/bin/cat'], 'kind': 'claude', - 'ptyc_path': ptycPath, + 'ptyc_path': toolchain.ptyc, }); final r = await call('pane.list', const {}); final panes = (r.data['panes'] as List).cast(); @@ -74,7 +74,7 @@ void main() { test('pane.write accepts text or bytes_b64', () async { final spawn = await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': ptycPath, + 'ptyc_path': toolchain.ptyc, }); final id = spawn.data['id']! as String; @@ -98,7 +98,7 @@ void main() { test('pane.resize + pane.close + pane.focus round-trip', () async { final spawn = await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': ptycPath, + 'ptyc_path': toolchain.ptyc, }); final id = spawn.data['id']! as String; diff --git a/test/daemon/pql_commands_test.dart b/test/daemon/pql_commands_test.dart index cf88f725..012f310b 100644 --- a/test/daemon/pql_commands_test.dart +++ b/test/daemon/pql_commands_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; +import 'package:clide/kernel/src/toolchain.dart'; import 'package:clide/src/daemon/pql_commands.dart'; import 'package:clide/src/pql/client.dart'; import 'package:test/test.dart'; @@ -10,7 +11,9 @@ void main() { late PqlClient pql; setUp(() { - pql = PqlClient(workDir: Directory.current); + final toolchain = Toolchain(); + toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path)); + pql = PqlClient(workDir: Directory.current, toolchain: toolchain); dispatcher = DaemonDispatcher(); registerPqlCommands(dispatcher, pql); });