Merge remote-tracking branch 'gitea/main'
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
# Conflicts: # .pql/pql-plan.json
This commit is contained in:
+188
-166
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Backend isolate — all subprocess and file I/O runs in a dedicated
|
||||
isolate, keeping the merged UI/platform thread on macOS free for
|
||||
rendering. Communicates via SendPort using the existing IPC protocol.
|
||||
Two-phase boot: resolve toolchain on spawn, initialize services on
|
||||
project open. Scheduler ticker only runs while a project is active.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
See [`decisions/README.md`](decisions/README.md).
|
||||
@@ -33,9 +33,9 @@ help: ## Show this help.
|
||||
.PHONY: run
|
||||
run: ## Launch the Flutter desktop app.
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_WORKSPACE=$(CURDIR)
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
else
|
||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_WORKSPACE=$(CURDIR)
|
||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
endif
|
||||
|
||||
TESTMODE_CATEGORY ?= all
|
||||
@@ -46,7 +46,7 @@ run-testmode: ## Launch ClideTestApp (TESTMODE_CATEGORY=toolchain|ipc|extensions
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
@GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \
|
||||
flutter run -d linux \
|
||||
--dart-define=CLIDE_WORKSPACE=$(CURDIR) \
|
||||
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||
@@ -54,7 +54,7 @@ ifeq ($(FLUTTER_OS),linux)
|
||||
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||
else
|
||||
@flutter run -d $(FLUTTER_OS) \
|
||||
--dart-define=CLIDE_WORKSPACE=$(CURDIR) \
|
||||
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ Future<void> _runDaemon(List<String> args) async {
|
||||
|
||||
final events = _ServerEventSink(server);
|
||||
final registry = PaneRegistry(events: events);
|
||||
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
||||
registerPaneCommands(dispatcher, registry);
|
||||
|
||||
final files = FilesService.atCwd(events: events);
|
||||
registerFilesCommands(dispatcher, files);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# macOS PTY Problem — Diagnosis Complete
|
||||
|
||||
## Status: Root cause found
|
||||
|
||||
The PTY master fd is valid (`isatty=1`), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is **timing**: the reader isolate starts too late and the shell has already exited by the time `read()` is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data.
|
||||
|
||||
## The timing sequence (what happens)
|
||||
|
||||
```
|
||||
1. Dart: socketpair()
|
||||
2. Dart: Process.start(ptyc)
|
||||
3. ptyc: posix_openpt + grantpt + unlockpt + open(slave)
|
||||
4. ptyc: fork() → child starts zsh
|
||||
5. ptyc: sendmsg(SCM_RIGHTS, master_fd) ← master fd sent
|
||||
6. ptyc: printf(ok) + exit (or block if diagnostic)
|
||||
7. Dart: recvmsg() → receives master_fd ← fd is valid, isatty=1
|
||||
8. Dart: PtySession._() constructor
|
||||
9. Dart: _startReader() → Isolate.spawn() ← ASYNC, does not start immediately
|
||||
10. ... event loop yields ...
|
||||
11. Reader isolate starts, calls read(masterFd)
|
||||
12. But zsh already exited → slave closed → read() returns 0 (EOF)
|
||||
```
|
||||
|
||||
The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes.
|
||||
|
||||
## Why is zsh exiting?
|
||||
|
||||
zsh (`/bin/zsh`) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons:
|
||||
|
||||
1. **No controlling terminal at spawn time.** ptyc's child does `setsid() + TIOCSCTTY + dup2(slave, 0/1/2)` — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits.
|
||||
|
||||
2. **Environment.** The `env` passed to ptyc may be empty or missing `TERM`, `HOME`, `SHELL` etc. Without `TERM`, zsh may fail to initialize and exit.
|
||||
|
||||
3. **The `PTYC_SOCK_FD` inherits open fds.** When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking.
|
||||
|
||||
4. **stdin is connected to the slave.** After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe.
|
||||
|
||||
## Verified facts
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| ptyc compiles on macOS | ✓ |
|
||||
| socketpair fd inherited by ptyc | ✓ (verified with test binary) |
|
||||
| ptyc creates PTY and forks | ✓ (`{"ok":true,"pid":N}`) |
|
||||
| SCM_RIGHTS transfer | ✓ (correct cmsg layout) |
|
||||
| cmsghdr struct (Dart) | ✓ (CmsghdrDarwin, 12 bytes, correct offsets) |
|
||||
| msghdr struct (Dart) | ✓ (MsghdrDarwin, correct field sizes) |
|
||||
| SOL_SOCKET | ✓ (0xffff on macOS) |
|
||||
| TIOCSWINSZ | ✓ (0x80087467 on macOS) |
|
||||
| Received fd is a tty | ✓ (isatty=1) |
|
||||
| Reader isolate starts | ✓ (prints "started, fd=N") |
|
||||
| Reader gets data | ✗ — EOF immediately (n=0) |
|
||||
| Keeping ptyc alive helps | ✗ — still EOF |
|
||||
| close(master) in ptyc is the cause | ✗ — disproven |
|
||||
|
||||
## Next steps
|
||||
|
||||
1. **Investigate why zsh exits immediately.** Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec.
|
||||
|
||||
2. **Check the environment passed to ptyc.** If `env` is empty, the child shell has no `TERM`, `HOME`, etc. and may exit immediately.
|
||||
|
||||
3. **Try a long-running command** instead of zsh — e.g. `sleep 10` — to rule out shell-specific init failures.
|
||||
|
||||
4. **Consider `forkpty()` on macOS** — eliminates the timing gap entirely. `forkpty()` creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd.
|
||||
|
||||
## Environment
|
||||
|
||||
- macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64)
|
||||
- Flutter 3.41.7, Developer ID signed (no sandbox)
|
||||
- pql 1.4.4, dugite-native git 2.53.0
|
||||
- ptyc compiled with: `cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE`
|
||||
@@ -0,0 +1,57 @@
|
||||
# PTY on macOS: A New Diagnostic and Resolution Plan
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Previous attempts to fix the PTY functionality on macOS have failed, even after correcting a deadlock in the Dart code. The core of the problem appears to be a fundamental mismatch in how the C helper (`ptyc`) constructs a control message and how the Dart FFI layer is trying to parse it.
|
||||
|
||||
This document proposes a new, systematic plan to diagnose and resolve this issue by focusing on a key unresolved contradiction: the length of the ancillary data message (`cmsg_len`).
|
||||
|
||||
## 2. The Unresolved Contradiction
|
||||
|
||||
The primary blocker is a discrepancy in the expected length of the `SCM_RIGHTS` control message:
|
||||
|
||||
- **Observed Behavior:** The raw byte dump provided in `docs/macos-pty-problem.md` from the Dart `recvmsg` call shows the `cmsg_len` field as **16 bytes**.
|
||||
- **Code Analysis:** An analysis of the POSIX `CMSG_LEN` macro, as used in `ptyc.c`, suggests the length should be **20 bytes** on a 64-bit macOS system.
|
||||
|
||||
This contradiction means we cannot be certain about the memory layout of the data we are parsing in Dart. Without resolving this, any attempt to fix the data offset is guesswork.
|
||||
|
||||
## 3. Proposed Investigation and Resolution Plan
|
||||
|
||||
This plan will definitively resolve the `cmsg_len` discrepancy and lead to a correct implementation.
|
||||
|
||||
### Step 1: Instrument `ptyc` to Reveal Ground Truth
|
||||
|
||||
The first step is to get definitive data from the source. We will modify `ptyc.c` to log the exact values it's using.
|
||||
|
||||
- **Action:** Add `fprintf(stderr, ...)` statements in `ptyc.c` right before the `sendmsg` call.
|
||||
- **Data to Log:**
|
||||
1. The calculated value of `CMSG_LEN(sizeof(int))`.
|
||||
2. The value of `CMSG_SPACE(sizeof(int))`.
|
||||
3. The value of `sizeof(struct cmsghdr)`.
|
||||
- **Expected Outcome:** This will give us the "ground truth" of the control message structure as constructed by `ptyc` in the actual build environment, resolving the 16-vs-20-byte mystery.
|
||||
|
||||
### Step 2: Correctly Implement the Dart FFI Parser
|
||||
|
||||
With the true `cmsg_len` and memory layout confirmed, we can correctly implement the Dart-side parser.
|
||||
|
||||
- **Action:** Modify `lib/src/pty/ffi/scm_rights.dart`.
|
||||
- **Logic:**
|
||||
- If the logged `cmsg_len` from Step 1 confirms there is alignment padding (i.e., data starts at offset 16), the `dataOffset` calculation will be updated to `16`.
|
||||
- If the logged data shows no padding (i.e., data starts at offset 12), the `dataOffset` will be confirmed as `12`, and we will know the issue lies elsewhere.
|
||||
- **Expected Outcome:** A Dart parser (`recvFd`) that correctly reads the file descriptor from the ancillary data based on empirical evidence, not theoretical calculation.
|
||||
|
||||
### Step 3: Ensure Asynchronous Operation
|
||||
|
||||
The previously identified deadlock, while not the root cause of this specific failure, is still a critical bug.
|
||||
|
||||
- **Action:** Ensure the fix in `lib/src/pty/session.dart` is applied, where the blocking `scm.recvFd` call is replaced with its asynchronous counterpart, `_recvFdAsync`.
|
||||
- **Expected Outcome:** The Dart event loop is not blocked during PTY creation, preventing deadlocks.
|
||||
|
||||
### Step 4: Verification
|
||||
|
||||
With the above changes in place, we will verify the complete solution.
|
||||
|
||||
- **Action:** Run the existing test suite via `flutter test test/pty/session_test.dart`.
|
||||
- **Success Criteria:** All tests in `session_test.dart` must pass, indicating that a PTY can be spawned, its output can be read, and data can be written to it.
|
||||
|
||||
This methodical approach replaces guesswork with a data-driven diagnosis, providing a clear and direct path to resolving the long-standing macOS PTY issue.
|
||||
+4
-4
@@ -478,7 +478,7 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 380,
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
@@ -513,10 +513,10 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(label: 'Open Local Project', shortcut: 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||
_ActionRow(label: 'New Window', shortcut: 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||
_ActionRow(label: 'Open Local Project', shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||
_ActionRow(label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Workspace', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -48,20 +48,26 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
@override
|
||||
bool _spawned = false;
|
||||
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
_terminal.onOutput = _onOutput;
|
||||
_terminal.onResize = _onResize;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
// Don't spawn here — wait for the first onResize from TerminalView
|
||||
// so the PTY gets real dimensions, not 80x24 defaults.
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resizeTimer?.cancel();
|
||||
_flushTimer?.cancel();
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
@@ -78,6 +84,25 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawnWhenReady() async {
|
||||
if (!mounted) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!kernel.project.isOpen) {
|
||||
// Wait for a project to open before spawning.
|
||||
final c = Completer<void>();
|
||||
late final StreamSubscription<ProjectOpened> sub;
|
||||
sub = kernel.events.on<ProjectOpened>().listen((_) {
|
||||
sub.cancel();
|
||||
if (!c.isCompleted) c.complete();
|
||||
});
|
||||
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
sub.cancel();
|
||||
});
|
||||
if (!mounted) return;
|
||||
}
|
||||
return _spawn();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _ipc();
|
||||
@@ -95,28 +120,32 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||
}
|
||||
|
||||
final sessionName = widget.isPrimary
|
||||
_sessionName = widget.isPrimary
|
||||
? primarySessionName(repoRoot)
|
||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
|
||||
// Try tmux-wrapped first (persistence). Fall back to direct claude
|
||||
// if tmux spawn errors.
|
||||
// tmux-wrapped session for persistence (D-041).
|
||||
// -x/-y set the initial window size; without them tmux defaults
|
||||
// to a huge size when running inside a PTY without a real terminal.
|
||||
final cols = _terminal.viewWidth;
|
||||
final rows = _terminal.viewHeight;
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'new-session',
|
||||
'-A',
|
||||
'-s',
|
||||
sessionName,
|
||||
'--',
|
||||
'claude',
|
||||
_sessionName!,
|
||||
'-x', '$cols',
|
||||
'-y', '$rows',
|
||||
];
|
||||
print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}');
|
||||
var resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
'title': _sessionName,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -129,7 +158,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
'title': _sessionName,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -139,7 +168,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
} else {
|
||||
setState(() => _statusLine = 'tmux · $sessionName');
|
||||
setState(() => _statusLine = 'tmux · $_sessionName');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -149,6 +178,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
final _outputBuf = StringBuffer();
|
||||
Timer? _flushTimer;
|
||||
|
||||
void _flushOutput() {
|
||||
_flushTimer = null;
|
||||
if (_outputBuf.isEmpty) return;
|
||||
_terminal.write(_outputBuf.toString());
|
||||
_outputBuf.clear();
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
@@ -158,7 +197,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
case 'pane.output':
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
// Batch all output from the current event loop turn into one
|
||||
// terminal.write() call. scheduleMicrotask runs after all
|
||||
// pending events but before the next frame, so split escape
|
||||
// sequences within the same event batch are reunited.
|
||||
if (_flushTimer == null) {
|
||||
_flushTimer = Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
}
|
||||
case 'pane.exit':
|
||||
if (widget.isPrimary) {
|
||||
@@ -181,10 +227,29 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
Timer? _resizeTimer;
|
||||
|
||||
void _onResize(int cols, int rows, int _, int __) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId');
|
||||
if (!_spawned) {
|
||||
// First resize — TerminalView has real dimensions now.
|
||||
_spawned = true;
|
||||
_spawnWhenReady();
|
||||
return;
|
||||
}
|
||||
// Debounce resize — rapid SIGWINCH during window drag corrupts
|
||||
// the terminal rendering. Wait for the resize to settle.
|
||||
_resizeTimer?.cancel();
|
||||
_resizeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
// tmux sizes windows by client, not PTY winsize. Explicitly
|
||||
// resize the tmux window to match the TerminalView dimensions.
|
||||
if (_sessionName != null) {
|
||||
Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
|
||||
@@ -32,6 +32,8 @@ class DecisionsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => DecisionDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ class TicketsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => TicketDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/// Manages the backend isolate lifecycle.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. [spawn] — starts the isolate, resolves toolchain (binary checks only).
|
||||
/// 2. [openWorkspace] — initializes services for a specific project root.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/backend_entry.dart';
|
||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
|
||||
class Backend {
|
||||
Backend._({
|
||||
required this.client,
|
||||
required this.toolchain,
|
||||
required SendPort backendRequestPort,
|
||||
required Isolate isolate,
|
||||
required ReceivePort receivePort,
|
||||
}) : _backendRequestPort = backendRequestPort,
|
||||
_isolate = isolate,
|
||||
_receivePort = receivePort;
|
||||
|
||||
final IsolateClient client;
|
||||
final Toolchain toolchain;
|
||||
final SendPort _backendRequestPort;
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _receivePort;
|
||||
|
||||
Completer<void>? _projectCompleter;
|
||||
final Map<String, Completer<String?>> _validateCompleters = {};
|
||||
int _validateId = 0;
|
||||
|
||||
/// Spawn the backend isolate. Returns when the toolchain is resolved.
|
||||
/// No services are active yet — call [openWorkspace] to activate.
|
||||
static Future<Backend> spawn({
|
||||
required IsolateClient Function(SendPort backendPort) clientFactory,
|
||||
String? hintRoot,
|
||||
}) async {
|
||||
final receivePort = ReceivePort();
|
||||
final completer = Completer<Backend>();
|
||||
|
||||
late final IsolateClient client;
|
||||
late final Isolate isolate;
|
||||
late final SendPort backendRequestPort;
|
||||
late final Backend backend;
|
||||
|
||||
receivePort.listen((message) {
|
||||
if (message is Map<String, Object?>) {
|
||||
final type = message['type'] as String?;
|
||||
if (type == 'ready') {
|
||||
backendRequestPort = message['requestPort'] as SendPort;
|
||||
client = clientFactory(backendRequestPort);
|
||||
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
ptyc: tcData['ptyc'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
|
||||
backend = Backend._(
|
||||
client: client,
|
||||
toolchain: toolchain,
|
||||
backendRequestPort: backendRequestPort,
|
||||
isolate: isolate,
|
||||
receivePort: receivePort,
|
||||
);
|
||||
|
||||
if (!completer.isCompleted) completer.complete(backend);
|
||||
} else if (type == 'project.validated') {
|
||||
final id = message['id'] as String;
|
||||
final root = message['root'] as String?;
|
||||
final c = backend._validateCompleters.remove(id);
|
||||
if (c != null && !c.isCompleted) c.complete(root);
|
||||
} else if (type == 'project.ready') {
|
||||
// Update toolchain with project-specific paths.
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
backend.toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
ptyc: tcData['ptyc'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
backend._projectCompleter?.complete();
|
||||
backend._projectCompleter = null;
|
||||
} else {
|
||||
// Response or event — forward to the client.
|
||||
client.handleMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isolate = await Isolate.spawn(
|
||||
backendEntry,
|
||||
BackendBootMessage(frontendPort: receivePort.sendPort, hintRoot: hintRoot),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Validate a path as a git repo. Returns the repo root or null.
|
||||
/// Runs git rev-parse in the backend isolate (no main-thread I/O).
|
||||
Future<String?> validateProject(String path) {
|
||||
final id = '${_validateId++}';
|
||||
final c = Completer<String?>();
|
||||
_validateCompleters[id] = c;
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.validate',
|
||||
'path': path,
|
||||
'id': id,
|
||||
});
|
||||
return c.future;
|
||||
}
|
||||
|
||||
/// Activate a project. The backend (re)initializes all services
|
||||
/// for the given root directory. Returns when services are ready.
|
||||
Future<void> openProject(String path) {
|
||||
_projectCompleter = Completer<void>();
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.open',
|
||||
'path': path,
|
||||
});
|
||||
return _projectCompleter!.future;
|
||||
}
|
||||
|
||||
/// Shut down the backend isolate.
|
||||
void dispose() {
|
||||
_isolate.kill(priority: Isolate.beforeNextEvent);
|
||||
_receivePort.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/// Backend isolate entry point.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. Resolve toolchain (find binaries) → report ready.
|
||||
/// 2. On `project.open` message → initialize services for the project.
|
||||
///
|
||||
/// The dispatcher only registers command handlers after a project is
|
||||
/// activated. IPC requests arriving before that get an error response.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
|
||||
/// Message sent from main isolate to bootstrap the backend.
|
||||
class BackendBootMessage {
|
||||
const BackendBootMessage({required this.frontendPort, this.hintRoot});
|
||||
final SendPort frontendPort;
|
||||
/// Optional path hint for initial toolchain resolution (e.g. CLIDE_PROJECT).
|
||||
/// Used to find project-local binaries like dugite before a project opens.
|
||||
final String? hintRoot;
|
||||
}
|
||||
|
||||
/// Top-level entry point for the backend isolate.
|
||||
void backendEntry(BackendBootMessage boot) {
|
||||
final frontendPort = boot.frontendPort;
|
||||
final requestPort = ReceivePort();
|
||||
final eventSink = _IsolateEventSink(frontendPort);
|
||||
final dispatcher = DaemonDispatcher();
|
||||
late Toolchain toolchain;
|
||||
|
||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||
// We need a project root for ptyc/dugite paths. Use a sensible
|
||||
// default; the real project comes from project.open.
|
||||
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
||||
|
||||
// Listen for messages from the frontend.
|
||||
requestPort.listen((message) async {
|
||||
if (message is! Map<String, Object?>) return;
|
||||
final type = message['type'] as String?;
|
||||
|
||||
if (type == 'project.validate') {
|
||||
// Validate a path as a git repo. Runs git rev-parse in the backend
|
||||
// isolate (safe from the merged thread). Returns the repo root or null.
|
||||
final path = message['path'] as String;
|
||||
final id = message['id'] as String;
|
||||
try {
|
||||
final r = await Process.run(toolchain.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: path, environment: toolchain.gitEnv);
|
||||
if (r.exitCode == 0) {
|
||||
final root = (r.stdout as String).trim();
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': root});
|
||||
} else {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} catch (_) {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} else if (type == 'project.open') {
|
||||
// Phase 2: (re)initialize services for the given project.
|
||||
final projectPath = message['path'] as String;
|
||||
final workDir = Directory(projectPath);
|
||||
|
||||
// Re-resolve toolchain with the actual project root (finds
|
||||
// dugite in native/dugite/, ptyc in ptyc/bin/, etc.)
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
||||
|
||||
// Clear existing handlers and re-register with new project.
|
||||
dispatcher.clear();
|
||||
|
||||
final filesService = FilesService(root: workDir, events: eventSink);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workDir);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workDir);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
|
||||
final pql = PqlClient(workDir: workDir, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
|
||||
// Tell the frontend the project is active.
|
||||
frontendPort.send({
|
||||
'type': 'project.ready',
|
||||
'path': projectPath,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
} else {
|
||||
// IPC request — dispatch if we have handlers.
|
||||
final req = IpcRequest.fromJson(message);
|
||||
if (dispatcher.isEmpty) {
|
||||
frontendPort.send(IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'No project active',
|
||||
hint: 'Open a project first',
|
||||
),
|
||||
).toJson());
|
||||
} else {
|
||||
final resp = await dispatcher.dispatch(req);
|
||||
frontendPort.send(resp.toJson());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send ready with toolchain state and request port.
|
||||
frontendPort.send({
|
||||
'type': 'ready',
|
||||
'requestPort': requestPort.sendPort,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _serializeToolchain(Toolchain tc) => {
|
||||
'git': tc.git,
|
||||
'pql': tc.pql,
|
||||
'tmux': tc.tmux,
|
||||
'ptyc': tc.ptyc,
|
||||
'shell': tc.shell,
|
||||
'gitEnv': tc.gitEnv,
|
||||
'missing': tc.missing,
|
||||
};
|
||||
|
||||
/// Sends IPC events to the frontend via SendPort.
|
||||
class _IsolateEventSink implements DaemonEventSink {
|
||||
_IsolateEventSink(this._port);
|
||||
final SendPort _port;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_port.send(event.toJson());
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,15 @@ class KernelServices {
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||
DaemonClient? isolateClient,
|
||||
bool autoStartDaemonClient = true,
|
||||
Toolchain? toolchain,
|
||||
Future<void> Function(String path)? onProjectOpen,
|
||||
Future<String?> Function(String path)? onValidateProject,
|
||||
DaemonBus? sharedBus,
|
||||
}) async {
|
||||
final log = Logger();
|
||||
final events = DaemonBus();
|
||||
final events = sharedBus ?? DaemonBus();
|
||||
final messages = MessageBus();
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
@@ -147,14 +151,17 @@ class KernelServices {
|
||||
events: events,
|
||||
settings: settings,
|
||||
toolchain: tc,
|
||||
onProjectOpen: onProjectOpen,
|
||||
onValidateProject: onValidateProject,
|
||||
);
|
||||
final ipc = daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
final ipc = isolateClient
|
||||
?? (daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
));
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// IPC client that sends requests to a backend isolate via SendPort.
|
||||
///
|
||||
/// Replaces [InProcessClient] for production use. The backend isolate
|
||||
/// owns the [DaemonDispatcher] and all subprocess/file-I/O services.
|
||||
/// Requests and responses travel as serialized Maps over SendPort,
|
||||
/// reusing the existing IPC protocol (IpcRequest/IpcResponse/IpcEvent).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
|
||||
class IsolateClient extends DaemonClient {
|
||||
IsolateClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required SendPort backendPort,
|
||||
}) : _backendPort = backendPort,
|
||||
_events = events,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
|
||||
final SendPort _backendPort;
|
||||
final DaemonBus _events;
|
||||
|
||||
/// The event bus that receives events from the backend.
|
||||
DaemonBus get events => _events;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
int _nextId = 0;
|
||||
|
||||
/// Called by [Backend] to feed incoming messages from the backend isolate.
|
||||
void handleMessage(Map<String, Object?> msg) {
|
||||
final type = msg['type'] as String?;
|
||||
switch (type) {
|
||||
case 'response':
|
||||
final resp = IpcResponse.fromJson(msg);
|
||||
final c = _pending.remove(resp.id);
|
||||
if (c != null && !c.isCompleted) c.complete(resp);
|
||||
case 'event':
|
||||
final evt = IpcEvent.fromJson(msg);
|
||||
_events.emit(DaemonEvent(
|
||||
subsystem: evt.subsystem,
|
||||
kind: evt.kind,
|
||||
data: evt.data,
|
||||
ts: evt.timestamp,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
final c = Completer<IpcResponse>();
|
||||
_pending[id] = c;
|
||||
_backendPort.send(req.toJson());
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
@@ -49,15 +49,21 @@ class ProjectManager extends ChangeNotifier {
|
||||
required DaemonBus events,
|
||||
required SettingsStore settings,
|
||||
required Toolchain toolchain,
|
||||
Future<void> Function(String path)? onProjectOpen,
|
||||
Future<String?> Function(String path)? onValidateProject,
|
||||
}) : _log = log,
|
||||
_events = events,
|
||||
_settings = settings,
|
||||
_toolchain = toolchain;
|
||||
_toolchain = toolchain,
|
||||
_onProjectOpen = onProjectOpen,
|
||||
_onValidateProject = onValidateProject;
|
||||
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
final SettingsStore _settings;
|
||||
final Toolchain _toolchain;
|
||||
final Future<void> Function(String path)? _onProjectOpen;
|
||||
final Future<String?> Function(String path)? _onValidateProject;
|
||||
|
||||
Directory? _current;
|
||||
Directory? get current => _current;
|
||||
@@ -81,12 +87,18 @@ class ProjectManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<bool> open(String path) async {
|
||||
final root = await resolveWorkspace(path);
|
||||
final root = await resolveProject(path);
|
||||
if (root == null) {
|
||||
_log.warn('project', 'not a git repo: $path');
|
||||
return false;
|
||||
}
|
||||
_current = Directory(root);
|
||||
|
||||
// Tell the backend isolate to (re)initialize services for this workspace.
|
||||
if (_onProjectOpen != null) {
|
||||
await _onProjectOpen!(root);
|
||||
}
|
||||
|
||||
await _settings.setProjectDir(_current);
|
||||
await _settings.set<String>('app.lastProject', root);
|
||||
|
||||
@@ -118,7 +130,13 @@ class ProjectManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> resolveWorkspace(String path) async {
|
||||
Future<String?> resolveProject(String path) async {
|
||||
// Prefer backend validation (runs in the backend isolate, safe from
|
||||
// the merged UI thread). Falls back to direct Process.run for tests
|
||||
// and the CLI binary.
|
||||
if (_onValidateProject != null) {
|
||||
return _onValidateProject!(path);
|
||||
}
|
||||
try {
|
||||
final r = await Process.run(_toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: _toolchain.gitEnv);
|
||||
if (r.exitCode != 0) return null;
|
||||
|
||||
@@ -36,9 +36,31 @@ class SchedulerService {
|
||||
Isolate? _isolate;
|
||||
ReceivePort? _port;
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
StreamSubscription<dynamic>? _projectSub;
|
||||
|
||||
/// Listen for project lifecycle events. The periodic ticker only runs
|
||||
/// while a project is open — no wasted cycles on the welcome screen.
|
||||
void start() {
|
||||
if (_isolate != null) return;
|
||||
_projectSub = _events.on<ProjectOpened>().listen((_) => _startTicker());
|
||||
_events.on<ProjectClosed>().listen((_) => _stopTicker());
|
||||
}
|
||||
|
||||
/// Start the periodic ticker and fire an immediate first cycle so
|
||||
/// all panels refresh without waiting for the first interval.
|
||||
void _startTicker() {
|
||||
_stopTicker();
|
||||
|
||||
// Stagger the initial ticks to avoid a rebuild storm on project open.
|
||||
var delay = 0;
|
||||
for (final tier in SchedulerTier.values) {
|
||||
if (tier == SchedulerTier.midnight) continue;
|
||||
Timer(Duration(milliseconds: delay), () {
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
});
|
||||
delay += 500;
|
||||
}
|
||||
|
||||
// Then start the periodic isolate.
|
||||
_port = ReceivePort();
|
||||
_sub = _port!.listen((msg) {
|
||||
if (msg is String) {
|
||||
@@ -49,6 +71,15 @@ class SchedulerService {
|
||||
Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso);
|
||||
}
|
||||
|
||||
void _stopTicker() {
|
||||
_sub?.cancel();
|
||||
_port?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_port = null;
|
||||
_sub = null;
|
||||
}
|
||||
|
||||
static void _isolateEntry(SendPort send) {
|
||||
int lastDay = DateTime.now().day;
|
||||
for (final tier in SchedulerTier.values) {
|
||||
@@ -67,11 +98,7 @@ class SchedulerService {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_port?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_port = null;
|
||||
_sub = null;
|
||||
_projectSub?.cancel();
|
||||
_stopTicker();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ 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({
|
||||
@@ -129,7 +127,7 @@ class Toolchain extends ChangeNotifier {
|
||||
}
|
||||
|
||||
static String? _findOnPath(String name) {
|
||||
for (final dir in expandedPath.split(':')) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
@@ -137,6 +135,23 @@ class Toolchain extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
static String _expandedPath() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
static String? _firstExisting(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
@@ -144,3 +159,72 @@ class Toolchain extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level function for compute/isolate use. Takes a single String
|
||||
/// argument (the workspace root) and returns a plain-data result.
|
||||
ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExistingStandalone(['$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 = _findOnPathStandalone('git');
|
||||
}
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: _findOnPathStandalone('pql'),
|
||||
tmux: _findOnPathStandalone('tmux'),
|
||||
ptyc: _firstExistingStandalone([
|
||||
'$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',
|
||||
]) ?? _findOnPathStandalone('ptyc'),
|
||||
shell: _findOnPathStandalone(
|
||||
Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
String? _findOnPathStandalone(String name) {
|
||||
for (final dir in _expandedPathStandalone().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstExistingStandalone(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPathStandalone() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
+25
-34
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/test_app.dart';
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
@@ -25,13 +23,11 @@ import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/builtin/tickets/tickets.dart';
|
||||
import 'package:clide/builtin/todos/todos.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:io' show Directory, 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';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
@@ -39,10 +35,12 @@ import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -64,7 +62,14 @@ Future<void> main() async {
|
||||
final appDir = await _resolveAppDir();
|
||||
final themes = await _loadBundledThemes();
|
||||
|
||||
// Resolve toolchain + boot daemon inline — same as Linux.
|
||||
// With proper signing (Developer ID), no sandbox or isolate needed.
|
||||
final toolchain = Toolchain();
|
||||
if (!kIsWeb) {
|
||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
||||
final root = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||
toolchain.applyResolved(resolveToolchainPaths(root));
|
||||
}
|
||||
|
||||
final services = await KernelServices.boot(
|
||||
appDir: appDir,
|
||||
@@ -79,7 +84,7 @@ Future<void> main() async {
|
||||
final filesService = FilesService.atCwd(events: eventSink);
|
||||
final workRoot = filesService.root;
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
@@ -139,19 +144,20 @@ Future<void> 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));
|
||||
});
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_bus.emit(DaemonEvent(
|
||||
subsystem: event.subsystem,
|
||||
kind: event.kind,
|
||||
data: event.data,
|
||||
ts: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,21 +194,6 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
||||
/// registered but not active (the 17 stubs) don't preload — their
|
||||
/// catalogs load lazily on activate in later tiers.
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_bus.emit(DaemonEvent(
|
||||
subsystem: event.subsystem,
|
||||
kind: event.kind,
|
||||
data: event.data,
|
||||
ts: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const List<String> _tier0Namespaces = [
|
||||
'builtin.default-layout',
|
||||
'builtin.welcome',
|
||||
|
||||
@@ -16,6 +16,13 @@ class DaemonDispatcher {
|
||||
_handlers[cmd] = handler;
|
||||
}
|
||||
|
||||
/// Remove all registered handlers except ping/version.
|
||||
void clear() {
|
||||
_handlers.removeWhere((k, _) => k != 'ping' && k != 'version');
|
||||
}
|
||||
|
||||
bool get isEmpty => _handlers.length <= 2; // only ping + version
|
||||
|
||||
Future<IpcResponse> dispatch(IpcRequest req) async {
|
||||
final h = _handlers[req.cmd];
|
||||
if (h == null) {
|
||||
|
||||
@@ -15,11 +15,10 @@ 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, {required Toolchain toolchain}) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry));
|
||||
d.register('pane.list', (req) => _list(req, registry));
|
||||
d.register('pane.close', (req) => _close(req, registry));
|
||||
d.register('pane.write', (req) => _write(req, registry));
|
||||
@@ -48,14 +47,7 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
|
||||
),
|
||||
);
|
||||
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
|
||||
final args = req.args;
|
||||
final rawArgv = args['argv'];
|
||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
||||
@@ -92,7 +84,6 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool
|
||||
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?) ?? toolchain.ptyc,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,10 +8,11 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../pty/session.dart';
|
||||
import '../pty/native_pty.dart';
|
||||
import 'event_sink.dart';
|
||||
import 'pane.dart';
|
||||
|
||||
@@ -20,7 +21,7 @@ class PaneRegistry {
|
||||
|
||||
final DaemonEventSink events;
|
||||
final Map<String, Pane> _panes = {};
|
||||
final Map<String, PtySession> _sessions = {};
|
||||
final Map<String, NativePty> _sessions = {};
|
||||
final Map<String, StreamSubscription<Uint8List>> _subs = {};
|
||||
int _nextId = 1;
|
||||
|
||||
@@ -42,16 +43,30 @@ class PaneRegistry {
|
||||
int cols = 80,
|
||||
int rows = 24,
|
||||
String? title,
|
||||
String ptycPath = 'ptyc',
|
||||
}) async {
|
||||
final id = 'p_${_nextId++}';
|
||||
final session = await PtySession.spawn(
|
||||
argv: argv,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
cols: cols,
|
||||
final executable = argv.first;
|
||||
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
|
||||
|
||||
// Merge the caller's env on top of the process environment +
|
||||
// terminal defaults, matching the old ptyc contract.
|
||||
final fullEnv = <String, String>{
|
||||
...Platform.environment,
|
||||
'TERM': 'xterm-256color',
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'LC_ALL': 'en_US.UTF-8',
|
||||
'TERMINFO': '/usr/share/terminfo',
|
||||
if (env != null) ...env,
|
||||
};
|
||||
|
||||
final session = NativePty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: cols,
|
||||
rows: rows,
|
||||
ptycPath: ptycPath,
|
||||
workingDirectory: cwd,
|
||||
environment: fullEnv,
|
||||
);
|
||||
final pane = Pane(
|
||||
id: id,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
@@ -21,8 +22,8 @@ import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
const int afUnix = 1;
|
||||
const int sockStream = 1;
|
||||
|
||||
const int solSocket = 1; // Linux; macOS = 0xffff
|
||||
const int scmRights = 1;
|
||||
final int solSocket = Platform.isMacOS ? 0xffff : 1;
|
||||
final int scmRights = Platform.isMacOS ? 0x01 : 1;
|
||||
|
||||
const int fIoNonblock = 0x800; // O_NONBLOCK — 04000 octal
|
||||
const int fGetFl = 3;
|
||||
@@ -133,7 +134,10 @@ final class Msghdr extends ffi.Struct {
|
||||
|
||||
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
|
||||
/// buffer as a raw byte region and compute offsets by hand.
|
||||
final class Cmsghdr extends ffi.Struct {
|
||||
// On Linux, cmsg_len is size_t (8 bytes on 64-bit).
|
||||
// On macOS, cmsg_len is socklen_t (4 bytes, always).
|
||||
// Use platform-specific structs.
|
||||
final class CmsghdrLinux extends ffi.Struct {
|
||||
@ffi.IntPtr()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
@@ -142,6 +146,18 @@ final class Cmsghdr extends ffi.Struct {
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
final class CmsghdrDarwin extends ffi.Struct {
|
||||
@ffi.Uint32()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
// Alias for backward compatibility — callers use Cmsghdr.
|
||||
typedef Cmsghdr = CmsghdrLinux;
|
||||
|
||||
/// POSIX `struct winsize` for `TIOCSWINSZ`.
|
||||
final class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
@@ -58,21 +59,28 @@ int recvFd(int socketFd) {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the first cmsghdr out of the control buffer. We assume a
|
||||
// single SCM_RIGHTS cmsg with one int of payload — that's what
|
||||
// `ptyc` sends and all we ever ask for.
|
||||
final hdr = control.cast<libc.Cmsghdr>().ref;
|
||||
if (hdr.cmsg_level != libc.solSocket || hdr.cmsg_type != libc.scmRights) {
|
||||
// Parse the first cmsghdr out of the control buffer. On macOS,
|
||||
// cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes).
|
||||
int cmsgLevel, cmsgType, dataOffset;
|
||||
if (Platform.isMacOS) {
|
||||
final hdr = control.cast<libc.CmsghdrDarwin>().ref;
|
||||
cmsgLevel = hdr.cmsg_level;
|
||||
cmsgType = hdr.cmsg_type;
|
||||
dataOffset = ffi.sizeOf<libc.CmsghdrDarwin>();
|
||||
} else {
|
||||
final hdr = control.cast<libc.CmsghdrLinux>().ref;
|
||||
cmsgLevel = hdr.cmsg_level;
|
||||
cmsgType = hdr.cmsg_type;
|
||||
dataOffset = ffi.sizeOf<libc.CmsghdrLinux>();
|
||||
}
|
||||
|
||||
if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) {
|
||||
throw PtyException(
|
||||
'recvmsg',
|
||||
'unexpected cmsg level=${hdr.cmsg_level} type=${hdr.cmsg_type}',
|
||||
'unexpected cmsg level=$cmsgLevel type=$cmsgType',
|
||||
);
|
||||
}
|
||||
|
||||
// CMSG_DATA starts at the first aligned boundary after the cmsghdr.
|
||||
// On Linux/glibc that's sizeof(cmsghdr) == 16, which is 8-byte
|
||||
// aligned already. We rely on that layout.
|
||||
final dataOffset = ffi.sizeOf<libc.Cmsghdr>();
|
||||
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
|
||||
return fdPtr.value;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/// Native PTY via forkpty() — replaces the ptyc helper binary.
|
||||
///
|
||||
/// Uses Dart FFI to call forkpty() directly. The master fd stays
|
||||
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate
|
||||
/// uses poll() for clean shutdown.
|
||||
///
|
||||
/// Based on the pty-spike proof-of-concept. Platform-aware:
|
||||
/// macOS: forkpty in libSystem (DynamicLibrary.process)
|
||||
/// Linux: forkpty in libutil.so.1
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show File, Platform;
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
|
||||
final class _Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int wsRow;
|
||||
@ffi.Uint16()
|
||||
external int wsCol;
|
||||
@ffi.Uint16()
|
||||
external int wsXpixel;
|
||||
@ffi.Uint16()
|
||||
external int wsYpixel;
|
||||
}
|
||||
|
||||
final class _Pollfd extends ffi.Struct {
|
||||
@ffi.Int32()
|
||||
external int fd;
|
||||
@ffi.Int16()
|
||||
external int events;
|
||||
@ffi.Int16()
|
||||
external int revents;
|
||||
}
|
||||
|
||||
// -- FFI bindings -----------------------------------------------------------
|
||||
|
||||
final ffi.DynamicLibrary _dl = _openLib();
|
||||
|
||||
ffi.DynamicLibrary _openLib() {
|
||||
if (Platform.isMacOS) return ffi.DynamicLibrary.process();
|
||||
// Linux: forkpty lives in libutil
|
||||
return ffi.DynamicLibrary.open('libutil.so.1');
|
||||
}
|
||||
|
||||
final _forkpty = _dl.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>,
|
||||
ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>),
|
||||
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>,
|
||||
ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>)>('forkpty');
|
||||
|
||||
final _execve = _dl.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Char>,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>),
|
||||
int Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>>)>('execve');
|
||||
|
||||
final _nativeWrite = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr),
|
||||
int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
|
||||
|
||||
final _nativeClose = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
|
||||
|
||||
final _ioctl = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>),
|
||||
int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
|
||||
|
||||
final _nativeKill = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
|
||||
|
||||
final _waitpid = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32),
|
||||
int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
|
||||
|
||||
final _chdir = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Char>),
|
||||
int Function(ffi.Pointer<ffi.Char>)>('chdir');
|
||||
|
||||
final _exit_ = ffi.DynamicLibrary.process().lookupFunction<
|
||||
ffi.Void Function(ffi.Int32), void Function(int)>('_exit');
|
||||
|
||||
final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
const _kSighup = 1;
|
||||
const _kWnohang = 1;
|
||||
|
||||
// -- NativePty --------------------------------------------------------------
|
||||
|
||||
/// A pseudo-terminal backed by forkpty() via Dart FFI.
|
||||
///
|
||||
/// Drop-in replacement for the old ptyc-based PtySession.
|
||||
class NativePty {
|
||||
final int _fd;
|
||||
final int pid;
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
|
||||
NativePty._(this._fd, this.pid);
|
||||
|
||||
/// Byte stream of data produced by the child.
|
||||
Stream<Uint8List> get output => _out.stream;
|
||||
|
||||
bool get isClosed => _dead;
|
||||
|
||||
/// Spawn a new PTY running [executable] with [arguments].
|
||||
///
|
||||
/// [environment] must be the complete environment — it goes straight
|
||||
/// to execve's envp. Merge Platform.environment before calling.
|
||||
static NativePty start({
|
||||
required String executable,
|
||||
List<String> arguments = const ['-l'],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
}) {
|
||||
// Resolve bare command names via PATH (execve doesn't search PATH).
|
||||
if (!executable.contains('/')) {
|
||||
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
|
||||
for (final dir in path.split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final candidate = '$dir/$executable';
|
||||
if (File(candidate).existsSync()) {
|
||||
executable = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force-resolve FFI functions that run in the child process.
|
||||
// Top-level finals are lazy; touching them here ensures the FFI
|
||||
// trampolines are compiled before fork() clones the process.
|
||||
final execve = _execve;
|
||||
final chdir = _chdir;
|
||||
final exit = _exit_;
|
||||
|
||||
// Allocate ALL native memory before fork.
|
||||
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
|
||||
final allArgs = [executable, ...arguments];
|
||||
final argvN = malloc<ffi.Pointer<ffi.Char>>(allArgs.length + 1);
|
||||
for (var i = 0; i < allArgs.length; i++) {
|
||||
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast();
|
||||
}
|
||||
argvN[allArgs.length] = ffi.nullptr;
|
||||
|
||||
final envList = environment.entries.toList();
|
||||
final envpN = malloc<ffi.Pointer<ffi.Char>>(envList.length + 1);
|
||||
for (var i = 0; i < envList.length; i++) {
|
||||
envpN[i] = '${envList[i].key}=${envList[i].value}'
|
||||
.toNativeUtf8(allocator: malloc)
|
||||
.cast();
|
||||
}
|
||||
envpN[envList.length] = ffi.nullptr;
|
||||
|
||||
final wdN = (workingDirectory ?? '/')
|
||||
.toNativeUtf8(allocator: malloc)
|
||||
.cast<ffi.Char>();
|
||||
final fdOut = calloc<ffi.Int32>();
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = columns;
|
||||
|
||||
// Fork.
|
||||
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
|
||||
|
||||
if (pid == -1) {
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN,
|
||||
fdOut, ws);
|
||||
throw StateError('forkpty() failed');
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// CHILD — only pre-resolved FFI calls, no Dart heap.
|
||||
chdir(wdN);
|
||||
execve(shellN, argvN, envpN);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PARENT
|
||||
final fd = fdOut.value;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN,
|
||||
fdOut, ws);
|
||||
|
||||
final pty = NativePty._(fd, pid);
|
||||
pty._spawnReader();
|
||||
return pty;
|
||||
}
|
||||
|
||||
static void _freeAll(
|
||||
ffi.Pointer shell,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> argv, int argc,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> envp, int envc,
|
||||
ffi.Pointer wd, ffi.Pointer fdOut, ffi.Pointer ws,
|
||||
) {
|
||||
malloc.free(shell);
|
||||
for (var i = 0; i < argc; i++) malloc.free(argv[i]);
|
||||
malloc.free(argv);
|
||||
for (var i = 0; i < envc; i++) malloc.free(envp[i]);
|
||||
malloc.free(envp);
|
||||
malloc.free(wd);
|
||||
calloc.free(fdOut);
|
||||
calloc.free(ws);
|
||||
}
|
||||
|
||||
// -- I/O ------------------------------------------------------------------
|
||||
|
||||
void _spawnReader() async {
|
||||
final rp = ReceivePort();
|
||||
await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Isolate entry — polls then reads until EOF/error/fd-closed.
|
||||
static void _readLoop((SendPort, int) msg) {
|
||||
final (port, fd) = msg;
|
||||
final dl = ffi.DynamicLibrary.process();
|
||||
final rd = dl.lookupFunction<
|
||||
ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr),
|
||||
int Function(int, ffi.Pointer<ffi.Void>, int)>('read');
|
||||
final poll = dl.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32),
|
||||
int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
|
||||
|
||||
final buf = malloc<ffi.Uint8>(65536);
|
||||
final pfd = calloc<_Pollfd>();
|
||||
pfd.ref.fd = fd;
|
||||
pfd.ref.events = 0x0001; // POLLIN
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
final ready = poll(pfd, 1, 100);
|
||||
if (ready < 0) break;
|
||||
if (ready == 0) continue;
|
||||
if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) {
|
||||
break;
|
||||
}
|
||||
final n = rd(fd, buf.cast(), 65536);
|
||||
if (n <= 0) break;
|
||||
port.send(Uint8List.fromList(buf.asTypedList(n)));
|
||||
}
|
||||
} finally {
|
||||
calloc.free(pfd);
|
||||
malloc.free(buf);
|
||||
}
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
/// Write bytes to the child's stdin.
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
|
||||
final n = _nativeWrite(_fd, buf.cast(), bytes.length);
|
||||
malloc.free(buf);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Resize the terminal.
|
||||
void resize({required int cols, required int rows}) {
|
||||
if (_dead) return;
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = cols;
|
||||
final rc = _ioctl(_fd, _kTiocsWinsz, ws);
|
||||
calloc.free(ws);
|
||||
print('[pty-resize] fd=$_fd cols=$cols rows=$rows ioctl=$rc pid=$pid');
|
||||
// Explicitly signal the child to re-query its terminal size.
|
||||
_nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux
|
||||
}
|
||||
|
||||
/// Send a signal to the child.
|
||||
bool kill([int signal = _kSighup]) {
|
||||
if (_dead) return false;
|
||||
return _nativeKill(pid, signal) == 0;
|
||||
}
|
||||
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, _kWnohang);
|
||||
calloc.free(s);
|
||||
}
|
||||
|
||||
/// Kill the child and release resources.
|
||||
Future<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
_nativeClose(_fd);
|
||||
_nativeKill(pid, _kSighup);
|
||||
_nativeKill(pid, 9);
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, 0);
|
||||
calloc.free(s);
|
||||
if (!_out.isClosed) await _out.close();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// PTY subsystem — spawn child processes under a PTY via `ptyc`,
|
||||
/// PTY subsystem — spawn child processes under a PTY via forkpty(),
|
||||
/// expose their master fd as a byte stream. Desktop IDE's pane model
|
||||
/// (terminal / Claude / future tmux wrappers) rides on this.
|
||||
library;
|
||||
|
||||
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
||||
export 'session.dart' show PtySession;
|
||||
export 'native_pty.dart' show NativePty;
|
||||
|
||||
@@ -29,6 +29,12 @@ import 'errors.dart';
|
||||
import 'ffi/libc.dart' as libc;
|
||||
import 'ffi/scm_rights.dart' as scm;
|
||||
|
||||
class _RecvFdArgs {
|
||||
const _RecvFdArgs(this.socketFd, this.sendPort);
|
||||
final int socketFd;
|
||||
final SendPort sendPort;
|
||||
}
|
||||
|
||||
/// A running PTY child plus its master-fd plumbing.
|
||||
class PtySession {
|
||||
PtySession._({
|
||||
@@ -124,7 +130,9 @@ class PtySession {
|
||||
await proc.stdin.close();
|
||||
|
||||
// Receive the master fd over the parent side of the socketpair.
|
||||
final masterFd = scm.recvFd(parentSock);
|
||||
// recvFd blocks until ptyc sends — run in a child isolate so the
|
||||
// calling isolate's event loop stays responsive.
|
||||
final masterFd = await _recvFdAsync(parentSock);
|
||||
|
||||
// Apply initial winsize (ptyc already did this, but doing it
|
||||
// again from Dart confirms the wire + gives a place to call it
|
||||
@@ -229,6 +237,27 @@ class PtySession {
|
||||
if (!_outputCtrl.isClosed) await _outputCtrl.close();
|
||||
}
|
||||
|
||||
/// Run recvFd in a child isolate so the blocking FFI call doesn't
|
||||
/// stall the calling isolate's event loop.
|
||||
static Future<int> _recvFdAsync(int socketFd) async {
|
||||
final port = ReceivePort();
|
||||
final iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
|
||||
final result = await port.first;
|
||||
iso.kill(priority: Isolate.immediate);
|
||||
port.close();
|
||||
if (result is int) return result;
|
||||
throw PtyException('recvFd', '$result');
|
||||
}
|
||||
|
||||
static void _recvFdEntry(_RecvFdArgs args) {
|
||||
try {
|
||||
final fd = scm.recvFd(args.socketFd);
|
||||
args.sendPort.send(fd);
|
||||
} catch (e) {
|
||||
args.sendPort.send('error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- //
|
||||
|
||||
void _startReader() {
|
||||
|
||||
+248
-1
@@ -14,7 +14,9 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate' show Isolate;
|
||||
|
||||
import 'package:flutter/foundation.dart' show compute;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -23,7 +25,19 @@ import 'builtin/files/files.dart';
|
||||
import 'builtin/git/git.dart';
|
||||
import 'builtin/terminal/terminal.dart';
|
||||
import 'extension/extension.dart' show ClideExtension;
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
import 'kernel/src/events/bus.dart';
|
||||
import 'kernel/src/events/types.dart';
|
||||
import 'kernel/src/ipc/in_process.dart';
|
||||
import 'kernel/src/log.dart';
|
||||
import 'src/daemon/pane_commands.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/panes/event_sink.dart';
|
||||
import 'src/panes/registry.dart';
|
||||
import 'src/pty/session.dart';
|
||||
import 'kernel/src/toolchain.dart';
|
||||
import 'src/daemon/dispatcher.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
@@ -53,7 +67,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
|
||||
Future<void> _runTests() async {
|
||||
const workspace = String.fromEnvironment('CLIDE_WORKSPACE');
|
||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
||||
const category = String.fromEnvironment('CLIDE_TESTMODE');
|
||||
final workDir = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||
|
||||
@@ -61,6 +75,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final runToolchain = runAll || category == 'toolchain';
|
||||
final runIpc = runAll || category == 'ipc';
|
||||
final runExtensions = runAll || category == 'extensions';
|
||||
final runTerminal = runAll || category == 'terminal';
|
||||
|
||||
print('[testmode] === ClideTestApp starting ===');
|
||||
print('[testmode] workspace=$workDir');
|
||||
@@ -75,6 +90,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
if (runToolchain) await _runToolchainTests(tc, workDir);
|
||||
if (runIpc) await _runIpcTests(workDir);
|
||||
if (runExtensions) await _runExtensionTests(workDir, tc);
|
||||
if (runTerminal) await _runTerminalTests(tc, workDir);
|
||||
|
||||
final passed = _results.where((r) => r.ok).length;
|
||||
final failed = _results.where((r) => !r.ok).length;
|
||||
@@ -134,6 +150,55 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
|
||||
_log('gitEnv', '${tc.gitEnv}');
|
||||
print('[testmode]');
|
||||
|
||||
// Boot sequence simulation tests
|
||||
print('[testmode] --- boot sequence ---');
|
||||
|
||||
await _testAsync('compute(resolveToolchainPaths)', () async {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
return 'git=${paths.git} pql=${paths.pql}';
|
||||
});
|
||||
|
||||
await _testAsync('Isolate.run(resolveToolchainPaths)', () async {
|
||||
final paths = await Isolate.run(() => resolveToolchainPaths(workDir));
|
||||
return 'git=${paths.git} pql=${paths.pql}';
|
||||
});
|
||||
|
||||
await _testAsync('git rev-parse (project.open sim)', () async {
|
||||
final r = await Process.run(tc.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
await _testAsync('sequential git calls', () async {
|
||||
final r1 = await Process.run(tc.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
final r2 = await Process.run(tc.git, ['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
return 'root=${(r1.stdout as String).trim()} branch=${(r2.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
await _testAsync('compute + immediate Process.run', () async {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
final tc2 = Toolchain();
|
||||
tc2.applyResolved(paths);
|
||||
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc2.gitEnv);
|
||||
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
// ptyc stdin/stdout test — send a valid request, verify JSON response
|
||||
await _testAsync('ptyc spawn echo', () async {
|
||||
final proc = await Process.start(tc.ptyc, []);
|
||||
// Send a request for /bin/echo — simplest possible child
|
||||
proc.stdin.write('{"argv":["/bin/echo","hello"],"cwd":"/tmp","env":{},"cols":80,"rows":24}');
|
||||
await proc.stdin.close();
|
||||
final stdout = await proc.stdout.transform(const SystemEncoding().decoder).join();
|
||||
final exitCode = await proc.exitCode;
|
||||
return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}';
|
||||
});
|
||||
|
||||
print('[testmode]');
|
||||
}
|
||||
|
||||
// -- ipc category ---------------------------------------------------------
|
||||
@@ -260,6 +325,162 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
print('[testmode]');
|
||||
}
|
||||
|
||||
// -- terminal category ----------------------------------------------------
|
||||
|
||||
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
||||
print('[testmode] --- terminal ---');
|
||||
|
||||
// Test PTY via InProcessClient — same path as the real app.
|
||||
await _testAsync('pane.spawn via IPC', () async {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final bus = DaemonBus();
|
||||
final eventSink = _TestEventSink(bus);
|
||||
final workDir2 = Directory(workDir);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher);
|
||||
|
||||
// Spawn a pane running /bin/echo.
|
||||
// Use interactive shell — fast-exiting commands lose output on macOS
|
||||
// because the slave closes before we can read the master.
|
||||
final spawnResp = await ipc.request('pane.spawn', args: {
|
||||
'argv': [tc.shell],
|
||||
'kind': 'terminal',
|
||||
});
|
||||
print('[testmode] spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||
if (!spawnResp.ok) {
|
||||
return 'spawn failed: ${spawnResp.error?.message}';
|
||||
}
|
||||
final paneId = spawnResp.data['id'] as String;
|
||||
|
||||
// Collect pane.output events.
|
||||
final outputParts = <String>[];
|
||||
int eventCount = 0;
|
||||
final sub = bus.on<DaemonEvent>().listen((e) {
|
||||
eventCount++;
|
||||
if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) {
|
||||
final b64 = e.data['bytes_b64'] as String?;
|
||||
if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
print('[testmode] events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}');
|
||||
if (outputParts.isNotEmpty) {
|
||||
print('[testmode] first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}');
|
||||
}
|
||||
await sub.cancel();
|
||||
paneRegistry.shutdown();
|
||||
|
||||
final output = outputParts.join();
|
||||
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
|
||||
});
|
||||
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS?
|
||||
await _testAsync('fd inheritance check', () async {
|
||||
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||
final parent = sv[0];
|
||||
final child = sv[1];
|
||||
pkg_ffi.calloc.free(sv);
|
||||
final proc = await Process.start('/tmp/checkfd', [],
|
||||
environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||
final exit = await proc.exitCode;
|
||||
libc.close(parent);
|
||||
libc.close(child);
|
||||
return 'exit=$exit stderr=${stderr.trim()}';
|
||||
});
|
||||
|
||||
// Direct PtySession test — bypasses IPC, tests fd transfer + reader.
|
||||
await _testAsync('PtySession.spawn direct', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
print('[testmode] session pid=${session.pid} masterFd exists');
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
print('[testmode] got ${chunk.length} bytes');
|
||||
},
|
||||
onDone: () {
|
||||
print('[testmode] stream done');
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (e) => print('[testmode] stream error: $e'),
|
||||
);
|
||||
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||
print('[testmode] timeout waiting for output, got ${bytes.length} bytes so far');
|
||||
});
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('DIRECT_PTY_TEST');
|
||||
return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}';
|
||||
});
|
||||
|
||||
if (!Platform.isMacOS) {
|
||||
// Additional direct PtySession tests (Linux only — no merged thread).
|
||||
|
||||
// Test 1: spawn /bin/echo via PtySession, read output
|
||||
await _testAsync('pty spawn echo', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_PTY_TEST_OK');
|
||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
|
||||
// Test 2: spawn shell, write a command, verify output
|
||||
await _testAsync('pty spawn shell', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_SHELL_TEST');
|
||||
return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
|
||||
// Test 3: spawn interactive shell, write to stdin, verify file creation
|
||||
await _testAsync('pty write to child', () async {
|
||||
final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
session.write(utf8.encode('touch $marker && exit\n'));
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final fileCreated = File(marker).existsSync();
|
||||
if (fileCreated) File(marker).deleteSync();
|
||||
return fileCreated ? 'file created + cleaned up' : 'file not created';
|
||||
});
|
||||
} // end !Platform.isMacOS
|
||||
|
||||
print('[testmode]');
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
void _log(String key, String value) {
|
||||
@@ -279,6 +500,17 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
setState(() => _results.add(r));
|
||||
}
|
||||
|
||||
Future<void> _testAsync(String label, Future<String> Function() fn) async {
|
||||
try {
|
||||
final result = await fn().timeout(const Duration(seconds: 10));
|
||||
_addResult(label, true, result);
|
||||
} on TimeoutException {
|
||||
_addResult(label, false, 'TIMEOUT (10s)');
|
||||
} catch (e) {
|
||||
_addResult(label, false, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testExec(String label, String bin, List<String> args, String workDir, {Map<String, String>? env}) async {
|
||||
try {
|
||||
final r = await Process.run(bin, args, workingDirectory: workDir, environment: env)
|
||||
@@ -341,6 +573,21 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
}
|
||||
|
||||
class _TestEventSink implements DaemonEventSink {
|
||||
_TestEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_bus.emit(DaemonEvent(
|
||||
subsystem: event.subsystem,
|
||||
kind: event.kind,
|
||||
data: event.data,
|
||||
ts: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _TestResult {
|
||||
const _TestResult({required this.name, required this.detail, required this.ok, required this.output});
|
||||
final String name;
|
||||
|
||||
@@ -54,7 +54,7 @@ class ClidePtyView extends StatelessWidget {
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundOpacity: 1,
|
||||
cursorType: TerminalCursorType.block,
|
||||
),
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
DEVELOPMENT_TEAM = 54XXM3ZQTX;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
@@ -601,7 +602,8 @@
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
DEVELOPMENT_TEAM = 54XXM3ZQTX;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
@@ -657,7 +659,8 @@
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
DEVELOPMENT_TEAM = 54XXM3ZQTX;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
</menu>
|
||||
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<rect key="contentRect" x="335" y="390" width="1280" height="720"/>
|
||||
<rect key="contentRect" x="200" y="200" width="1600" height="900"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1280" height="720"/>
|
||||
|
||||
@@ -2,30 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.sbpl</key>
|
||||
<array>
|
||||
<string>(allow process-exec* (literal "/bin/zsh"))</string>
|
||||
<string>(allow process-exec* (literal "/usr/bin/which"))</string>
|
||||
<string>(allow process-exec* (subpath "/Users/jeroenschweitzer/.local/bin"))</string>
|
||||
<string>(allow process-exec* (subpath "/Users/jeroenschweitzer/Projects/clide/ptyc/bin"))</string>
|
||||
<string>(allow process-exec* (subpath "/Users/jeroenschweitzer/Projects/clide/native/dugite"))</string>
|
||||
<string>(allow process-fork)</string>
|
||||
<string>(allow file-read* file-write* (subpath "/Users/jeroenschweitzer/Projects"))</string>
|
||||
<string>(allow file-read* file-write* (subpath "/Users/jeroenschweitzer/projects"))</string>
|
||||
<string>(allow file-read* (subpath "/opt/homebrew"))</string>
|
||||
<string>(allow file-read* (subpath "/Users/jeroenschweitzer/.local"))</string>
|
||||
<string>(allow file-read* (subpath "/Users/jeroenschweitzer/.pql"))</string>
|
||||
<string>(allow file-read* file-write* (subpath "/private/tmp"))</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -28,7 +28,7 @@ void main() {
|
||||
final sink = RecordingEventSink();
|
||||
registry = PaneRegistry(events: sink);
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
||||
registerPaneCommands(dispatcher, registry);
|
||||
});
|
||||
|
||||
tearDown(() => registry.shutdown());
|
||||
|
||||
@@ -35,7 +35,6 @@ void main() {
|
||||
final pane = await registry.spawn(
|
||||
kind: PaneKind.terminal,
|
||||
argv: const ['/bin/echo', 'hi'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
|
||||
expect(pane.id, startsWith('p_'));
|
||||
@@ -50,7 +49,6 @@ void main() {
|
||||
await registry.spawn(
|
||||
kind: PaneKind.terminal,
|
||||
argv: const ['/bin/echo', 'hello-panes'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
|
||||
// /bin/echo closes its pty quickly. Wait briefly for output +
|
||||
@@ -73,7 +71,6 @@ void main() {
|
||||
final pane = await registry.spawn(
|
||||
kind: PaneKind.terminal,
|
||||
argv: const ['/bin/cat'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
|
||||
final writeCount = registry.write(pane.id, utf8.encode('abc'));
|
||||
@@ -90,7 +87,6 @@ void main() {
|
||||
final pane = await registry.spawn(
|
||||
kind: PaneKind.terminal,
|
||||
argv: const ['/bin/cat'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
|
||||
await registry.close(pane.id);
|
||||
@@ -109,7 +105,6 @@ void main() {
|
||||
final pane = await registry.spawn(
|
||||
kind: PaneKind.claude,
|
||||
argv: const ['/bin/sh', '-c', 'exit 0'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
expect(pane.kind, PaneKind.claude);
|
||||
expect(pane.toJson()['kind'], 'claude');
|
||||
|
||||
+61
-81
@@ -1,114 +1,94 @@
|
||||
/// `PtySession` smoke tests.
|
||||
/// NativePty smoke tests.
|
||||
///
|
||||
/// Exercises the real `ptyc` binary end-to-end: socketpair → spawn →
|
||||
/// SCM_RIGHTS fd receive → child output through the reader isolate.
|
||||
/// Linux + macOS only; skipped elsewhere.
|
||||
/// Exercises forkpty() end-to-end: spawn → child output through the
|
||||
/// reader isolate. Linux + macOS only; skipped elsewhere.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/pty/pty.dart';
|
||||
import 'package:clide/src/pty/native_pty.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
if (!Platform.isLinux && !Platform.isMacOS) {
|
||||
return; // POSIX-only wrapper for now.
|
||||
}
|
||||
if (!Platform.isLinux && !Platform.isMacOS) return;
|
||||
|
||||
final ptycPath = _resolvePtyc();
|
||||
final shell = Platform.environment['SHELL'] ?? '/bin/zsh';
|
||||
|
||||
group('PtySession', () {
|
||||
test('spawns /bin/echo and reads its output', () async {
|
||||
final s = await PtySession.spawn(
|
||||
argv: const ['/bin/echo', 'hello-pty'],
|
||||
ptycPath: ptycPath,
|
||||
group('NativePty', () {
|
||||
test('spawns shell -c echo and reads output', () async {
|
||||
final s = NativePty.start(
|
||||
executable: shell,
|
||||
arguments: ['-l', '-c', 'echo hello-pty'],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
'TERM': 'xterm-256color',
|
||||
},
|
||||
);
|
||||
addTearDown(s.close);
|
||||
|
||||
final buf = StringBuffer();
|
||||
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
|
||||
try {
|
||||
// echo exits quickly; give the reader up to 2s to see its
|
||||
// output before we assert.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
for (var i = 0; i < 20 && !buf.toString().contains('hello-pty'); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true)));
|
||||
|
||||
// Shell exits quickly; give reader up to 3s.
|
||||
for (var i = 0; i < 30 && !buf.toString().contains('hello-pty'); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(buf.toString(), contains('hello-pty'));
|
||||
expect(s.pid, greaterThan(0));
|
||||
});
|
||||
|
||||
test('write round-trips through /bin/cat', () async {
|
||||
final s = await PtySession.spawn(
|
||||
argv: const ['/bin/cat'],
|
||||
ptycPath: ptycPath,
|
||||
);
|
||||
addTearDown(s.close);
|
||||
|
||||
final got = Completer<String>();
|
||||
final buf = StringBuffer();
|
||||
s.output.listen((bytes) {
|
||||
buf.write(utf8.decode(bytes));
|
||||
if (buf.toString().contains('echo-me')) {
|
||||
if (!got.isCompleted) got.complete(buf.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// Give the PTY a moment to be ready.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
s.write(utf8.encode('echo-me\n'));
|
||||
|
||||
final out = await got.future.timeout(const Duration(seconds: 3));
|
||||
expect(out, contains('echo-me'));
|
||||
});
|
||||
|
||||
test('COLORTERM truecolor propagates to the child', () async {
|
||||
// `/usr/bin/env` prints the child's environment. We should see
|
||||
// COLORTERM=truecolor because clidePtyEnvDefaults sets it.
|
||||
final s = await PtySession.spawn(
|
||||
argv: const ['/usr/bin/env'],
|
||||
ptycPath: ptycPath,
|
||||
test('write sends keystrokes to child', () async {
|
||||
final s = NativePty.start(
|
||||
executable: shell,
|
||||
arguments: ['-l'],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
'TERM': 'xterm-256color',
|
||||
},
|
||||
);
|
||||
addTearDown(s.close);
|
||||
|
||||
final buf = StringBuffer();
|
||||
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
|
||||
try {
|
||||
for (var i = 0; i < 20; i++) {
|
||||
if (buf.toString().contains('COLORTERM=truecolor')) break;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true)));
|
||||
|
||||
// Wait for prompt.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Type a command.
|
||||
s.write(utf8.encode('echo write-test-ok\n'));
|
||||
|
||||
for (var i = 0; i < 30 && !buf.toString().contains('write-test-ok'); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
expect(buf.toString(), contains('COLORTERM=truecolor'));
|
||||
expect(buf.toString(), contains('TERM=xterm-256color'));
|
||||
expect(buf.toString(), contains('write-test-ok'));
|
||||
});
|
||||
|
||||
test('close is idempotent and stops the stream', () async {
|
||||
final s = await PtySession.spawn(
|
||||
argv: const ['/bin/cat'],
|
||||
ptycPath: ptycPath,
|
||||
test('close kills child and closes output', () async {
|
||||
final s = NativePty.start(
|
||||
executable: shell,
|
||||
arguments: ['-l'],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
'TERM': 'xterm-256color',
|
||||
},
|
||||
);
|
||||
expect(s.isClosed, isFalse);
|
||||
|
||||
final done = Completer<void>();
|
||||
s.output.listen((_) {}, onDone: () => done.complete());
|
||||
|
||||
await s.close();
|
||||
await done.future.timeout(const Duration(seconds: 3));
|
||||
expect(s.isClosed, isTrue);
|
||||
await s.close(); // second call should not throw
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Locate the `ptyc` binary relative to the repo root, falling back to
|
||||
/// PATH. Lets tests run in fresh clones before anyone's touched PATH.
|
||||
String _resolvePtyc() {
|
||||
final devPath = File('ptyc/bin/ptyc');
|
||||
if (devPath.existsSync()) return devPath.absolute.path;
|
||||
return 'ptyc';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user