diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95511d70f7..6770c6a9c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -141,6 +141,7 @@ For live updates on Fresh, [follow me on X](https://x.com/TheNoamLewis).
> If a new mouse behavior gets in your way, disable it in the **Settings UI** (`Ctrl+P` → **Open Settings**) - see Terminal > "Mouse Drag Selects" and Terminal > "Mouse Forwarding".
+* **Debug Adapter Protocol (DAP)** - launch VS Code-compatible debug configurations, toggle persistent breakpoints, and continue, pause, or step from command-palette actions; stopped sessions navigate to and mark the active stack frame (#988, by @asukaminato0721).
* **Indent rainbow** - color indentation guides by indent level via Editor > "Rainbow Indentation" and a six-color `indent_rainbow_1`-`indent_rainbow_6` theme palette; also fixes a literal `{level}` placeholder leaking into translated locale strings (#2632, requested by @akarinotomoshibi, by @asukaminato0721).
* **Virtual space** - the cursor can move past a line's end, like Visual Studio or Vim's `virtualedit`. Enable with Editor > "Virtual Space" (`on` or `block`), toggle per buffer via **Toggle Virtual Space (Current Buffer)**.
* **Theme text attributes** - syntax colors can now carry `bold`/`italic`/`underlined`/`dim`/`reversed` modifiers (#2638, by @asukaminato0721).
diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs
index ff9b19b76f..211211b27f 100644
--- a/crates/fresh-core/src/api.rs
+++ b/crates/fresh-core/src/api.rs
@@ -3230,6 +3230,13 @@ pub enum PluginCommand {
callback_id: JsCallbackId,
},
+ /// Write data to a long-running background process's standard input.
+ ///
+ /// This is intentionally separate from `SpawnBackgroundProcess`: protocols
+ /// such as DAP keep a child alive and exchange multiple framed messages
+ /// over its stdin/stdout pair.
+ WriteBackgroundProcess { process_id: u64, data: String },
+
/// Kill a background process by ID
KillBackgroundProcess { process_id: u64 },
diff --git a/crates/fresh-editor/plugins/README.md b/crates/fresh-editor/plugins/README.md
index 51bfa5ce16..cc09e05c22 100644
--- a/crates/fresh-editor/plugins/README.md
+++ b/crates/fresh-editor/plugins/README.md
@@ -11,6 +11,7 @@ This directory contains production-ready plugins for the editor. Plugins are wri
| `welcome.ts` | Displays welcome message on startup |
| `manual_help.ts` | Manual page and keyboard shortcuts display |
| `diagnostics_panel.ts` | LSP diagnostics panel with navigation |
+| `dap.ts` | Debug Adapter Protocol client (launch, breakpoints, stepping) |
| `search_replace.ts` | Search and replace functionality |
| `path_complete.ts` | Path completion in prompts |
@@ -70,3 +71,31 @@ For plugin development guides, see:
- **Examples:** [`examples/README.md`](examples/README.md)
- **Clangd Plugin:** [`clangd_support.md`](clangd_support.md)
+## Debug adapters
+
+The `dap.ts` plugin reads VS Code-compatible launch configurations from
+`.vscode/launch.json`. Configure the executable for each adapter type in
+`config.json`; the adapter itself must already be installed:
+
+```json
+{
+ "plugins": {
+ "dap": {
+ "settings": {
+ "adapters": [
+ {
+ "type": "python",
+ "command": "python",
+ "args": ["-m", "debugpy.adapter"]
+ }
+ ]
+ }
+ }
+ }
+}
+```
+
+Use the command palette actions beginning with `Debug:` to start or stop a
+session, toggle breakpoints, continue, pause, and step. Set
+`plugins.dap.settings.configuration` when `launch.json` contains more than one
+configuration; otherwise the first entry is used.
diff --git a/crates/fresh-editor/plugins/dap.schema.json b/crates/fresh-editor/plugins/dap.schema.json
new file mode 100644
index 0000000000..3f35f320e6
--- /dev/null
+++ b/crates/fresh-editor/plugins/dap.schema.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Debug Adapter Protocol",
+ "type": "object",
+ "properties": {
+ "launchJson": {
+ "type": "string",
+ "default": ".vscode/launch.json",
+ "description": "Workspace-relative path to a VS Code-compatible launch.json file."
+ },
+ "configuration": {
+ "type": "string",
+ "description": "Name of the launch configuration to use. The first configuration is used when omitted."
+ },
+ "adapters": {
+ "type": "array",
+ "description": "Commands used to start debug adapters, keyed by the launch configuration's type.",
+ "items": {
+ "type": "object",
+ "required": ["type", "command"],
+ "properties": {
+ "type": { "type": "string", "description": "launch.json type, for example python or lldb." },
+ "command": { "type": "string", "description": "Debug adapter executable." },
+ "args": { "type": "array", "items": { "type": "string" }, "default": [] },
+ "cwd": { "type": "string", "description": "Adapter working directory. Supports ${workspaceFolder} and ${file}." }
+ }
+ },
+ "default": []
+ }
+ }
+}
diff --git a/crates/fresh-editor/plugins/dap.ts b/crates/fresh-editor/plugins/dap.ts
new file mode 100644
index 0000000000..6e35ab0baf
--- /dev/null
+++ b/crates/fresh-editor/plugins/dap.ts
@@ -0,0 +1,428 @@
+///
+
+/**
+ * Debug Adapter Protocol client.
+ *
+ * Launch requests come from `.vscode/launch.json` (JSONC is accepted). Adapter
+ * executables are intentionally configured separately: launch.json describes
+ * the debuggee, while `plugins.dap.settings.adapters` describes how Fresh can
+ * start each adapter type.
+ */
+const editor = getEditor();
+
+interface AdapterConfig { type: string; command: string; args?: string[]; cwd?: string }
+interface DapSettings {
+ launchJson?: string;
+ configuration?: string;
+ adapters?: AdapterConfig[];
+}
+interface LaunchConfig {
+ name?: string;
+ type: string;
+ request: "launch" | "attach";
+ [key: string]: unknown;
+}
+interface DapMessage {
+ seq: number;
+ type: "request" | "response" | "event";
+ command?: string;
+ event?: string;
+ request_seq?: number;
+ success?: boolean;
+ message?: string;
+ body?: any;
+ arguments?: any;
+}
+interface DuplexProcess extends ProcessHandle {
+ readonly processId: number;
+ write(data: string): boolean;
+}
+interface Session {
+ process: DuplexProcess;
+ nextSeq: number;
+ pending: Map void; reject: (error: Error) => void }>;
+ wire: string;
+ initializedEvent: boolean;
+ launchSent: boolean;
+ configured: boolean;
+ capabilities: any;
+ stoppedThread: number | null;
+}
+
+const BREAKPOINT_NS = "dap-breakpoints";
+const EXECUTION_NS = "dap-execution";
+const settings = (editor.getPluginConfig() || {}) as DapSettings;
+let session: Session | null = null;
+let stoppedLocation: { path: string; line: number } | null = null;
+
+function loadBreakpoints(): Record {
+ const value = editor.getGlobalState("breakpoints") as Record | null;
+ return value && typeof value === "object" ? value : {};
+}
+let breakpoints = loadBreakpoints(); // paths -> one-based DAP lines
+
+function persistBreakpoints(): void {
+ editor.setGlobalState("breakpoints", breakpoints);
+}
+
+function utf8Length(value: string): number {
+ let n = 0;
+ for (const c of value) {
+ const cp = c.codePointAt(0)!;
+ n += cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
+ }
+ return n;
+}
+
+/** Return a UTF-16 substring containing exactly `bytes` UTF-8 bytes. */
+function takeUtf8(value: string, bytes: number): { text: string; units: number } | null {
+ let used = 0;
+ let units = 0;
+ for (const c of value) {
+ const cp = c.codePointAt(0)!;
+ const width = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
+ if (used + width > bytes) return null;
+ used += width;
+ units += c.length;
+ if (used === bytes) return { text: value.slice(0, units), units };
+ }
+ return bytes === 0 ? { text: "", units: 0 } : null;
+}
+
+function send(message: Omit): number {
+ if (!session) throw new Error("No active debug session");
+ const seq = session.nextSeq++;
+ const json = JSON.stringify({ seq, ...message });
+ const frame = `Content-Length: ${utf8Length(json)}\r\n\r\n${json}`;
+ if (!session.process.write(frame)) throw new Error("Debug adapter stdin is closed");
+ return seq;
+}
+
+function request(command: string, args: any = {}): Promise {
+ if (!session) return Promise.reject(new Error("No active debug session"));
+ const current = session;
+ const seq = send({ type: "request", command, arguments: args });
+ return new Promise((resolve, reject) => current.pending.set(seq, { resolve, reject }));
+}
+
+function acceptOutput(data: string): void {
+ if (!session) return;
+ session.wire += data;
+ while (session) {
+ const headerAt = session.wire.search(/Content-Length\s*:/i);
+ if (headerAt < 0) {
+ // Retain a short suffix in case the header itself was split.
+ if (session.wire.length > 64) session.wire = session.wire.slice(-64);
+ return;
+ }
+ if (headerAt > 0) session.wire = session.wire.slice(headerAt);
+ const separator = session.wire.indexOf("\r\n\r\n");
+ const altSeparator = session.wire.indexOf("\n\n");
+ const headerEnd = separator >= 0 ? separator : altSeparator;
+ if (headerEnd < 0) return;
+ const separatorLength = separator >= 0 ? 4 : 2;
+ const header = session.wire.slice(0, headerEnd);
+ const match = /Content-Length\s*:\s*(\d+)/i.exec(header);
+ if (!match) {
+ session.wire = session.wire.slice(headerEnd + separatorLength);
+ continue;
+ }
+ const payloadStart = headerEnd + separatorLength;
+ const payload = takeUtf8(session.wire.slice(payloadStart), Number(match[1]));
+ if (!payload) return;
+ session.wire = session.wire.slice(payloadStart + payload.units);
+ try {
+ handleMessage(JSON.parse(payload.text) as DapMessage);
+ } catch (error) {
+ editor.debug(`[dap] invalid adapter message: ${String(error)}`);
+ }
+ }
+}
+
+function handleMessage(message: DapMessage): void {
+ if (!session) return;
+ if (message.type === "response") {
+ const pending = session.pending.get(message.request_seq!);
+ if (!pending) return;
+ session.pending.delete(message.request_seq!);
+ if (message.success === false) pending.reject(new Error(message.message || "DAP request failed"));
+ else pending.resolve(message.body);
+ return;
+ }
+ if (message.type === "event") void handleEvent(message.event || "", message.body || {});
+ if (message.type === "request") {
+ // Adapters may ask the client to run something in a terminal. Fresh does
+ // not silently execute those requests; answer explicitly so they do not
+ // wait forever and can fall back to their internal console.
+ send({
+ type: "response",
+ request_seq: message.seq,
+ command: message.command,
+ success: false,
+ message: `Client request '${message.command}' is not supported`,
+ });
+ }
+}
+
+async function handleEvent(event: string, body: any): Promise {
+ if (!session) return;
+ switch (event) {
+ case "initialized":
+ session.initializedEvent = true;
+ await configureSession();
+ break;
+ case "stopped":
+ session.stoppedThread = Number(body.threadId);
+ editor.setStatus(`Debug paused${body.description ? `: ${body.description}` : ""}`);
+ await revealTopFrame(session.stoppedThread);
+ break;
+ case "continued":
+ stoppedLocation = null;
+ clearExecutionIndicator();
+ editor.setStatus("Debug running");
+ break;
+ case "capabilities":
+ session.capabilities = { ...session.capabilities, ...(body.capabilities || {}) };
+ break;
+ case "output":
+ if (body.output) editor.debug(`[dap:${body.category || "output"}] ${String(body.output).trimEnd()}`);
+ break;
+ case "terminated":
+ case "exited":
+ endSession(event === "exited" && body.exitCode != null ? `Debuggee exited (${body.exitCode})` : "Debug session ended");
+ break;
+ }
+}
+
+async function revealTopFrame(threadId: number): Promise {
+ try {
+ const body = await request("stackTrace", { threadId, startFrame: 0, levels: 1 });
+ const frame = body?.stackFrames?.[0];
+ if (!frame?.source?.path || !frame?.line) return;
+ stoppedLocation = { path: String(frame.source.path), line: Number(frame.line) };
+ editor.openFile(stoppedLocation.path, stoppedLocation.line, frame.column || 1);
+ // `openFile` does not emit `buffer_activated` when the frame is already in
+ // the active buffer, so render immediately as well as from the hook below.
+ renderIndicators();
+ editor.setStatus(`Paused at ${frame.name || "frame"} — ${stoppedLocation.path}:${stoppedLocation.line}`);
+ } catch (error) {
+ editor.setStatus(`Could not load stack frame: ${String(error)}`);
+ }
+}
+
+function renderIndicators(bufferId = editor.getActiveBufferId()): void {
+ const path = editor.getBufferPath(bufferId);
+ editor.clearLineIndicators(bufferId, BREAKPOINT_NS);
+ const lines = breakpoints[path] || [];
+ if (lines.length) editor.setLineIndicators(bufferId, lines.map((line) => line - 1), BREAKPOINT_NS, "●", 255, 85, 85, 50);
+ editor.clearLineIndicators(bufferId, EXECUTION_NS);
+ if (stoppedLocation?.path === path) {
+ editor.setLineIndicator(bufferId, stoppedLocation.line - 1, EXECUTION_NS, "▶", 80, 250, 123, 100);
+ }
+}
+
+function clearExecutionIndicator(): void {
+ const id = editor.getActiveBufferId();
+ editor.clearLineIndicators(id, EXECUTION_NS);
+}
+
+async function syncBreakpoints(path: string): Promise {
+ if (!session) return;
+ const lines = breakpoints[path] || [];
+ await request("setBreakpoints", {
+ source: { path, name: path.replace(/\\/g, "/").split("/").pop() },
+ breakpoints: lines.map((line) => ({ line })),
+ sourceModified: false,
+ });
+}
+
+async function configureSession(): Promise {
+ if (!session || session.configured || !session.initializedEvent || !session.launchSent) return;
+ session.configured = true;
+ try {
+ for (const path of Object.keys(breakpoints)) await syncBreakpoints(path);
+ if (session.capabilities?.supportsConfigurationDoneRequest === true) {
+ await request("configurationDone", {});
+ }
+ } catch (error) {
+ editor.setStatus(`Debug configuration failed: ${String(error)}`);
+ }
+}
+
+function expand(value: any, variables: Record): any {
+ if (typeof value === "string") {
+ return value.replace(/\$\{(file|workspaceFolder)\}/g, (_, key: string) => variables[key] || "");
+ }
+ if (Array.isArray(value)) return value.map((item) => expand(item, variables));
+ if (value && typeof value === "object") {
+ const out: Record = {};
+ for (const [key, item] of Object.entries(value)) out[key] = expand(item, variables);
+ return out;
+ }
+ return value;
+}
+
+function readLaunchConfiguration(): { adapter: AdapterConfig; launch: LaunchConfig; root: string } {
+ const root = editor.getCwd();
+ const launchPath = settings.launchJson || ".vscode/launch.json";
+ const absolute = /^(?:[A-Za-z]:[\\/]|\/)/.test(launchPath)
+ ? launchPath
+ : `${root.replace(/[\\/]$/, "")}/${launchPath}`;
+ const text = editor.readFile(absolute);
+ if (!text) throw new Error(`No launch configuration at ${absolute}`);
+ const document = editor.parseJsonc(text) as { configurations?: LaunchConfig[] };
+ const configs = document?.configurations || [];
+ const launch = (settings.configuration
+ ? configs.find((item) => item.name === settings.configuration)
+ : configs[0]);
+ if (!launch) throw new Error("launch.json has no matching configuration");
+ const adapter = (settings.adapters || []).find((item) => item.type === launch.type);
+ if (!adapter?.command) throw new Error(`No DAP adapter command configured for type '${launch.type}'`);
+ const file = editor.getBufferPath(editor.getActiveBufferId());
+ return {
+ adapter: expand(adapter, { file, workspaceFolder: root }),
+ launch: expand(launch, { file, workspaceFolder: root }),
+ root,
+ };
+}
+
+async function startDebugging(): Promise {
+ if (session) {
+ editor.setStatus("A debug session is already active");
+ return;
+ }
+ try {
+ const { adapter, launch, root } = readLaunchConfiguration();
+ const process = editor.spawnBackgroundProcess(
+ adapter.command,
+ adapter.args || [],
+ adapter.cwd || root,
+ ) as DuplexProcess;
+ if (typeof process.processId !== "number" || typeof process.write !== "function") {
+ throw new Error("This Fresh build does not provide duplex background processes");
+ }
+ session = {
+ process,
+ nextSeq: 1,
+ pending: new Map(),
+ wire: "",
+ initializedEvent: false,
+ launchSent: false,
+ configured: false,
+ capabilities: {},
+ stoppedThread: null,
+ };
+ process.result.then((result) => {
+ if (session?.process === process) endSession(`Debug adapter exited (${result.exit_code})`);
+ });
+
+ session.capabilities = await request("initialize", {
+ clientID: "fresh",
+ clientName: "Fresh Editor",
+ adapterID: launch.type,
+ pathFormat: "path",
+ linesStartAt1: true,
+ columnsStartAt1: true,
+ supportsVariableType: true,
+ supportsRunInTerminalRequest: false,
+ });
+ const args: Record = { ...launch };
+ delete args.name;
+ delete args.type;
+ delete args.request;
+ const launchResponse = request(launch.request, args);
+ session.launchSent = true;
+ await configureSession();
+ await launchResponse;
+ editor.setStatus(`Debugging: ${launch.name || launch.type}`);
+ } catch (error) {
+ endSession(`Could not start debugger: ${String(error)}`);
+ }
+}
+
+function endSession(status: string): void {
+ const old = session;
+ session = null;
+ stoppedLocation = null;
+ if (old) {
+ for (const pending of old.pending.values()) pending.reject(new Error(status));
+ old.pending.clear();
+ old.process.kill();
+ }
+ clearExecutionIndicator();
+ editor.setStatus(status);
+}
+
+async function stopDebugging(): Promise {
+ if (!session) {
+ editor.setStatus("No active debug session");
+ return;
+ }
+ try { await request("disconnect", { restart: false, terminateDebuggee: true }); } catch (_) { /* adapter exited */ }
+ endSession("Debug session stopped");
+}
+
+async function toggleBreakpoint(): Promise {
+ const bufferId = editor.getActiveBufferId();
+ const path = editor.getBufferPath(bufferId);
+ const cursor = editor.getPrimaryCursor();
+ if (!path || cursor?.line == null) {
+ editor.setStatus("Breakpoints require a file-backed buffer");
+ return;
+ }
+ const line = cursor.line + 1;
+ const lines = breakpoints[path] || [];
+ breakpoints[path] = lines.includes(line) ? lines.filter((item) => item !== line) : [...lines, line].sort((a, b) => a - b);
+ if (!breakpoints[path].length) delete breakpoints[path];
+ persistBreakpoints();
+ renderIndicators(bufferId);
+ try { await syncBreakpoints(path); } catch (error) { editor.setStatus(`Could not update breakpoints: ${String(error)}`); }
+}
+
+async function control(command: string): Promise {
+ if (!session) {
+ editor.setStatus("No active debug session");
+ return;
+ }
+ try {
+ let threadId = session.stoppedThread;
+ if (command === "pause" && threadId == null) {
+ const body = await request("threads", {});
+ threadId = body?.threads?.[0]?.id ?? null;
+ }
+ if (threadId == null) throw new Error("The adapter has not reported a thread");
+ const args = command === "pause" ? { threadId } : { threadId, singleThread: false };
+ await request(command, args);
+ } catch (error) {
+ editor.setStatus(`Debug ${command} failed: ${String(error)}`);
+ }
+}
+
+registerHandler("dap_start", startDebugging);
+registerHandler("dap_stop", stopDebugging);
+registerHandler("dap_toggle_breakpoint", toggleBreakpoint);
+registerHandler("dap_continue", () => control("continue"));
+registerHandler("dap_next", () => control("next"));
+registerHandler("dap_step_in", () => control("stepIn"));
+registerHandler("dap_step_out", () => control("stepOut"));
+registerHandler("dap_pause", () => control("pause"));
+
+editor.registerCommand("Debug: Start", "Start the configured debug adapter", "dap_start", null);
+editor.registerCommand("Debug: Stop", "Terminate the active debug session", "dap_stop", null);
+editor.registerCommand("Debug: Toggle Breakpoint", "Toggle a breakpoint on the current line", "dap_toggle_breakpoint", null);
+editor.registerCommand("Debug: Continue", "Continue execution", "dap_continue", null);
+editor.registerCommand("Debug: Step Over", "Step over the current line", "dap_next", null);
+editor.registerCommand("Debug: Step Into", "Step into the current call", "dap_step_in", null);
+editor.registerCommand("Debug: Step Out", "Step out of the current call", "dap_step_out", null);
+editor.registerCommand("Debug: Pause", "Pause the debuggee", "dap_pause", null);
+
+(editor.on as any)("onProcessStdout", (args: { process_id: number; data: string }) => {
+ if (session?.process.processId === args.process_id) acceptOutput(args.data);
+});
+(editor.on as any)("onProcessStderr", (args: { process_id: number; data: string }) => {
+ if (session?.process.processId === args.process_id) editor.debug(`[dap:stderr] ${args.data.trimEnd()}`);
+});
+editor.on("buffer_activated", (args) => { renderIndicators(args.buffer_id); });
+editor.on("after_file_save", (args) => { renderIndicators(args.buffer_id); });
+
+renderIndicators();
diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts
index 1eab3ab178..612ce68b32 100644
--- a/crates/fresh-editor/plugins/lib/fresh.d.ts
+++ b/crates/fresh-editor/plugins/lib/fresh.d.ts
@@ -37,6 +37,10 @@ declare function registerHandler(name: string, fn: Function): void;
interface ProcessHandle extends PromiseLike {
/** Promise that resolves to the result when complete */
readonly result: Promise;
+ /** Immediate process id for long-running background processes. */
+ readonly processId?: number;
+ /** Write UTF-8 data to a long-running background process. */
+ write?(data: string): boolean;
/** Cancel/kill the operation. Returns true if cancelled, false if already completed */
kill(): Promise;
}
@@ -4475,6 +4479,8 @@ interface EditorAPI {
* Spawn a background process (async, returns request_id which is also process_id)
*/
spawnBackgroundProcess(command: string, args: string[], cwd?: string): ProcessHandle;
+ /** Write UTF-8 data to a running background process's stdin. */
+ writeBackgroundProcess(processId: number, data: string): boolean;
/**
* Kill a background process
*/
diff --git a/crates/fresh-editor/plugins/tsconfig.json b/crates/fresh-editor/plugins/tsconfig.json
index 87f74d21ba..8989137279 100644
--- a/crates/fresh-editor/plugins/tsconfig.json
+++ b/crates/fresh-editor/plugins/tsconfig.json
@@ -33,6 +33,7 @@
"code-tour.ts",
"csharp_support.ts",
"css-lsp.ts",
+ "dap.ts",
"dart-lsp.ts",
"dashboard.ts",
"devcontainer.ts",
diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs
index 82a7b2ef78..b4ab1aeffb 100644
--- a/crates/fresh-editor/src/app/mod.rs
+++ b/crates/fresh-editor/src/app/mod.rs
@@ -876,9 +876,8 @@ pub struct Editor {
// grouped_subtrees moved onto `Window` — each window owns its
// own buffer-group subtrees (a window with a Live Grep panel
// open doesn't share the panel state with sibling windows).
- /// Background process abort handles for cancellation
- /// Maps process_id to abort handle
- background_process_handles: HashMap,
+ /// Duplex handles for long-running plugin processes.
+ background_process_handles: HashMap,
/// Cancellation senders for host-side processes spawned via
/// `spawnHostProcess`. Firing the sender (or dropping it) triggers
@@ -1251,6 +1250,13 @@ pub(crate) struct WidgetTextDrag {
pub anchor_flat: usize,
}
+/// Control plane for a long-running plugin child process.
+struct BackgroundProcessHandle {
+ abort: tokio::task::AbortHandle,
+ stdin: tokio::sync::mpsc::UnboundedSender>,
+ callback_id: u64,
+}
+
/// Sentinel `BufferId` registered with the widget registry for the
/// floating panel — never appears in the editor's buffer table, so
/// `set_virtual_buffer_content` against it would fail. The mount /
diff --git a/crates/fresh-editor/src/app/plugin_dispatch.rs b/crates/fresh-editor/src/app/plugin_dispatch.rs
index b0c1707ab2..6ba3fd95c2 100644
--- a/crates/fresh-editor/src/app/plugin_dispatch.rs
+++ b/crates/fresh-editor/src/app/plugin_dispatch.rs
@@ -24,6 +24,125 @@ use crate::view::split::SplitViewState;
use super::window::Window;
use super::{Editor, FloatingWidgetState};
+/// Forward a child stream without imposing line boundaries.
+///
+/// DAP/LSP-style protocols use `Content-Length` byte framing, so `lines()` is
+/// not a valid transport: it rewrites newlines and withholds partial frames.
+/// Keep incomplete UTF-8 tails between reads to avoid replacing a code point
+/// when the OS splits it across two pipe reads.
+async fn forward_background_stream(
+ mut stream: R,
+ sender: std::sync::mpsc::Sender,
+ process_id: u64,
+ stderr: bool,
+) where
+ R: tokio::io::AsyncRead + Unpin,
+{
+ use tokio::io::AsyncReadExt;
+
+ let mut read_buf = [0_u8; 4096];
+ let mut pending = Vec::new();
+ loop {
+ match stream.read(&mut read_buf).await {
+ Ok(0) => break,
+ Ok(n) => pending.extend_from_slice(&read_buf[..n]),
+ Err(error) => {
+ tracing::debug!(process_id, %error, "background process stream read failed");
+ break;
+ }
+ }
+
+ loop {
+ match std::str::from_utf8(&pending) {
+ Ok(text) => {
+ if !text.is_empty() {
+ send_background_chunk(&sender, process_id, stderr, text.to_owned());
+ }
+ pending.clear();
+ break;
+ }
+ Err(error) => {
+ let valid = error.valid_up_to();
+ if valid > 0 {
+ let text = String::from_utf8_lossy(&pending[..valid]).into_owned();
+ send_background_chunk(&sender, process_id, stderr, text);
+ pending.drain(..valid);
+ }
+ if let Some(invalid_len) = error.error_len() {
+ send_background_chunk(&sender, process_id, stderr, "�".to_string());
+ pending.drain(..invalid_len);
+ continue;
+ }
+ // A valid code point was split across pipe reads.
+ break;
+ }
+ }
+ }
+ }
+
+ if !pending.is_empty() {
+ send_background_chunk(
+ &sender,
+ process_id,
+ stderr,
+ String::from_utf8_lossy(&pending).into_owned(),
+ );
+ }
+}
+
+fn send_background_chunk(
+ sender: &std::sync::mpsc::Sender,
+ process_id: u64,
+ stderr: bool,
+ data: String,
+) {
+ let message = if stderr {
+ fresh_core::api::PluginAsyncMessage::ProcessStderr { process_id, data }
+ } else {
+ fresh_core::api::PluginAsyncMessage::ProcessStdout { process_id, data }
+ };
+ #[allow(clippy::let_underscore_must_use)]
+ let _ = sender.send(AsyncMessage::Plugin(message));
+}
+
+#[cfg(test)]
+mod background_stream_tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn preserves_partial_frames_and_split_utf8_codepoints() {
+ use tokio::io::AsyncWriteExt;
+
+ // Capacity one forces the reader to observe small chunks, including
+ // splits inside the multi-byte characters below.
+ let (mut writer, reader) = tokio::io::duplex(1);
+ let bridge = crate::services::async_bridge::AsyncBridge::new();
+ let forwarding = tokio::spawn(forward_background_stream(reader, bridge.sender(), 7, false));
+ let bytes = "Content-Length: 8\r\n\r\n\"é🙂\"".as_bytes();
+ for byte in bytes {
+ writer.write_all(&[*byte]).await.unwrap();
+ }
+ writer.shutdown().await.unwrap();
+ forwarding.await.unwrap();
+
+ let joined: String = bridge
+ .try_recv_all()
+ .into_iter()
+ .filter_map(|message| match message {
+ AsyncMessage::Plugin(fresh_core::api::PluginAsyncMessage::ProcessStdout {
+ process_id,
+ data,
+ }) => {
+ assert_eq!(process_id, 7);
+ Some(data)
+ }
+ _ => None,
+ })
+ .collect();
+ assert_eq!(joined, "Content-Length: 8\r\n\r\n\"é🙂\"");
+ }
+}
+
/// Normalize a session path for the plugin API. Sessions reach `WindowInfo`
/// from two sources — the canonicalized launch session and `create_window_at`'s
/// raw `PathBuf` — so any byte-level path field (lex sort, equality, …) in a
@@ -1281,6 +1400,10 @@ impl Editor {
self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
}
+ PluginCommand::WriteBackgroundProcess { process_id, data } => {
+ self.handle_write_background_process(process_id, data);
+ }
+
PluginCommand::KillBackgroundProcess { process_id } => {
self.handle_kill_background_process(process_id);
}
@@ -3128,7 +3251,6 @@ impl Editor {
) {
// Spawn background process with streaming output via tokio
if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
- use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as TokioCommand;
let effective_cwd = cwd.unwrap_or_else(|| {
@@ -3138,9 +3260,8 @@ impl Editor {
});
let sender = bridge.sender();
- let sender_stdout = sender.clone();
- let sender_stderr = sender.clone();
let callback_id_u64 = callback_id.as_u64();
+ let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::unbounded_channel::>();
// Receiver may be dropped if editor is shutting down
#[allow(clippy::let_underscore_must_use)]
@@ -3149,8 +3270,12 @@ impl Editor {
let mut child = match TokioCommand::new(&command)
.args(&args)
.current_dir(&effective_cwd)
+ .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
+ // Cancelling the plugin handle must not orphan a debugger,
+ // watcher, or other long-running child.
+ .kill_on_drop(true)
.hide_window()
.spawn()
{
@@ -3168,45 +3293,40 @@ impl Editor {
}
};
- // Stream stdout
+ let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
- let pid = process_id;
- // Spawn stdout reader
- if let Some(stdout) = stdout {
- let sender = sender_stdout;
+ // Serialize writes through one task so protocol frames cannot
+ // interleave even when several plugin promises resolve at once.
+ if let Some(mut stdin) = stdin {
tokio::spawn(async move {
- let reader = BufReader::new(stdout);
- let mut lines = reader.lines();
- while let Ok(Some(line)) = lines.next_line().await {
- let _ =
- sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
- fresh_core::api::PluginAsyncMessage::ProcessStdout {
- process_id: pid,
- data: line + "\n",
- },
- ));
+ use tokio::io::AsyncWriteExt;
+ while let Some(data) = stdin_rx.recv().await {
+ if stdin.write_all(&data).await.is_err() || stdin.flush().await.is_err()
+ {
+ break;
+ }
}
});
}
- // Spawn stderr reader
+ if let Some(stdout) = stdout {
+ tokio::spawn(forward_background_stream(
+ stdout,
+ sender.clone(),
+ process_id,
+ false,
+ ));
+ }
+
if let Some(stderr) = stderr {
- let sender = sender_stderr;
- tokio::spawn(async move {
- let reader = BufReader::new(stderr);
- let mut lines = reader.lines();
- while let Ok(Some(line)) = lines.next_line().await {
- let _ =
- sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
- fresh_core::api::PluginAsyncMessage::ProcessStderr {
- process_id: pid,
- data: line + "\n",
- },
- ));
- }
- });
+ tokio::spawn(forward_background_stream(
+ stderr,
+ sender.clone(),
+ process_id,
+ true,
+ ));
}
// Wait for process to complete
@@ -3225,8 +3345,14 @@ impl Editor {
});
// Store abort handle for potential kill
- self.background_process_handles
- .insert(process_id, handle.abort_handle());
+ self.background_process_handles.insert(
+ process_id,
+ super::BackgroundProcessHandle {
+ abort: handle.abort_handle(),
+ stdin: stdin_tx,
+ callback_id: callback_id_u64,
+ },
+ );
} else {
// No runtime - reject immediately
self.plugin_manager
@@ -3236,6 +3362,16 @@ impl Editor {
}
}
+ fn handle_write_background_process(&mut self, process_id: u64, data: String) {
+ let Some(handle) = self.background_process_handles.get(&process_id) else {
+ tracing::debug!(process_id, "write ignored for exited background process");
+ return;
+ };
+ if handle.stdin.send(data.into_bytes()).is_err() {
+ tracing::debug!(process_id, "background process stdin is closed");
+ }
+ }
+
#[allow(clippy::too_many_arguments)]
fn handle_create_virtual_buffer_with_content(
&mut self,
@@ -4687,7 +4823,17 @@ impl Editor {
fn handle_kill_background_process(&mut self, process_id: u64) {
if let Some(handle) = self.background_process_handles.remove(&process_id) {
- handle.abort();
+ handle.abort.abort();
+ if let Some(bridge) = &self.async_bridge {
+ #[allow(clippy::let_underscore_must_use)]
+ let _ = bridge.sender().send(AsyncMessage::Plugin(
+ fresh_core::api::PluginAsyncMessage::ProcessExit {
+ process_id,
+ callback_id: handle.callback_id,
+ exit_code: -1,
+ },
+ ));
+ }
tracing::debug!("Killed background process {}", process_id);
}
}
diff --git a/crates/fresh-editor/tests/e2e/plugins/dap.rs b/crates/fresh-editor/tests/e2e/plugins/dap.rs
new file mode 100644
index 0000000000..77a9777417
--- /dev/null
+++ b/crates/fresh-editor/tests/e2e/plugins/dap.rs
@@ -0,0 +1,270 @@
+//! End-to-end coverage for the bundled DAP plugin.
+//!
+//! The test drives every `Debug:` command through the command palette and
+//! talks to an isolated fake adapter over the same stdin/stdout framing used
+//! in production. Assertions observe only rendered status text and gutter
+//! markers, as required by CONTRIBUTING.md §2.
+
+#![cfg(feature = "plugins")]
+
+use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness};
+use crossterm::event::{KeyCode, KeyModifiers};
+use fresh::config::{Config, PluginConfig};
+use std::fs;
+use std::path::Path;
+
+const FAKE_ADAPTER: &str = r###"#!/usr/bin/env bash
+set -eu
+
+next_seq=1
+program=""
+current_line=2
+
+send_frame() {
+ local json="$1"
+ printf 'Content-Length: %s\r\n\r\n%s' "${#json}" "$json"
+}
+
+send_response() {
+ local request_seq="$1"
+ local command="$2"
+ local body="$3"
+ local json
+ printf -v json \
+ '{"seq":%s,"type":"response","request_seq":%s,"success":true,"command":"%s","body":%s}' \
+ "$next_seq" "$request_seq" "$command" "$body"
+ next_seq=$((next_seq + 1))
+ send_frame "$json"
+}
+
+send_event() {
+ local event="$1"
+ local body="$2"
+ local json
+ printf -v json '{"seq":%s,"type":"event","event":"%s","body":%s}' \
+ "$next_seq" "$event" "$body"
+ next_seq=$((next_seq + 1))
+ send_frame "$json"
+}
+
+while true; do
+ length=""
+ while IFS= read -r header; do
+ header="${header%$'\r'}"
+ if [[ -z "$header" ]]; then
+ break
+ fi
+ if [[ "$header" =~ ^Content-Length:[[:space:]]*([0-9]+)$ ]]; then
+ length="${BASH_REMATCH[1]}"
+ fi
+ done
+ [[ -n "$length" ]] || exit 0
+
+ payload=""
+ IFS= read -r -N "$length" payload || true
+ [[ "$payload" =~ \"seq\":([0-9]+) ]] || exit 2
+ request_seq="${BASH_REMATCH[1]}"
+ [[ "$payload" =~ \"command\":\"([^\"]+)\" ]] || exit 3
+ command="${BASH_REMATCH[1]}"
+
+ case "$command" in
+ initialize)
+ send_response "$request_seq" "$command" \
+ '{"supportsConfigurationDoneRequest":true}'
+ ;;
+ launch)
+ if [[ "$payload" =~ \"program\":\"([^\"]+)\" ]]; then
+ program="${BASH_REMATCH[1]}"
+ fi
+ send_response "$request_seq" "$command" '{}'
+ send_event initialized '{}'
+ ;;
+ setBreakpoints)
+ send_response "$request_seq" "$command" \
+ '{"breakpoints":[{"verified":true,"line":1}]}'
+ ;;
+ configurationDone)
+ send_response "$request_seq" "$command" '{}'
+ send_event stopped '{"reason":"breakpoint","threadId":1}'
+ ;;
+ stackTrace)
+ body=""
+ printf -v body \
+ '{"stackFrames":[{"id":1,"name":"fake-frame","source":{"name":"debug_target.txt","path":"%s"},"line":%s,"column":1}],"totalFrames":1}' \
+ "$program" "$current_line"
+ send_response "$request_seq" "$command" "$body"
+ ;;
+ threads)
+ send_response "$request_seq" "$command" \
+ '{"threads":[{"id":1,"name":"main"}]}'
+ ;;
+ continue)
+ send_response "$request_seq" "$command" '{}'
+ send_event continued '{"threadId":1,"allThreadsContinued":true}'
+ ;;
+ pause)
+ current_line=2
+ send_response "$request_seq" "$command" '{}'
+ send_event stopped '{"reason":"pause","threadId":1}'
+ ;;
+ next)
+ current_line=3
+ send_response "$request_seq" "$command" '{}'
+ send_event stopped '{"reason":"step","threadId":1}'
+ ;;
+ stepIn)
+ current_line=2
+ send_response "$request_seq" "$command" '{}'
+ send_event stopped '{"reason":"step","threadId":1}'
+ ;;
+ stepOut)
+ current_line=3
+ send_response "$request_seq" "$command" '{}'
+ send_event stopped '{"reason":"step","threadId":1}'
+ ;;
+ disconnect)
+ send_response "$request_seq" "$command" '{}'
+ exit 0
+ ;;
+ *)
+ send_response "$request_seq" "$command" '{}'
+ ;;
+ esac
+done
+"###;
+
+fn run_palette_command(harness: &mut EditorTestHarness, command: &str) {
+ harness
+ .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
+ .unwrap();
+ harness
+ .wait_until(|h| h.screen_to_string().contains(">command"))
+ .unwrap();
+ harness.type_text(command).unwrap();
+ harness
+ .wait_until(|h| h.screen_to_string().contains(command))
+ .unwrap();
+ harness
+ .send_key(KeyCode::Enter, KeyModifiers::NONE)
+ .unwrap();
+ harness
+ .wait_until(|h| !h.screen_to_string().contains(">command"))
+ .unwrap();
+}
+
+fn wait_for_indicator(harness: &mut EditorTestHarness, marker: char, source_line: &str) {
+ harness
+ .wait_until(|h| {
+ h.screen_to_string()
+ .lines()
+ .any(|line| line.contains(marker) && line.contains(source_line))
+ })
+ .unwrap();
+}
+
+fn wait_for_no_indicator(harness: &mut EditorTestHarness, marker: char) {
+ harness
+ .wait_until(|h| !h.screen_to_string().contains(marker))
+ .unwrap();
+}
+
+fn dap_harness() -> (EditorTestHarness, tempfile::TempDir) {
+ fresh::i18n::set_locale("en");
+
+ let temp = tempfile::tempdir().unwrap();
+ let workspace = temp.path().join("work");
+ fs::create_dir_all(&workspace).unwrap();
+
+ let target = workspace.join("debug_target.txt");
+ fs::write(
+ &target,
+ "breakpoint line\npaused on second line\npaused on third line\n",
+ )
+ .unwrap();
+
+ let adapter = workspace.join("fake-dap-adapter.sh");
+ fs::write(&adapter, FAKE_ADAPTER).unwrap();
+
+ let vscode = workspace.join(".vscode");
+ fs::create_dir_all(&vscode).unwrap();
+ fs::write(
+ vscode.join("launch.json"),
+ serde_json::to_vec_pretty(&serde_json::json!({
+ "version": "0.2.0",
+ "configurations": [{
+ "name": "Fake DAP",
+ "type": "fake",
+ "request": "launch",
+ "program": target,
+ }],
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+
+ let plugins_dir = workspace.join("plugins");
+ fs::create_dir_all(&plugins_dir).unwrap();
+ copy_plugin(&plugins_dir, "dap");
+ copy_plugin_lib(&plugins_dir);
+
+ let mut config = Config::default();
+ config.plugins.insert(
+ "dap".to_string(),
+ PluginConfig {
+ enabled: true,
+ path: Some(plugins_dir.join("dap.ts")),
+ settings: serde_json::json!({
+ "adapters": [{
+ "type": "fake",
+ "command": "bash",
+ "args": [adapter],
+ }],
+ }),
+ },
+ );
+
+ let mut harness =
+ EditorTestHarness::with_config_and_working_dir(140, 36, config, workspace).unwrap();
+ harness.open_file(Path::new(&target)).unwrap();
+ harness.render().unwrap();
+ (harness, temp)
+}
+
+#[test]
+fn debug_palette_flow_renders_breakpoints_steps_and_teardown() {
+ let (mut harness, _temp) = dap_harness();
+
+ run_palette_command(&mut harness, "Debug: Toggle Breakpoint");
+ wait_for_indicator(&mut harness, '●', "breakpoint line");
+
+ run_palette_command(&mut harness, "Debug: Start");
+ harness
+ .wait_until(|h| h.screen_to_string().contains("Paused at fake-frame"))
+ .unwrap();
+ wait_for_indicator(&mut harness, '▶', "paused on second line");
+
+ run_palette_command(&mut harness, "Debug: Continue");
+ harness
+ .wait_until(|h| h.screen_to_string().contains("Debug running"))
+ .unwrap();
+ wait_for_no_indicator(&mut harness, '▶');
+
+ run_palette_command(&mut harness, "Debug: Pause");
+ wait_for_indicator(&mut harness, '▶', "paused on second line");
+
+ run_palette_command(&mut harness, "Debug: Step Over");
+ wait_for_indicator(&mut harness, '▶', "paused on third line");
+
+ run_palette_command(&mut harness, "Debug: Step Into");
+ wait_for_indicator(&mut harness, '▶', "paused on second line");
+
+ run_palette_command(&mut harness, "Debug: Step Out");
+ wait_for_indicator(&mut harness, '▶', "paused on third line");
+
+ run_palette_command(&mut harness, "Debug: Stop");
+ harness
+ .wait_until(|h| h.screen_to_string().contains("Debug session stopped"))
+ .unwrap();
+ wait_for_no_indicator(&mut harness, '▶');
+ wait_for_indicator(&mut harness, '●', "breakpoint line");
+}
diff --git a/crates/fresh-editor/tests/e2e/plugins/mod.rs b/crates/fresh-editor/tests/e2e/plugins/mod.rs
index 84f868ddd3..fdb5f9bdf4 100644
--- a/crates/fresh-editor/tests/e2e/plugins/mod.rs
+++ b/crates/fresh-editor/tests/e2e/plugins/mod.rs
@@ -9,6 +9,8 @@ pub mod buffer_info_splits;
pub mod command_keybinding_editor;
pub mod config_changed_adoption;
pub mod csharp_restore_trust;
+#[cfg(unix)]
+pub mod dap;
pub mod dashboard;
pub mod hostile_plugin;
// The three modules below drive the in-tree fake-devcontainer
diff --git a/crates/fresh-plugin-api-macros/src/lib.rs b/crates/fresh-plugin-api-macros/src/lib.rs
index eaf630d620..67a1606c8d 100644
--- a/crates/fresh-plugin-api-macros/src/lib.rs
+++ b/crates/fresh-plugin-api-macros/src/lib.rs
@@ -661,6 +661,10 @@ declare function registerHandler(name: string, fn: Function): void;
interface ProcessHandle extends PromiseLike {
/** Promise that resolves to the result when complete */
readonly result: Promise;
+ /** Immediate process id for long-running background processes. */
+ readonly processId?: number;
+ /** Write UTF-8 data to a long-running background process. */
+ write?(data: string): boolean;
/** Cancel/kill the operation. Returns true if cancelled, false if already completed */
kill(): Promise;
}
diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs
index 0516ed042e..876789be6b 100644
--- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs
+++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs
@@ -7244,6 +7244,16 @@ impl JsEditorApi {
id
}
+ /// Write UTF-8 data to a running background process's stdin.
+ ///
+ /// Returns false when the command channel is closed. A true return means
+ /// the write was queued; the process may still exit before consuming it.
+ pub fn write_background_process(&self, process_id: u64, data: String) -> bool {
+ self.command_sender
+ .send(PluginCommand::WriteBackgroundProcess { process_id, data })
+ .is_ok()
+ }
+
/// Kill a background process
pub fn kill_background_process(&self, process_id: u64) -> bool {
self.command_sender
@@ -7772,7 +7782,34 @@ const EDITOR_PROMISE_BOOTSTRAP: &str = r#"
editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
- editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
+ // Background processes are duplex protocol transports. Expose
+ // the callback id immediately so callers can route streaming
+ // output and write to stdin before the child exits.
+ editor.spawnBackgroundProcess = function(command, args, cwd) {
+ if (typeof editor._spawnBackgroundProcessStart !== 'function') {
+ throw new Error('editor.spawnBackgroundProcess is not implemented (missing _spawnBackgroundProcessStart)');
+ }
+ const processId = editor._spawnBackgroundProcessStart(command, args || [], cwd || "");
+ const resultPromise = new Promise((resolve, reject) => {
+ globalThis._pendingCallbacks.set(processId, { resolve, reject });
+ });
+ return {
+ processId,
+ get result() { return resultPromise; },
+ write(data) {
+ return editor.writeBackgroundProcess(processId, String(data));
+ },
+ kill() {
+ return Promise.resolve(editor.killBackgroundProcess(processId));
+ },
+ then(onFulfilled, onRejected) {
+ return resultPromise.then(onFulfilled, onRejected);
+ },
+ catch(onRejected) {
+ return resultPromise.catch(onRejected);
+ }
+ };
+ };
editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
editor.watchPath = _wrapAsync("_watchPathStart", "watchPath");
@@ -10970,6 +11007,85 @@ mod tests {
// ==================== Buffer Operations Tests ====================
+ #[test]
+ fn background_process_handle_is_duplex_and_addressable_immediately() {
+ let (mut backend, rx) = create_test_backend();
+
+ backend
+ .execute_js(
+ r#"
+ const editor = getEditor();
+ const process = editor.spawnBackgroundProcess("fake-adapter", ["--stdio"], "/tmp");
+ globalThis._processId = process.processId;
+ globalThis._writeQueued = process.write("Content-Length: 2\r\n\r\n{}");
+ process.kill();
+ "#,
+ "test.js",
+ )
+ .unwrap();
+
+ let spawn = rx.try_recv().unwrap();
+ let process_id = match spawn {
+ PluginCommand::SpawnBackgroundProcess {
+ process_id,
+ command,
+ args,
+ cwd,
+ ..
+ } => {
+ assert_eq!(command, "fake-adapter");
+ assert_eq!(args, ["--stdio"]);
+ assert_eq!(cwd.as_deref(), Some("/tmp"));
+ process_id
+ }
+ other => panic!("Expected SpawnBackgroundProcess, got {other:?}"),
+ };
+
+ match rx.try_recv().unwrap() {
+ PluginCommand::WriteBackgroundProcess {
+ process_id: written_to,
+ data,
+ } => {
+ assert_eq!(written_to, process_id);
+ assert_eq!(data, "Content-Length: 2\r\n\r\n{}");
+ }
+ other => panic!("Expected WriteBackgroundProcess, got {other:?}"),
+ }
+ match rx.try_recv().unwrap() {
+ PluginCommand::KillBackgroundProcess { process_id: killed } => {
+ assert_eq!(killed, process_id);
+ }
+ other => panic!("Expected KillBackgroundProcess, got {other:?}"),
+ }
+
+ backend
+ .plugin_contexts
+ .borrow()
+ .get("test")
+ .unwrap()
+ .clone()
+ .with(|ctx| {
+ let globals = ctx.globals();
+ assert_eq!(globals.get::<_, u64>("_processId").unwrap(), process_id);
+ assert!(globals.get::<_, bool>("_writeQueued").unwrap());
+ });
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn dap_plugin_loads_and_registers_stream_handlers() {
+ let (mut backend, _rx) = create_test_backend();
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fresh-editor/plugins/dap.ts");
+
+ backend
+ .load_module_with_source(path.to_str().unwrap(), "")
+ .await
+ .unwrap();
+
+ assert!(backend.has_handlers("onProcessStdout"));
+ assert!(backend.has_handlers("onProcessStderr"));
+ assert!(backend.plugin_contexts.borrow().contains_key("dap"));
+ }
+
#[test]
fn test_api_close_buffer() {
let (mut backend, rx) = create_test_backend();