diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 2136d0e68..f40eb221a 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -71,6 +71,19 @@ pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult, +) -> IdeResult { + host.require_trust(window)?; + let cwd = cwd.or_else(|| host.project(window).ok().map(|p| p.root.clone())); + tokio::task::spawn_blocking(move || ide_sdk::php::xdebug(&php, cwd.as_deref())).await.map_err(join_err)? +} + /// The PHP language level of the project `path` belongs to (a workspace member, by path, as /// the language server configuration picks it). #[derive(Clone, Debug, serde::Serialize, specta::Type)] @@ -245,6 +258,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await }); d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) }); d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) }); + d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await }); d.add( "sdk_php_language_level", |c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await }, diff --git a/crates/ide-sdk/src/exec.rs b/crates/ide-sdk/src/exec.rs index c7d036b71..26a791f91 100644 --- a/crates/ide-sdk/src/exec.rs +++ b/crates/ide-sdk/src/exec.rs @@ -55,12 +55,16 @@ pub(crate) fn query_in( if let Some(cwd) = cwd { cmd.current_dir(cwd); } - if !env.iter().any(|(k, _)| *k == "PATH") { + cmd.args(args).envs(env.iter().copied()); + query_command(cmd, timeout) +} + +/// [`query`] of a command built by the caller; `PATH` is the search path unless it sets one. +pub(crate) fn query_command(mut cmd: Command, timeout: Duration) -> Option { + if !cmd.get_envs().any(|(k, _)| k == "PATH") { cmd.env("PATH", search_path()); } let mut child = ide_core::process::prepare(&mut cmd) - .args(args) - .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index dfc29fe3a..e0b26119c 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -1,14 +1,16 @@ //! The PHP language level: the one chosen in Settings › PHP, else the one the root //! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows); //! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes; -//! and the PHPStan run that checks saved files. +//! the PHPStan run that checks saved files; and whether an interpreter loads Xdebug. use crate::apply::{merge, merge_init_options, setting_path}; use ide_core::settings::Settings; +use ide_core::{IdeError, IdeResult}; use ide_language::LanguageServerConfig; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::Duration; /// A PHP version and where it comes from. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] @@ -205,6 +207,60 @@ pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option, +} + +const XDEBUG_TIMEOUT: Duration = Duration::from_secs(4); + +/// `php` printing its version and the loaded Xdebug's (nothing without one), with Xdebug off: a +/// php.ini with `xdebug.start_with_request=yes` would connect to a listening debugger. Xdebug stays +/// loaded in mode `off`, so it still reports its version. Run in `cwd`, where a version manager's +/// `php` shim (asdf, mise, phpenv) picks the PHP it starts. +fn xdebug_command(php: &Path, cwd: Option<&Path>) -> Command { + let mut cmd = Command::new(php); + cmd.args(["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#]).env("XDEBUG_MODE", "off"); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + cmd +} + +/// The version of `php` and of the Xdebug it loads, run in `cwd`. An error when `php` doesn't run, +/// times out or prints something else. +pub fn xdebug(php: &Path, cwd: Option<&Path>) -> IdeResult { + let out = crate::exec::query_command(xdebug_command(php, cwd), XDEBUG_TIMEOUT) + .ok_or_else(|| IdeError::unavailable(format!("cannot run {}", php.display())))?; + let (php_version, xdebug) = parse_xdebug(&out) + .ok_or_else(|| IdeError::invalid(format!("{} printed no PHP version: {out}", php.display())))?; + Ok(PhpXdebug { php: php.to_path_buf().into(), php_version, xdebug }) +} + +/// The PHP version, then the Xdebug version when one is loaded: the last lines of the output, after +/// the startup warnings PHP prints first with `display_startup_errors` (as a broken Xdebug build does). +fn parse_xdebug(out: &str) -> Option<(String, Option)> { + let lines: Vec<&str> = out.lines().map(str::trim).collect(); + match lines[..] { + [.., php, xdebug] if is_version(php) && is_version(xdebug) => Some((php.into(), Some(xdebug.into()))), + [.., php] if is_version(php) => Some((php.into(), None)), + _ => None, + } +} + +/// `8.3.14`, `8.1.2-1ubuntu2.14`, `3.5.0alpha3`. +fn is_version(s: &str) -> bool { + s.starts_with(|c: char| c.is_ascii_digit()) + && s.contains('.') + && s.chars().all(|c| c.is_ascii_alphanumeric() || ".-+~_".contains(c)) +} + type Version = (u32, u32, u32); impl PhpLanguageLevel { @@ -521,6 +577,43 @@ mod tests { assert!(!args(" ").iter().any(|a| a.starts_with("--configuration"))); } + #[test] + fn the_xdebug_probe_runs_with_xdebug_off() { + let php = Path::new("/opt/homebrew/opt/php@8.1/bin/php"); + let project = Path::new("/work/app"); + let cmd = xdebug_command(php, Some(project)); + assert_eq!(cmd.get_program(), php.as_os_str()); + assert_eq!(cmd.get_current_dir(), Some(project), "where a php shim picks the version"); + assert_eq!( + cmd.get_args().map(text).collect::>(), + ["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#] + ); + assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]); + assert_eq!(xdebug_command(php, None).get_current_dir(), None); + } + + #[test] + fn the_xdebug_probe_reads_both_versions() { + let found = |php: &str, xdebug: Option<&str>| Some((php.to_string(), xdebug.map(str::to_string))); + assert_eq!(parse_xdebug("8.3.14\n3.4.7"), found("8.3.14", Some("3.4.7"))); + assert_eq!(parse_xdebug("8.1.33"), found("8.1.33", None)); + assert_eq!(parse_xdebug("8.1.2-1ubuntu2.14\r\n3.5.0alpha3"), found("8.1.2-1ubuntu2.14", Some("3.5.0alpha3"))); + assert_eq!(parse_xdebug("5.6.40\n2.5.5"), found("5.6.40", Some("2.5.5"))); + // display_startup_errors prints startup warnings before the script's output. + let warning = "\nWarning: PHP Startup: Unable to load dynamic library 'xdebug.so' in Unknown on line 0"; + assert_eq!(parse_xdebug(&format!("{warning}\n8.1.33")), found("8.1.33", None)); + assert_eq!(parse_xdebug(&format!("{warning}\n8.3.14\n3.4.7")), found("8.3.14", Some("3.4.7"))); + } + + #[test] + fn the_xdebug_probe_fails_on_other_output() { + for out in ["", " ", "Could not open input file: -r", "8.3.14\nnot a version", "PHP", "8"] { + assert_eq!(parse_xdebug(out), None, "{out:?}"); + } + let missing = Path::new("/nonexistent/bin/php"); + assert!(xdebug(missing, None).is_err(), "no PHP is an error, not a PHP without Xdebug"); + } + #[test] fn each_level_names_its_source() { let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source }); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8ff584d68..c642d42b0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -407,6 +407,7 @@ pub fn builder() -> tauri_specta::Builder { sdk::sdk_detect, sdk::sdk_python_effective, sdk::sdk_php_effective, + sdk::sdk_php_xdebug, sdk::sdk_php_language_level, sdk::sdk_php_composer_install, sdk::sdk_project_libraries, diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 98fbd297e..6546b3e90 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -35,6 +35,19 @@ pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResul ide_host::ops::sdk::sdk_php_effective(&state.host, window.label()) } +/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the +/// project root). +#[tauri::command] +#[specta::specta] +pub async fn sdk_php_xdebug( + window: Window, + state: State<'_, AppState>, + php: PathBuf, + cwd: Option, +) -> IdeResult { + ide_host::ops::sdk::sdk_php_xdebug(&state.host, window.label(), php, cwd).await +} + /// The PHP language level of the project `path` belongs to: the effective one and Auto's. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 16ef77944..07fce559e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -799,6 +799,11 @@ export const commands = { sdkPythonEffective: (near: string | null) => typedError(__TAURI_INVOKE("sdk_python_effective", { near })), /** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */ sdkPhpEffective: () => typedError(__TAURI_INVOKE("sdk_php_effective")), + /** + * The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the + * project root). + */ + sdkPhpXdebug: (php: string, cwd: string | null) => typedError(__TAURI_INVOKE("sdk_php_xdebug", { php, cwd })), /** The PHP language level of the project `path` belongs to: the effective one and Auto's. */ sdkPhpLanguageLevel: (path: string) => typedError(__TAURI_INVOKE("sdk_php_language_level", { path })), /** The Composer project `path` belongs to, when its dependencies need `composer install`. */ @@ -5673,6 +5678,15 @@ export type PhpSettings = { phpstanConfig?: string, }; +/** A PHP interpreter's version and the Xdebug it loads. */ +export type PhpXdebug = { + php: IpcPath, + /** `PHP_VERSION`. */ + phpVersion: string, + /** The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets. */ + xdebug: string | null, +}; + /** An entry of a plugin's `pick` or `pick-many`. */ export type PickItem = { key: string, diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index c0ec86227..47c828b66 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; const ipc = vi.hoisted(() => { @@ -20,6 +20,16 @@ const ipc = vi.hoisted(() => { debugStackTrace: vi.fn(() => ok([])), debugThreads: vi.fn(() => ok([])), debugScopes: vi.fn(() => ok([])), + debugStart: vi.fn((_launch: { extraJson: string | null }) => ok(42)), + debugSetBreakpoints: vi.fn(() => ok([])), + debugBreakpointStates: vi.fn(() => ok([])), + sdkPhpEffective: vi.fn(() => ok("/opt/php81/bin/php")), + sdkLocateTools: vi.fn((_names: string[], _near: string | null) => + ok({ tools: [{ name: "php", path: "/usr/bin/php" }], compileDir: "/tmp" }), + ), + sdkPhpXdebug: vi.fn((php: string, _cwd: string | null) => + ok({ php, phpVersion: "8.1.33", xdebug: null as string | null }), + ), }; }); @@ -49,9 +59,11 @@ vi.mock("@/workbench/contributions", async (importOriginal) => { }; }); -import { executeAction, getAction } from "@/platform/actions"; +import { executeAction, getAction, registerActions } from "@/platform/actions"; import { intellijMacos } from "@/platform/keymaps/intellij-macos"; -import type { DebugCapabilities } from "@/platform/ipc"; +import type { DebugCapabilities, RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { provideNotificationService } from "@/workbench/contributions"; import { registerDebugActions } from "../actions"; import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store"; import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special"; @@ -313,6 +325,82 @@ describe("debugger actions (IDEA parity)", () => { }); }); +describe("PHP debugging without Xdebug", () => { + const php: RunConfig = { + id: "php:index", + name: "index.php", + kind: "php", + command: "php", + args: ["index.php"], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "php-debug", + }; + const notify = vi.fn(); + const settings = vi.fn(); + let unregister: () => void; + beforeEach(() => { + useProject.setState({ project: { root: "/p" } as never }); + provideNotificationService({ notify }); + unregister = registerActions([{ id: "file.settings", title: "Settings", run: ({ arg }) => settings(arg) }]); + }); + afterEach(() => { + unregister(); + useProject.setState({ project: null }); + }); + const runtime = () => JSON.parse(ipc.debugStart.mock.calls[0]![0].extraJson!).runtimeExecutable; + + it("warns that breakpoints are skipped and still starts the session", async () => { + await executeAction("debug.startConfig", php); + // In the session's folder, where a version manager's shim picks the PHP. + expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/opt/php81/bin/php", "/p"); + expect(ipc.debugStart).toHaveBeenCalledOnce(); + expect(runtime()).toBe("/opt/php81/bin/php"); + expect(notify).toHaveBeenCalledOnce(); + const n = notify.mock.calls[0]![0]; + expect(n).toMatchObject({ group: "run", level: "warning", key: "debug.phpXdebug" }); + expect(n.title).toBe("Xdebug is not loaded in PHP 8.1.33"); + expect(n.body).toContain("/opt/php81/bin/php"); + expect(n.body).toContain("Xdebug 3"); + expect(n.actions.map((a: { label: string }) => a.label)).toEqual(["Open PHP Settings"]); + n.actions[0].run(); + expect(settings).toHaveBeenCalledWith("languages.php"); + }); + + it("does not warn when the PHP loads Xdebug or the check fails", async () => { + ipc.sdkPhpXdebug.mockImplementationOnce((p: string) => ipc.ok({ php: p, phpVersion: "8.3.14", xdebug: "3.4.7" })); + await executeAction("debug.startConfig", php); + ipc.sdkPhpXdebug.mockImplementationOnce( + () => + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "cannot run", data: null }, + }) as never, + ); + await executeAction("debug.startConfig", php); + expect(ipc.sdkPhpXdebug).toHaveBeenCalledTimes(2); + expect(ipc.debugStart).toHaveBeenCalledTimes(2); + expect(notify).not.toHaveBeenCalled(); + }); + + it("checks the php on the search path for the Default interpreter", async () => { + ipc.sdkPhpEffective.mockImplementationOnce(() => ipc.ok(null)); + await executeAction("debug.startConfig", php); + expect(ipc.sdkLocateTools).toHaveBeenCalledWith(["php"], null); + expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/usr/bin/php", "/p"); + expect(runtime()).toBeUndefined(); + expect(notify.mock.calls[0]![0].body).toContain("/usr/bin/php"); + }); + + it("checks the PHP the command names, in the configuration's working directory", async () => { + await executeAction("debug.startConfig", { ...php, command: "/opt/php83/bin/php", cwd: "web" }); + expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/opt/php83/bin/php", "/p/web"); + expect(ipc.sdkLocateTools).not.toHaveBeenCalled(); + }); +}); + describe("output class breakpoint restrictions", () => { it.each([ "debug.toggleBreakpoint", diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 5772dc0a3..354b7328d 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -1,6 +1,15 @@ /** Debugger actions (ids match `keymaps/intellij-macos.ts`). */ import { executeAction, getAction, registerActions, type Action } from "@/platform/actions"; -import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc"; +import { + call, + commands, + isCancelled, + isTauri, + nextRequestId, + type LaunchConfig, + type RunConfig, + type RunId, +} from "@/platform/ipc"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; @@ -313,10 +322,34 @@ async function startConfig(arg: unknown) { }); return; } + const xdebugCheck = config.debugAdapter === "php-debug" && isTauri() ? warnWithoutXdebug(launch) : null; await maybeEditorService() ?.saveAll() .catch(() => undefined); await startSession(launch, { runConfig: origin ?? config }); + await xdebugCheck; +} + +/** + * A PHP without the Xdebug extension ignores the `-dxdebug.*` options php-debug starts it with: the + * script runs past every breakpoint. Warns, without holding the session back (a command may load + * Xdebug itself with `-dzend_extension=`). + */ +async function warnWithoutXdebug(launch: LaunchConfig) { + const runtime = (JSON.parse(launch.extraJson ?? "{}") as { runtimeExecutable?: string }).runtimeExecutable; + // Without one, php-debug runs the `php` on the search path. + const php = runtime ?? (await call(commands.sdkLocateTools(["php"], null)).catch(() => null))?.tools[0]?.path; + // In the session's folder: a version manager's `php` shim picks the PHP from it. + const probe = php ? await call(commands.sdkPhpXdebug(php, launch.cwd)).catch(() => null) : null; + if (!probe || probe.xdebug) return; + notifications().notify({ + group: "run", + level: "warning", + key: "debug.phpXdebug", + title: `Xdebug is not loaded in PHP ${probe.phpVersion}`, + body: tx`${probe.php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.`, + actions: [{ label: "Open PHP Settings", run: () => void executeAction("file.settings", "languages.php") }], + }); } export function registerDebugActions(): () => void { diff --git a/src/features/settings/__tests__/phpXdebug.test.tsx b/src/features/settings/__tests__/phpXdebug.test.tsx new file mode 100644 index 000000000..4384b80f2 --- /dev/null +++ b/src/features/settings/__tests__/phpXdebug.test.tsx @@ -0,0 +1,112 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import type { Sdk } from "@/platform/ipc"; + +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const sdk = (path: string, version: string): Sdk => ({ + path, + name: `PHP ${version}`, + version, + source: "system", + home: null, + extra: null, +}); +const XDEBUG: Record = { "/opt/php83/bin/php": "3.4.7", "/opt/php81/bin/php": null }; +const mocks = vi.hoisted(() => ({ xdebug: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { + ...actual.commands, + sdkDetect: () => ok([sdk("/opt/php83/bin/php", "8.3.14"), sdk("/opt/php81/bin/php", "8.1.33")]), + sdkLocateTools: () => ok({ tools: [{ name: "php", path: "/opt/php81/bin/php" }], compileDir: "/tmp" }), + sdkPhpXdebug: mocks.xdebug, + }, + }; +}); + +import { PageContext } from "../components/context"; +import { PhpPage } from "../languages/LanguagePages"; +import { detectSdks } from "../languages/api"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, stage } from "../model/staging"; + +beforeEach(() => { + mocks.xdebug.mockImplementation((php: string) => + php in XDEBUG + ? ok({ php, phpVersion: php.includes("83") ? "8.3.14" : "8.1.33", xdebug: XDEBUG[php] }) + : Promise.resolve({ status: "error", error: { kind: "unavailable", message: "cannot run", data: null } }), + ); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function page(interpreter: string) { + const settings = structuredClone(DEFAULT_SETTINGS); + settings.languages.php.interpreter = interpreter; + useProject.setState({ settings, project: null }); + openSettings("languages.php"); + render( + + + , + ); +} + +/** The value of the CLI Interpreter row labelled `label`. */ +const row = (label: string) => screen.queryByText(label, { selector: "label *" })?.closest("div")?.textContent; + +describe("PHP settings: Xdebug row", () => { + it("shows the Xdebug version of the selected PHP, re-checked when the interpreter changes", async () => { + page("/opt/php83/bin/php"); + await waitFor(() => expect(row("Xdebug")).toContain("3.4.7")); + expect(row("Version")).toContain("8.3.14"); + expect(mocks.xdebug).toHaveBeenCalledWith("/opt/php83/bin/php", null); + + let finish: () => void = () => {}; + mocks.xdebug.mockImplementationOnce( + (php: string) => + new Promise((r) => (finish = () => r({ status: "ok", data: { php, phpVersion: "8.1.33", xdebug: null } }))), + ); + act(() => stage("languages.php", "global", "languages.php.interpreter", "/opt/php81/bin/php")); + expect(row("Version")).toContain("8.1.33"); + expect(row("Xdebug"), "not the previous PHP's Xdebug").toBeUndefined(); + await act(async () => finish()); + expect(row("Xdebug")).toContain("Not installed"); + }); + + it("checks again when the interpreters are detected again", async () => { + page("/opt/php81/bin/php"); + await waitFor(() => expect(row("Xdebug")).toContain("Not installed")); + XDEBUG["/opt/php81/bin/php"] = "3.4.7"; + try { + await act(() => detectSdks("php", true)); + await waitFor(() => expect(row("Xdebug")).toContain("3.4.7")); + } finally { + XDEBUG["/opt/php81/bin/php"] = null; + } + }); + + it("checks the php on the search path for the Default interpreter", async () => { + page(""); + await waitFor(() => expect(row("Xdebug")).toContain("Not installed")); + expect(mocks.xdebug).toHaveBeenCalledWith("/opt/php81/bin/php", null); + }); + + it("hides the row while checking and when the check fails", async () => { + let finish: (v: unknown) => void = () => {}; + mocks.xdebug.mockImplementationOnce(() => new Promise((r) => (finish = r))); + page("/opt/php83/bin/php"); + await waitFor(() => expect(row("Version")).toContain("8.3.14")); + expect(row("Xdebug")).toBeUndefined(); + await act(async () => + finish({ status: "error", error: { kind: "unavailable", message: "timed out", data: null } }), + ); + expect(row("Xdebug")).toBeUndefined(); + }); +}); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 3276b1f18..f516588bf 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -4,8 +4,8 @@ * use (applied by `ide-sdk` when language servers start and when run configurations load). */ import { useEffect, useId, useMemo, useState } from "react"; -import { call, commands } from "@/platform/ipc"; -import { IconButton, Input, type SelectOption } from "@/ui"; +import { call, commands, type Sdk } from "@/platform/ipc"; +import { IconButton, Input, type SelectOption, T } from "@/ui"; import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings"; import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form"; import { choosePath } from "./api"; @@ -134,6 +134,25 @@ function usePathPhp(enabled: boolean): string | null { return path; } +/** + * The Xdebug version `php` loads, `null` for none; `undefined` while it is checked or when the check + * fails. Checked again when detection runs again (after installing Xdebug). + */ +function useXdebug(php: Sdk | undefined): string | null | undefined { + const [found, setFound] = useState<{ php: Sdk; xdebug: string | null } | null>(null); + useEffect(() => { + if (!php) return; + let live = true; + call(commands.sdkPhpXdebug(php.path, null)) + .then((r) => live && setFound({ php, xdebug: r.xdebug })) + .catch(() => {}); + return () => { + live = false; + }; + }, [php]); + return found && found.php === php ? found.xdebug : undefined; +} + export function PhpPage() { const [interpreter] = useSetting("languages.php.interpreter"); const [level, setLevel] = useSetting("languages.php.languageLevel"); @@ -143,6 +162,7 @@ export function PhpPage() { const pathPhp = usePathPhp(!interpreter); // Default runs the `php` on PATH: the list is newest first, so it isn't necessarily `list[0]`. const selected = list.find((s) => s.path === (interpreter || pathPhp)); + const xdebug = useXdebug(selected); return (
@@ -203,6 +223,7 @@ export function PhpPage() { hint="Runs PHP scripts, PHPUnit, Composer and artisan, and the PHP-based language servers (phpactor). Its folder is put first on PATH, so vendor/bin/phpunit and other #!/usr/bin/env php scripts use it too." /> {selected?.version && {selected.version}} + {xdebug !== undefined && {xdebug ?? Not installed}}
); diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 5291b3e0d..4352bdac1 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -168,6 +168,7 @@ export const LANGUAGE_PAGES: PageSpec[] = [ "cli interpreter", "language level", "php version", + "xdebug", "php", "composer", "phpunit", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index ca9d09aff..a19672eb9 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -46,5 +46,8 @@ "Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.", "Automatic": "Automatic", "CNB (China mirror)": "CNB (China mirror)", - "Remote connection cancelled": "Remote connection cancelled" + "Remote connection cancelled": "Remote connection cancelled", + "Xdebug is not loaded in PHP {version}": "Xdebug is not loaded in PHP {version}", + "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.", + "Open PHP Settings": "Open PHP Settings" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e3c92d6f2..4fd67605a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6401,5 +6401,8 @@ "Update source": "更新源", "Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。", "CNB (China mirror)": "CNB(国内镜像)", - "Remote connection cancelled": "远程连接已取消" + "Remote connection cancelled": "远程连接已取消", + "Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug", + "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。", + "Open PHP Settings": "打开 PHP 设置" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index b6b026d53..ec37888a2 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -96,6 +96,7 @@ const UNTRANSLATED = new Set([ "IntelliJ IDEA", "GitHub", "PHPStan", + "Xdebug", // Docker run target placeholder values "compose.yaml", "python:3.12",