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
@@ -18,6 +18,18 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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 desktop target — OS-detecting Makefile (`make run` works on
|
||||||
macOS/Linux/Windows), 1280x720 default window, squared app icons,
|
macOS/Linux/Windows), 1280x720 default window, squared app icons,
|
||||||
sandbox entitlements with SBPL exceptions, `_DARWIN_C_SOURCE` for
|
sandbox entitlements with SBPL exceptions, `_DARWIN_C_SOURCE` for
|
||||||
|
|||||||
+9
-5
@@ -13,6 +13,8 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
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-only deep imports — these pull in dart:ffi (PTY) and
|
||||||
// daemon-subsystem wiring that the Flutter app doesn't need 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.
|
// can't compile for web. See lib/clide.dart for the barrel split.
|
||||||
@@ -142,20 +144,22 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
socketPath: socketPath,
|
socketPath: socketPath,
|
||||||
dispatch: dispatcher.dispatch,
|
dispatch: dispatcher.dispatch,
|
||||||
);
|
);
|
||||||
final ptycPath = _resolvePtycPath();
|
final toolchain = Toolchain();
|
||||||
|
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||||
|
|
||||||
final events = _ServerEventSink(server);
|
final events = _ServerEventSink(server);
|
||||||
final registry = PaneRegistry(events: events);
|
final registry = PaneRegistry(events: events);
|
||||||
registerPaneCommands(dispatcher, registry, defaultPtycPath: ptycPath);
|
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
||||||
|
|
||||||
final files = FilesService.atCwd(events: events);
|
final files = FilesService.atCwd(events: events);
|
||||||
registerFilesCommands(dispatcher, files);
|
registerFilesCommands(dispatcher, files);
|
||||||
|
|
||||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
||||||
registerEditorCommands(dispatcher, editor);
|
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, toolchain: toolchain);
|
||||||
|
|
||||||
final pql = PqlClient(workDir: files.root);
|
|
||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
final stopping = Completer<void>();
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ class ToolStatusItem extends StatelessWidget {
|
|||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: kernel.toolCheck,
|
listenable: kernel.toolchain,
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
final tc = kernel.toolCheck;
|
final tc = kernel.toolchain;
|
||||||
if (!tc.checked) return const SizedBox.shrink();
|
if (!tc.resolved) return const SizedBox.shrink();
|
||||||
if (tc.allOk) {
|
if (tc.allOk) {
|
||||||
return _chip('ok', tokens.statusSuccess, tokens);
|
return _chip('application ok', tokens.statusSuccess, tokens);
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
if (i > 0) const SizedBox(width: 10),
|
||||||
_chip(tc.errors[i], tokens.statusWarning, tokens),
|
_chip('${tc.missing[i]} not found', tokens.statusWarning, tokens),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class WelcomeView extends StatelessWidget {
|
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<String>((ctx, dismiss) {
|
kernel.dialog.show<String>((ctx, dismiss) {
|
||||||
return _OpenProjectDialog(
|
return _OpenProjectDialog(
|
||||||
onOpen: (path) async {
|
onOpen: (path) async {
|
||||||
@@ -242,20 +261,20 @@ class _StatusLine extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeName = kernel.theme.currentName;
|
final themeName = kernel.theme.currentName;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: kernel.toolCheck,
|
listenable: kernel.toolchain,
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
final tc = kernel.toolCheck;
|
final tc = kernel.toolchain;
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||||
ClideText(' · ', muted: true, fontSize: 12),
|
ClideText(' · ', muted: true, fontSize: 12),
|
||||||
if (!tc.checked)
|
if (!tc.resolved)
|
||||||
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
|
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
|
||||||
else if (tc.allOk)
|
else if (tc.allOk)
|
||||||
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||||
else
|
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),
|
ClideText(' · ', muted: true, fontSize: 12),
|
||||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
_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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export 'src/editor/buffer.dart';
|
|||||||
export 'src/files/ignore.dart';
|
export 'src/files/ignore.dart';
|
||||||
export 'src/files/listing.dart' show FileEntry, listDir;
|
export 'src/files/listing.dart' show FileEntry, listDir;
|
||||||
export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind;
|
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/operations.dart' show GitLogEntry, GitException;
|
||||||
export 'src/git/status.dart'
|
export 'src/git/status.dart'
|
||||||
show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ export 'src/theme/palette.dart';
|
|||||||
export 'src/theme/resolver.dart';
|
export 'src/theme/resolver.dart';
|
||||||
export 'src/theme/semantic.dart';
|
export 'src/theme/semantic.dart';
|
||||||
export 'src/theme/tokens.dart';
|
export 'src/theme/tokens.dart';
|
||||||
export 'src/tool_check.dart';
|
export 'src/toolchain.dart';
|
||||||
export 'src/window_controls.dart';
|
export 'src/window_controls.dart';
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import 'package:clide/kernel/src/secrets.dart';
|
|||||||
import 'package:clide/kernel/src/settings.dart';
|
import 'package:clide/kernel/src/settings.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
import 'package:clide/kernel/src/theme/controller.dart';
|
||||||
import 'package:clide/kernel/src/theme/loader.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/tray.dart';
|
||||||
import 'package:clide/kernel/src/window_controls.dart';
|
import 'package:clide/kernel/src/window_controls.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -61,7 +61,7 @@ class KernelServices {
|
|||||||
required this.project,
|
required this.project,
|
||||||
required this.extensions,
|
required this.extensions,
|
||||||
required this.window,
|
required this.window,
|
||||||
required this.toolCheck,
|
required this.toolchain,
|
||||||
required this.scheduler,
|
required this.scheduler,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class KernelServices {
|
|||||||
final ProjectManager project;
|
final ProjectManager project;
|
||||||
final ExtensionManager extensions;
|
final ExtensionManager extensions;
|
||||||
final WindowControls window;
|
final WindowControls window;
|
||||||
final ToolCheck toolCheck;
|
final Toolchain toolchain;
|
||||||
final SchedulerService scheduler;
|
final SchedulerService scheduler;
|
||||||
|
|
||||||
static Future<KernelServices> boot({
|
static Future<KernelServices> boot({
|
||||||
@@ -103,6 +103,7 @@ class KernelServices {
|
|||||||
String? socketPath,
|
String? socketPath,
|
||||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||||
bool autoStartDaemonClient = true,
|
bool autoStartDaemonClient = true,
|
||||||
|
Toolchain? toolchain,
|
||||||
}) async {
|
}) async {
|
||||||
final log = Logger();
|
final log = Logger();
|
||||||
final events = DaemonBus();
|
final events = DaemonBus();
|
||||||
@@ -138,13 +139,14 @@ class KernelServices {
|
|||||||
final net = NetworkStatus();
|
final net = NetworkStatus();
|
||||||
final focus = FocusTracker();
|
final focus = FocusTracker();
|
||||||
final window = WindowControls();
|
final window = WindowControls();
|
||||||
final toolCheck = ToolCheck();
|
final tc = toolchain ?? Toolchain();
|
||||||
final scheduler = SchedulerService(events);
|
final scheduler = SchedulerService(events);
|
||||||
scheduler.start();
|
scheduler.start();
|
||||||
final project = ProjectManager(
|
final project = ProjectManager(
|
||||||
log: log,
|
log: log,
|
||||||
events: events,
|
events: events,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
toolchain: tc,
|
||||||
);
|
);
|
||||||
final ipc = daemonClientFactory != null
|
final ipc = daemonClientFactory != null
|
||||||
? daemonClientFactory(log, events)
|
? daemonClientFactory(log, events)
|
||||||
@@ -207,7 +209,7 @@ class KernelServices {
|
|||||||
project: project,
|
project: project,
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
window: window,
|
window: window,
|
||||||
toolCheck: toolCheck,
|
toolchain: tc,
|
||||||
scheduler: scheduler,
|
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/events/types.dart';
|
||||||
import 'package:clide/kernel/src/log.dart';
|
import 'package:clide/kernel/src/log.dart';
|
||||||
import 'package:clide/kernel/src/settings.dart';
|
import 'package:clide/kernel/src/settings.dart';
|
||||||
|
import 'package:clide/kernel/src/toolchain.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class RecentProject {
|
class RecentProject {
|
||||||
@@ -47,13 +48,16 @@ class ProjectManager extends ChangeNotifier {
|
|||||||
required Logger log,
|
required Logger log,
|
||||||
required DaemonBus events,
|
required DaemonBus events,
|
||||||
required SettingsStore settings,
|
required SettingsStore settings,
|
||||||
|
required Toolchain toolchain,
|
||||||
}) : _log = log,
|
}) : _log = log,
|
||||||
_events = events,
|
_events = events,
|
||||||
_settings = settings;
|
_settings = settings,
|
||||||
|
_toolchain = toolchain;
|
||||||
|
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
final DaemonBus _events;
|
final DaemonBus _events;
|
||||||
final SettingsStore _settings;
|
final SettingsStore _settings;
|
||||||
|
final Toolchain _toolchain;
|
||||||
|
|
||||||
Directory? _current;
|
Directory? _current;
|
||||||
Directory? get current => _current;
|
Directory? get current => _current;
|
||||||
@@ -116,7 +120,7 @@ class ProjectManager extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<String?> resolveWorkspace(String path) async {
|
Future<String?> resolveWorkspace(String path) async {
|
||||||
try {
|
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;
|
if (r.exitCode != 0) return null;
|
||||||
final out = (r.stdout as String).trim();
|
final out = (r.stdout as String).trim();
|
||||||
return out.isEmpty ? null : out;
|
return out.isEmpty ? null : out;
|
||||||
@@ -128,7 +132,7 @@ class ProjectManager extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<String?> _currentBranch(String root) async {
|
Future<String?> _currentBranch(String root) async {
|
||||||
try {
|
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;
|
if (r.exitCode != 0) return null;
|
||||||
final out = (r.stdout as String).trim();
|
final out = (r.stdout as String).trim();
|
||||||
return out.isEmpty ? null : out;
|
return out.isEmpty ? null : out;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../src/pty/env.dart';
|
||||||
|
|
||||||
class ToolCheck extends ChangeNotifier {
|
class ToolCheck extends ChangeNotifier {
|
||||||
bool ptycOk = false;
|
bool ptycOk = false;
|
||||||
bool pqlOk = false;
|
bool pqlOk = false;
|
||||||
@@ -18,24 +20,31 @@ class ToolCheck extends ChangeNotifier {
|
|||||||
if (!gitOk) 'git not found',
|
if (!gitOk) 'git not found',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Workspace root, set by the app at boot. Falls back to cwd.
|
||||||
|
static String? workspaceRoot;
|
||||||
|
|
||||||
Future<void> check() async {
|
Future<void> check() async {
|
||||||
final cwd = Directory.current.path;
|
final root = workspaceRoot ?? Directory.current.path;
|
||||||
ptycOk = File('$cwd/native/linux-x64/ptyc').existsSync() ||
|
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
|
||||||
File('$cwd/ptyc/bin/ptyc').existsSync() ||
|
File('$root/native/macos-arm64/ptyc').existsSync() ||
|
||||||
await _which('ptyc');
|
File('$root/native/macos-x64/ptyc').existsSync() ||
|
||||||
pqlOk = await _which('pql');
|
File('$root/ptyc/bin/ptyc').existsSync() ||
|
||||||
tmuxOk = await _which('tmux');
|
_existsOnPath('ptyc');
|
||||||
gitOk = await _which('git');
|
pqlOk = _existsOnPath('pql');
|
||||||
|
tmuxOk = _existsOnPath('tmux');
|
||||||
|
gitOk = _existsOnPath('git');
|
||||||
checked = true;
|
checked = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> _which(String name) async {
|
/// Check if [name] exists as an executable in any PATH directory.
|
||||||
try {
|
/// Uses direct file-existence checks — works inside a macOS sandbox
|
||||||
final r = await Process.run('which', [name]);
|
/// without needing to exec `which`.
|
||||||
return r.exitCode == 0;
|
static bool _existsOnPath(String name) {
|
||||||
} catch (_) {
|
for (final dir in expandedPath.split(':')) {
|
||||||
return false;
|
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;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-17
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/app.dart';
|
import 'package:clide/app.dart';
|
||||||
|
import 'package:clide/test_app.dart';
|
||||||
import 'package:clide/builtin/canvas/canvas.dart';
|
import 'package:clide/builtin/canvas/canvas.dart';
|
||||||
import 'package:clide/builtin/claude/claude.dart';
|
import 'package:clide/builtin/claude/claude.dart';
|
||||||
import 'package:clide/builtin/claude_control/claude_control.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/kernel.dart';
|
||||||
import 'package:clide/kernel/src/ipc/in_process.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/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||||
import 'package:clide/src/daemon/dispatcher.dart';
|
import 'package:clide/src/daemon/dispatcher.dart';
|
||||||
import 'package:clide/src/daemon/editor_commands.dart';
|
import 'package:clide/src/daemon/editor_commands.dart';
|
||||||
@@ -47,6 +50,13 @@ import 'package:flutter/widgets.dart';
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
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();
|
binding.ensureSemantics();
|
||||||
|
|
||||||
TreeSitterLib.init();
|
TreeSitterLib.init();
|
||||||
@@ -54,25 +64,28 @@ Future<void> main() async {
|
|||||||
final appDir = await _resolveAppDir();
|
final appDir = await _resolveAppDir();
|
||||||
final themes = await _loadBundledThemes();
|
final themes = await _loadBundledThemes();
|
||||||
|
|
||||||
|
final toolchain = Toolchain();
|
||||||
|
|
||||||
final services = await KernelServices.boot(
|
final services = await KernelServices.boot(
|
||||||
appDir: appDir,
|
appDir: appDir,
|
||||||
bundledThemes: themes,
|
bundledThemes: themes,
|
||||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||||
preloadNamespaces: _tier0Namespaces,
|
preloadNamespaces: _tier0Namespaces,
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
|
toolchain: toolchain,
|
||||||
daemonClientFactory: kIsWeb ? null : (log, events) {
|
daemonClientFactory: kIsWeb ? null : (log, events) {
|
||||||
final dispatcher = DaemonDispatcher();
|
final dispatcher = DaemonDispatcher();
|
||||||
final eventSink = _BusEventSink(events);
|
final eventSink = _BusEventSink(events);
|
||||||
final filesService = FilesService.atCwd(events: eventSink);
|
final filesService = FilesService.atCwd(events: eventSink);
|
||||||
final workRoot = filesService.root;
|
final workRoot = filesService.root;
|
||||||
final ptycPath = _resolvePtyc(workRoot.path);
|
|
||||||
final paneRegistry = PaneRegistry(events: eventSink);
|
final paneRegistry = PaneRegistry(events: eventSink);
|
||||||
registerPaneCommands(dispatcher, paneRegistry, defaultPtycPath: ptycPath);
|
registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain);
|
||||||
registerFilesCommands(dispatcher, filesService);
|
registerFilesCommands(dispatcher, filesService);
|
||||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||||
registerEditorCommands(dispatcher, editorRegistry);
|
registerEditorCommands(dispatcher, editorRegistry);
|
||||||
registerGitCommands(dispatcher, workRoot, eventSink);
|
final gitClient = GitClient(toolchain: toolchain, workDir: workRoot);
|
||||||
final pql = PqlClient(workDir: workRoot);
|
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||||
|
final pql = PqlClient(workDir: workRoot, toolchain: toolchain);
|
||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||||
},
|
},
|
||||||
@@ -115,7 +128,6 @@ Future<void> main() async {
|
|||||||
await services.extensions.activateAll();
|
await services.extensions.activateAll();
|
||||||
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
unawaited(services.toolCheck.check());
|
|
||||||
await services.project.loadRecents();
|
await services.project.loadRecents();
|
||||||
var opened = await services.project.openLast();
|
var opened = await services.project.openLast();
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
@@ -127,6 +139,20 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runApp(ClideApp(services: services));
|
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.
|
/// Resolve the app-settings directory.
|
||||||
@@ -161,18 +187,6 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
|||||||
/// Every Tier-0 extension that ships an i18n catalog. Extensions
|
/// Every Tier-0 extension that ships an i18n catalog. Extensions
|
||||||
/// registered but not active (the 17 stubs) don't preload — their
|
/// registered but not active (the 17 stubs) don't preload — their
|
||||||
/// catalogs load lazily on activate in later tiers.
|
/// 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 {
|
class _BusEventSink implements DaemonEventSink {
|
||||||
_BusEventSink(this._bus);
|
_BusEventSink(this._bus);
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
/// can refresh.
|
/// can refresh.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import '../git/client.dart';
|
||||||
|
import '../git/operations.dart' show GitException;
|
||||||
import '../git/diff.dart';
|
|
||||||
import '../git/operations.dart';
|
|
||||||
import '../git/status.dart';
|
|
||||||
import '../ipc/envelope.dart';
|
import '../ipc/envelope.dart';
|
||||||
import '../ipc/schema_v1.dart';
|
import '../ipc/schema_v1.dart';
|
||||||
import '../panes/event_sink.dart';
|
import '../panes/event_sink.dart';
|
||||||
@@ -17,22 +14,30 @@ import 'dispatcher.dart';
|
|||||||
|
|
||||||
void registerGitCommands(
|
void registerGitCommands(
|
||||||
DaemonDispatcher d,
|
DaemonDispatcher d,
|
||||||
Directory workDir,
|
GitClient git,
|
||||||
DaemonEventSink events,
|
DaemonEventSink events,
|
||||||
) {
|
) {
|
||||||
d.register('git.status', (req) async {
|
d.register('git.status', (req) async {
|
||||||
final status = await gitStatus(workDir);
|
try {
|
||||||
return IpcResponse.ok(id: req.id, data: status.toJson());
|
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 {
|
d.register('git.diff', (req) async {
|
||||||
final staged = req.args['staged'] as bool? ?? false;
|
try {
|
||||||
final paths = _pathList(req.args['paths']);
|
final staged = req.args['staged'] as bool? ?? false;
|
||||||
final diffs = await gitDiff(workDir, staged: staged, paths: paths);
|
final paths = _pathList(req.args['paths']);
|
||||||
return IpcResponse.ok(id: req.id, data: {
|
final diffs = await git.diff(staged: staged, paths: paths);
|
||||||
'staged': staged,
|
return IpcResponse.ok(id: req.id, data: {
|
||||||
'diffs': [for (final d in diffs) d.toJson()],
|
'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 {
|
d.register('git.stage', (req) async {
|
||||||
@@ -49,7 +54,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await gitStage(workDir, paths);
|
await git.stage(paths);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'staged': paths});
|
return IpcResponse.ok(id: req.id, data: {'staged': paths});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -59,7 +64,7 @@ void registerGitCommands(
|
|||||||
|
|
||||||
d.register('git.stage-all', (req) async {
|
d.register('git.stage-all', (req) async {
|
||||||
try {
|
try {
|
||||||
await gitStage(workDir, const []);
|
await git.stage(const []);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
|
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -70,7 +75,7 @@ void registerGitCommands(
|
|||||||
d.register('git.unstage', (req) async {
|
d.register('git.unstage', (req) async {
|
||||||
final paths = _pathList(req.args['paths']);
|
final paths = _pathList(req.args['paths']);
|
||||||
try {
|
try {
|
||||||
await gitUnstage(workDir, paths);
|
await git.unstage(paths);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
|
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -91,7 +96,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await gitStageHunk(workDir, patch);
|
await git.stageHunk(patch);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -112,7 +117,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await gitUnstageHunk(workDir, patch);
|
await git.unstageHunk(patch);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -133,7 +138,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await gitDiscard(workDir, paths);
|
await git.discard(paths);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
|
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -154,7 +159,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final hash = await gitCommit(workDir, message);
|
final hash = await git.commit(message);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'hash': hash});
|
return IpcResponse.ok(id: req.id, data: {'hash': hash});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -166,7 +171,7 @@ void registerGitCommands(
|
|||||||
final message = req.args['message'] as String?;
|
final message = req.args['message'] as String?;
|
||||||
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
|
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
|
||||||
try {
|
try {
|
||||||
await gitStash(workDir, message: message, includeUntracked: includeUntracked);
|
await git.stash(message: message, includeUntracked: includeUntracked);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
|
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -176,7 +181,7 @@ void registerGitCommands(
|
|||||||
|
|
||||||
d.register('git.stash-pop', (req) async {
|
d.register('git.stash-pop', (req) async {
|
||||||
try {
|
try {
|
||||||
await gitStashPop(workDir);
|
await git.stashPop();
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: const {'popped': true});
|
return IpcResponse.ok(id: req.id, data: const {'popped': true});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -185,16 +190,20 @@ void registerGitCommands(
|
|||||||
});
|
});
|
||||||
|
|
||||||
d.register('git.log', (req) async {
|
d.register('git.log', (req) async {
|
||||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
try {
|
||||||
final entries = await gitLog(workDir, count: count);
|
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||||
return IpcResponse.ok(id: req.id, data: {
|
final entries = await git.log(count: count);
|
||||||
'entries': [for (final e in entries) e.toJson()],
|
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 {
|
d.register('git.pull', (req) async {
|
||||||
try {
|
try {
|
||||||
final output = await gitPull(workDir);
|
final output = await git.pull();
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
@@ -207,12 +216,7 @@ void registerGitCommands(
|
|||||||
final branch = req.args['branch'] as String?;
|
final branch = req.args['branch'] as String?;
|
||||||
final setUpstream = req.args['setUpstream'] as bool? ?? false;
|
final setUpstream = req.args['setUpstream'] as bool? ?? false;
|
||||||
try {
|
try {
|
||||||
final output = await gitPush(
|
final output = await git.push(remote: remote, branch: branch, setUpstream: setUpstream);
|
||||||
workDir,
|
|
||||||
remote: remote,
|
|
||||||
branch: branch,
|
|
||||||
setUpstream: setUpstream,
|
|
||||||
);
|
|
||||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
return _gitError(req.id, e);
|
return _gitError(req.id, e);
|
||||||
@@ -220,13 +224,14 @@ void registerGitCommands(
|
|||||||
});
|
});
|
||||||
|
|
||||||
d.register('git.branches', (req) async {
|
d.register('git.branches', (req) async {
|
||||||
final branches = await gitBranches(workDir);
|
try {
|
||||||
return IpcResponse.ok(id: req.id, data: {
|
final b = await git.branches();
|
||||||
'branches': [
|
return IpcResponse.ok(id: req.id, data: {
|
||||||
for (final b in branches)
|
'branches': [for (final e in b) {'name': e.name, 'current': e.current}],
|
||||||
{'name': b.name, 'current': b.current},
|
});
|
||||||
],
|
} on GitException catch (e) {
|
||||||
});
|
return _gitError(req.id, e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
d.register('git.checkout', (req) async {
|
d.register('git.checkout', (req) async {
|
||||||
@@ -242,7 +247,7 @@ void registerGitCommands(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await gitCheckout(workDir, branch);
|
await git.checkout(branch);
|
||||||
_emitChanged(events);
|
_emitChanged(events);
|
||||||
return IpcResponse.ok(id: req.id, data: {'branch': branch});
|
return IpcResponse.ok(id: req.id, data: {'branch': branch});
|
||||||
} on GitException catch (e) {
|
} on GitException catch (e) {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import '../ipc/envelope.dart';
|
|||||||
import '../ipc/schema_v1.dart';
|
import '../ipc/schema_v1.dart';
|
||||||
import '../panes/pane.dart';
|
import '../panes/pane.dart';
|
||||||
import '../panes/registry.dart';
|
import '../panes/registry.dart';
|
||||||
|
import '../../kernel/src/toolchain.dart';
|
||||||
import 'dispatcher.dart';
|
import 'dispatcher.dart';
|
||||||
|
|
||||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {String defaultPtycPath = 'ptyc'}) {
|
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
|
||||||
d.register('pane.spawn', (req) => _spawn(req, registry, defaultPtycPath));
|
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
|
||||||
d.register('pane.list', (req) => _list(req, registry));
|
d.register('pane.list', (req) => _list(req, registry));
|
||||||
d.register('pane.close', (req) => _close(req, registry));
|
d.register('pane.close', (req) => _close(req, registry));
|
||||||
d.register('pane.write', (req) => _write(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 args = req.args;
|
||||||
final rawArgv = args['argv'];
|
final rawArgv = args['argv'];
|
||||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
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,
|
cols: (args['cols'] as num?)?.toInt() ?? 80,
|
||||||
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
||||||
title: args['title'] as String?,
|
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());
|
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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<GitStatus> 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<List<GitDiff>> diff({bool staged = false, List<String> 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<List<GitLogEntry>> 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<String?> currentBranch() async {
|
||||||
|
final r = await _run(['symbolic-ref', '--short', 'HEAD']);
|
||||||
|
if (r.exitCode != 0) return null;
|
||||||
|
return (r.stdout as String).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<({String name, bool current})>> 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<String?> 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<void> stage(List<String> 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<void> unstage(List<String> 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<void> stageHunk(String patch) => _applyPatch(patch, cached: true);
|
||||||
|
|
||||||
|
Future<void> unstageHunk(String patch) => _applyPatch(patch, cached: true, reverse: true);
|
||||||
|
|
||||||
|
Future<void> discard(List<String> 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<String> 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<void> 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<void> stashPop() async {
|
||||||
|
final r = await _run(['stash', 'pop']);
|
||||||
|
if (r.exitCode != 0) throw GitException('git stash pop failed', stderr: r.stderr as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> 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<String> 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<void> 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<ProcessResult> _run(List<String> 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<void> _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<GitLogEntry> parseLog(String output) {
|
||||||
|
if (output.trim().isEmpty) return const [];
|
||||||
|
final records = output.split('\x01');
|
||||||
|
final entries = <GitLogEntry>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'operations.dart' show gitBin;
|
||||||
|
|
||||||
enum DiffLineKind { context, addition, removal, header }
|
enum DiffLineKind { context, addition, removal, header }
|
||||||
|
|
||||||
class DiffLine {
|
class DiffLine {
|
||||||
@@ -129,7 +131,7 @@ Future<List<GitDiff>> gitDiff(
|
|||||||
args.addAll(paths);
|
args.addAll(paths);
|
||||||
}
|
}
|
||||||
final result = await Process.run(
|
final result = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
args,
|
args,
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
|
|||||||
+33
-13
@@ -7,6 +7,26 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
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 {
|
class GitException implements Exception {
|
||||||
const GitException(this.message, {this.stderr = ''});
|
const GitException(this.message, {this.stderr = ''});
|
||||||
final String message;
|
final String message;
|
||||||
@@ -52,7 +72,7 @@ Future<void> gitStage(Directory workDir, List<String> paths) async {
|
|||||||
args.add('--');
|
args.add('--');
|
||||||
args.addAll(paths);
|
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) {
|
if (r.exitCode != 0) {
|
||||||
throw GitException('git add failed', stderr: r.stderr as String);
|
throw GitException('git add failed', stderr: r.stderr as String);
|
||||||
}
|
}
|
||||||
@@ -65,7 +85,7 @@ Future<void> gitUnstage(Directory workDir, List<String> paths) async {
|
|||||||
args.add('--');
|
args.add('--');
|
||||||
args.addAll(paths);
|
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) {
|
if (r.exitCode != 0) {
|
||||||
throw GitException('git reset failed', stderr: r.stderr as String);
|
throw GitException('git reset failed', stderr: r.stderr as String);
|
||||||
}
|
}
|
||||||
@@ -85,7 +105,7 @@ Future<void> gitUnstageHunk(Directory workDir, String patch) async {
|
|||||||
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
|
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
|
||||||
if (paths.isEmpty) return;
|
if (paths.isEmpty) return;
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['checkout', '--', ...paths],
|
['checkout', '--', ...paths],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -102,13 +122,13 @@ Future<String> gitCommit(
|
|||||||
}) async {
|
}) async {
|
||||||
final args = ['commit', '-m', message];
|
final args = ['commit', '-m', message];
|
||||||
if (amend) args.add('--amend');
|
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) {
|
if (r.exitCode != 0) {
|
||||||
throw GitException('git commit failed', stderr: r.stderr as String);
|
throw GitException('git commit failed', stderr: r.stderr as String);
|
||||||
}
|
}
|
||||||
// Return the new commit hash.
|
// Return the new commit hash.
|
||||||
final hashResult = await Process.run(
|
final hashResult = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['rev-parse', 'HEAD'],
|
['rev-parse', 'HEAD'],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -126,7 +146,7 @@ Future<void> gitStash(
|
|||||||
args.addAll(['-m', message]);
|
args.addAll(['-m', message]);
|
||||||
}
|
}
|
||||||
if (includeUntracked) args.add('--include-untracked');
|
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) {
|
if (r.exitCode != 0) {
|
||||||
throw GitException('git stash failed', stderr: r.stderr as String);
|
throw GitException('git stash failed', stderr: r.stderr as String);
|
||||||
}
|
}
|
||||||
@@ -135,7 +155,7 @@ Future<void> gitStash(
|
|||||||
/// Pop the top stash entry.
|
/// Pop the top stash entry.
|
||||||
Future<void> gitStashPop(Directory workDir) async {
|
Future<void> gitStashPop(Directory workDir) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['stash', 'pop'],
|
['stash', 'pop'],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -150,7 +170,7 @@ Future<List<GitLogEntry>> gitLog(
|
|||||||
int count = 20,
|
int count = 20,
|
||||||
}) async {
|
}) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
[
|
[
|
||||||
'log',
|
'log',
|
||||||
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
|
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
|
||||||
@@ -166,7 +186,7 @@ Future<List<GitLogEntry>> gitLog(
|
|||||||
/// Pull from remote.
|
/// Pull from remote.
|
||||||
Future<String> gitPull(Directory workDir) async {
|
Future<String> gitPull(Directory workDir) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['pull'],
|
['pull'],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -187,7 +207,7 @@ Future<String> gitPush(
|
|||||||
if (setUpstream) args.add('-u');
|
if (setUpstream) args.add('-u');
|
||||||
if (remote != null) args.add(remote);
|
if (remote != null) args.add(remote);
|
||||||
if (branch != null) args.add(branch);
|
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) {
|
if (r.exitCode != 0) {
|
||||||
throw GitException('git push failed', stderr: r.stderr as String);
|
throw GitException('git push failed', stderr: r.stderr as String);
|
||||||
}
|
}
|
||||||
@@ -198,7 +218,7 @@ Future<String> gitPush(
|
|||||||
Future<List<({String name, bool current})>> gitBranches(
|
Future<List<({String name, bool current})>> gitBranches(
|
||||||
Directory workDir) async {
|
Directory workDir) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['branch', '--format=%(refname:short)|%(HEAD)'],
|
['branch', '--format=%(refname:short)|%(HEAD)'],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -218,7 +238,7 @@ Future<List<({String name, bool current})>> gitBranches(
|
|||||||
/// Checkout a branch.
|
/// Checkout a branch.
|
||||||
Future<void> gitCheckout(Directory workDir, String branch) async {
|
Future<void> gitCheckout(Directory workDir, String branch) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['checkout', branch],
|
['checkout', branch],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
@@ -230,7 +250,7 @@ Future<void> gitCheckout(Directory workDir, String branch) async {
|
|||||||
/// Get the current branch name.
|
/// Get the current branch name.
|
||||||
Future<String?> gitCurrentBranch(Directory workDir) async {
|
Future<String?> gitCurrentBranch(Directory workDir) async {
|
||||||
final r = await Process.run(
|
final r = await Process.run(
|
||||||
'git',
|
gitBin,
|
||||||
['symbolic-ref', '--short', 'HEAD'],
|
['symbolic-ref', '--short', 'HEAD'],
|
||||||
workingDirectory: workDir.path,
|
workingDirectory: workDir.path,
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-12
@@ -7,6 +7,8 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'operations.dart' show gitBin;
|
||||||
|
|
||||||
enum GitFileState {
|
enum GitFileState {
|
||||||
added,
|
added,
|
||||||
modified,
|
modified,
|
||||||
@@ -110,11 +112,16 @@ class GitStatus {
|
|||||||
|
|
||||||
/// Run `git status` and parse the result.
|
/// Run `git status` and parse the result.
|
||||||
Future<GitStatus> gitStatus(Directory workDir) async {
|
Future<GitStatus> gitStatus(Directory workDir) async {
|
||||||
final branchResult = await Process.run(
|
final ProcessResult branchResult;
|
||||||
'git',
|
try {
|
||||||
['status', '--porcelain=v2', '--branch', '-z'],
|
branchResult = await Process.run(
|
||||||
workingDirectory: workDir.path,
|
gitBin,
|
||||||
);
|
['status', '--porcelain=v2', '--branch', '-z'],
|
||||||
|
workingDirectory: workDir.path,
|
||||||
|
);
|
||||||
|
} on ProcessException {
|
||||||
|
return const GitStatus(branch: null, entries: []);
|
||||||
|
}
|
||||||
|
|
||||||
String? branch;
|
String? branch;
|
||||||
String? upstream;
|
String? upstream;
|
||||||
@@ -138,11 +145,16 @@ Future<GitStatus> gitStatus(Directory workDir) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await Process.run(
|
final ProcessResult result;
|
||||||
'git',
|
try {
|
||||||
['status', '--porcelain=v1', '-z'],
|
result = await Process.run(
|
||||||
workingDirectory: workDir.path,
|
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) {
|
if (result.exitCode != 0) {
|
||||||
return GitStatus(
|
return GitStatus(
|
||||||
@@ -154,7 +166,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final entries = _parsePorcelainV1(result.stdout as String);
|
final entries = parsePorcelainV1(result.stdout as String);
|
||||||
return GitStatus(
|
return GitStatus(
|
||||||
branch: branch,
|
branch: branch,
|
||||||
upstream: upstream,
|
upstream: upstream,
|
||||||
@@ -164,7 +176,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<GitFileStatus> _parsePorcelainV1(String output) {
|
List<GitFileStatus> parsePorcelainV1(String output) {
|
||||||
if (output.isEmpty) return const [];
|
if (output.isEmpty) return const [];
|
||||||
final entries = <GitFileStatus>[];
|
final entries = <GitFileStatus>[];
|
||||||
final parts = output.split('\x00');
|
final parts = output.split('\x00');
|
||||||
|
|||||||
+18
-7
@@ -8,6 +8,8 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../kernel/src/toolchain.dart';
|
||||||
|
|
||||||
class PqlException implements Exception {
|
class PqlException implements Exception {
|
||||||
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
|
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
|
||||||
final String message;
|
final String message;
|
||||||
@@ -19,10 +21,10 @@ class PqlException implements Exception {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PqlClient {
|
class PqlClient {
|
||||||
PqlClient({required this.workDir, this.pqlBinary = 'pql'});
|
PqlClient({required this.workDir, required this.toolchain});
|
||||||
|
|
||||||
final Directory workDir;
|
final Directory workDir;
|
||||||
final String pqlBinary;
|
final Toolchain toolchain;
|
||||||
|
|
||||||
Future<List<Map<String, Object?>>> files({String? glob, int? limit}) async {
|
Future<List<Map<String, Object?>>> files({String? glob, int? limit}) async {
|
||||||
final args = ['files'];
|
final args = ['files'];
|
||||||
@@ -165,11 +167,20 @@ class PqlClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Object?> _run(List<String> args) async {
|
Future<Object?> _run(List<String> args) async {
|
||||||
final r = await Process.run(
|
final ProcessResult r;
|
||||||
pqlBinary,
|
try {
|
||||||
args,
|
r = await Process.run(
|
||||||
workingDirectory: workDir.path,
|
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();
|
final stderr = (r.stderr as String).trim();
|
||||||
// Exit 2 = zero matches — valid empty result, not an error.
|
// Exit 2 = zero matches — valid empty result, not an error.
|
||||||
if (r.exitCode != 0 && r.exitCode != 2) {
|
if (r.exitCode != 0 && r.exitCode != 2) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
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:clide/src/daemon/git_commands.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
@@ -32,7 +34,10 @@ void main() {
|
|||||||
|
|
||||||
sink = RecordingEventSink();
|
sink = RecordingEventSink();
|
||||||
dispatcher = DaemonDispatcher();
|
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 {
|
tearDown(() async {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
|
import 'package:clide/kernel/src/toolchain.dart';
|
||||||
import 'package:clide/src/daemon/pane_commands.dart';
|
import 'package:clide/src/daemon/pane_commands.dart';
|
||||||
import 'package:clide/src/panes/registry.dart';
|
import 'package:clide/src/panes/registry.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
@@ -16,9 +17,8 @@ import 'package:test/test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
if (!Platform.isLinux && !Platform.isMacOS) return;
|
if (!Platform.isLinux && !Platform.isMacOS) return;
|
||||||
|
|
||||||
final ptycPath = File('ptyc/bin/ptyc').existsSync()
|
final toolchain = Toolchain();
|
||||||
? File('ptyc/bin/ptyc').absolute.path
|
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||||
: 'ptyc';
|
|
||||||
|
|
||||||
group('pane.* dispatch', () {
|
group('pane.* dispatch', () {
|
||||||
late DaemonDispatcher dispatcher;
|
late DaemonDispatcher dispatcher;
|
||||||
@@ -28,7 +28,7 @@ void main() {
|
|||||||
final sink = RecordingEventSink();
|
final sink = RecordingEventSink();
|
||||||
registry = PaneRegistry(events: sink);
|
registry = PaneRegistry(events: sink);
|
||||||
dispatcher = DaemonDispatcher();
|
dispatcher = DaemonDispatcher();
|
||||||
registerPaneCommands(dispatcher, registry);
|
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() => registry.shutdown());
|
tearDown(() => registry.shutdown());
|
||||||
@@ -48,7 +48,7 @@ void main() {
|
|||||||
final r = await call('pane.spawn', {
|
final r = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/sh', '-c', 'sleep 0.1'],
|
'argv': const ['/bin/sh', '-c', 'sleep 0.1'],
|
||||||
'kind': 'terminal',
|
'kind': 'terminal',
|
||||||
'ptyc_path': ptycPath,
|
'ptyc_path': toolchain.ptyc,
|
||||||
});
|
});
|
||||||
expect(r.ok, isTrue, reason: r.error?.message);
|
expect(r.ok, isTrue, reason: r.error?.message);
|
||||||
expect(r.data['id'], startsWith('p_'));
|
expect(r.data['id'], startsWith('p_'));
|
||||||
@@ -58,12 +58,12 @@ void main() {
|
|||||||
test('pane.list shows spawned panes', () async {
|
test('pane.list shows spawned panes', () async {
|
||||||
await call('pane.spawn', {
|
await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': ptycPath,
|
'ptyc_path': toolchain.ptyc,
|
||||||
});
|
});
|
||||||
await call('pane.spawn', {
|
await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'kind': 'claude',
|
'kind': 'claude',
|
||||||
'ptyc_path': ptycPath,
|
'ptyc_path': toolchain.ptyc,
|
||||||
});
|
});
|
||||||
final r = await call('pane.list', const {});
|
final r = await call('pane.list', const {});
|
||||||
final panes = (r.data['panes'] as List).cast<Map>();
|
final panes = (r.data['panes'] as List).cast<Map>();
|
||||||
@@ -74,7 +74,7 @@ void main() {
|
|||||||
test('pane.write accepts text or bytes_b64', () async {
|
test('pane.write accepts text or bytes_b64', () async {
|
||||||
final spawn = await call('pane.spawn', {
|
final spawn = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': ptycPath,
|
'ptyc_path': toolchain.ptyc,
|
||||||
});
|
});
|
||||||
final id = spawn.data['id']! as String;
|
final id = spawn.data['id']! as String;
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ void main() {
|
|||||||
test('pane.resize + pane.close + pane.focus round-trip', () async {
|
test('pane.resize + pane.close + pane.focus round-trip', () async {
|
||||||
final spawn = await call('pane.spawn', {
|
final spawn = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': ptycPath,
|
'ptyc_path': toolchain.ptyc,
|
||||||
});
|
});
|
||||||
final id = spawn.data['id']! as String;
|
final id = spawn.data['id']! as String;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
|
import 'package:clide/kernel/src/toolchain.dart';
|
||||||
import 'package:clide/src/daemon/pql_commands.dart';
|
import 'package:clide/src/daemon/pql_commands.dart';
|
||||||
import 'package:clide/src/pql/client.dart';
|
import 'package:clide/src/pql/client.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
@@ -10,7 +11,9 @@ void main() {
|
|||||||
late PqlClient pql;
|
late PqlClient pql;
|
||||||
|
|
||||||
setUp(() {
|
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();
|
dispatcher = DaemonDispatcher();
|
||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user