fix(debugger): tell when the PHP has no Xdebug (#1801)

A PHP without the Xdebug extension ignores the -dxdebug.* options php-debug
starts it with, so a PHP debug session ran past every breakpoint and nothing
said why. Starting one now checks the PHP that will run (with XDEBUG_MODE=off,
in the session's folder, where a version manager's php shim picks the PHP) and
warns when Xdebug is not loaded, with Open PHP Settings; the session still
starts. Settings > PHP > CLI Interpreter shows the Xdebug version of the
selected PHP, or Not installed.

Co-authored-by: arookieofc <[email protected]>
Цей коміт міститься в:
Daniel Adámekтаarookieofc authored and GitHub committed 2026-11-07 01:36:04 +08:00
1 джерело 4dd51a1101
коміт 7a6b1601d5
14 files changed
+413 -12

No files matched your search

+14
Переглянути файл
@@ -71,6 +71,19 @@ pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
/// project root).
pub async fn sdk_php_xdebug(
host: &Host,
window: &str,
php: PathBuf,
cwd: Option<PathBuf>,
) -> IdeResult<ide_sdk::php::PhpXdebug> {
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 },
+7 -3
Переглянути файл
@@ -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<String> {
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())
+94 -1
Переглянути файл
@@ -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<C
Some(cmd)
}
/// A PHP interpreter's version and the Xdebug it loads.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpXdebug {
pub php: ide_core::IpcPath,
/// `PHP_VERSION`.
pub php_version: String,
/// The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets.
pub xdebug: Option<String>,
}
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<PhpXdebug> {
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<String>)> {
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/[email protected]/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::<Vec<_>>(),
["-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 });
+1
Переглянути файл
@@ -407,6 +407,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+13
Переглянути файл
@@ -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<PathBuf>,
) -> IdeResult<ide_sdk::php::PhpXdebug> {
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]
+14
Переглянути файл
@@ -799,6 +799,11 @@ export const commands = {
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
sdkPhpEffective: () => typedError<string | null, IdeError>(__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<PhpXdebug, IdeError>(__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<PhpLanguageLevels, IdeError>(__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,
+91 -3
Переглянути файл
@@ -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<string | null>("/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",
+34 -1
Переглянути файл
@@ -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 {
+112
Переглянути файл
@@ -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 = <T,>(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<string, string | null> = { "/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<typeof import("@/platform/ipc")>();
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(
<PageContext.Provider value={{ pageId: "languages.php", scope: "global" }}>
<PhpPage />
</PageContext.Provider>,
);
}
/** 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();
});
});
+23 -2
Переглянути файл
@@ -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<string>("languages.php.interpreter");
const [level, setLevel] = useSetting<string>("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 (
<div className={styles.page}>
<Section>
@@ -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 && <Row label="Version">{selected.version}</Row>}
{xdebug !== undefined && <Row label="Xdebug">{xdebug ?? <T>Not installed</T>}</Row>}
</Section>
</div>
);
+1
Переглянути файл
@@ -168,6 +168,7 @@ export const LANGUAGE_PAGES: PageSpec[] = [
"cli interpreter",
"language level",
"php version",
"xdebug",
"php",
"composer",
"phpunit",
+4 -1
Переглянути файл
@@ -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"
}
+4 -1
Переглянути файл
@@ -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 设置"
}
+1
Переглянути файл
@@ -96,6 +96,7 @@ const UNTRANSLATED = new Set<string>([
"IntelliJ IDEA",
"GitHub",
"PHPStan",
"Xdebug",
// Docker run target placeholder values
"compose.yaml",
"python:3.12",