From 6b6e119f200bb28b4cc19d600f21e787f5bb0c81 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 15 Jul 2026 23:34:41 +0900 Subject: [PATCH] Add DAP debugging workflow to Fresh Fresh had no way to use VS Code-compatible debug adapters from the editor. Add a built-in DAP client and duplex background-process transport so users can launch or attach, manage persistent breakpoints, and control execution from the command palette. Cover the complete palette flow with an isolated fake-adapter e2e test. --- CHANGELOG.md | 1 + crates/fresh-core/src/api.rs | 7 + crates/fresh-editor/plugins/README.md | 29 ++ crates/fresh-editor/plugins/dap.schema.json | 31 ++ crates/fresh-editor/plugins/dap.ts | 428 ++++++++++++++++++ crates/fresh-editor/plugins/lib/fresh.d.ts | 6 + crates/fresh-editor/plugins/tsconfig.json | 1 + crates/fresh-editor/src/app/mod.rs | 12 +- .../fresh-editor/src/app/plugin_dispatch.rs | 218 +++++++-- crates/fresh-editor/tests/e2e/plugins/dap.rs | 270 +++++++++++ crates/fresh-editor/tests/e2e/plugins/mod.rs | 2 + crates/fresh-plugin-api-macros/src/lib.rs | 4 + .../src/backend/quickjs_backend.rs | 118 ++++- 13 files changed, 1087 insertions(+), 40 deletions(-) create mode 100644 crates/fresh-editor/plugins/dap.schema.json create mode 100644 crates/fresh-editor/plugins/dap.ts create mode 100644 crates/fresh-editor/tests/e2e/plugins/dap.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e49c5e3a18..7f8f3868c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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 c02f3906b1..8fa8fe4d8c 100644 --- a/crates/fresh-core/src/api.rs +++ b/crates/fresh-core/src/api.rs @@ -2799,6 +2799,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 ecbce95864..a3de23cd14 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; } @@ -3748,6 +3752,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 0f2bca6db0..d378abbb21 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -838,9 +838,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 @@ -1145,6 +1144,13 @@ pub struct Editor { pub(crate) dock_resizing: bool, } +/// 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 1f31ad34c9..16d9eb318f 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 @@ -1208,6 +1327,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); } @@ -2960,7 +3083,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(|| { @@ -2970,9 +3092,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)] @@ -2981,8 +3102,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() { @@ -3000,45 +3125,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 @@ -3057,8 +3177,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 @@ -3068,6 +3194,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, @@ -4501,7 +4637,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 88d2e3b478..56767ae1ce 100644 --- a/crates/fresh-editor/tests/e2e/plugins/mod.rs +++ b/crates/fresh-editor/tests/e2e/plugins/mod.rs @@ -7,6 +7,8 @@ pub mod audit_mode; pub mod authority_snapshot; pub mod buffer_info_splits; pub mod command_keybinding_editor; +#[cfg(unix)] +pub mod dap; pub mod dashboard; // The three modules below drive the in-tree fake-devcontainer // CLI (a bash script under `scripts/fake-devcontainer/bin/`). 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 9063cbfdeb..5098082ae4 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -6729,6 +6729,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 @@ -7381,7 +7391,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"); @@ -10459,6 +10496,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();