feat(php): switch the PHP language level from the status bar (#1371)

With a PHP file focused, the status bar shows the PHP level Intelephense
checks against (`PHP 7.4`); the tooltip says where it comes from (the
settings, config.platform.php or require.php). Clicking it opens Auto (with
the composer.json version) and 5.6 to 8.5; picking one writes the same
project setting as Settings › Languages & Frameworks › PHP, so diagnostics
follow without reopening the project. The widget is hidden for other files.
For a file of a linked workspace project, the level comes from that
project's settings and the choices are disabled. The menu is also the
action "PHP Language Level".

Closes #1091

Co-authored-by: DanielADK <18214958+DanielADK@users.noreply.github.com>
This commit is contained in:
Daniel Adámek
2026-11-05 07:45:26 +08:00
committed by GitHub
co-authored by DanielADK
parent 742e504719
commit 2ba731f0ec
14 changed files with 665 additions and 12 deletions
+40
View File
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// 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)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
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) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+51
View File
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
+2 -2
View File
@@ -612,9 +612,9 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(version) = crate::php::language_level(root, s)
&& let Some(level) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
+62 -9
View File
@@ -8,17 +8,46 @@ use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
/// `composer.json` name one.
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
let explicit = s.languages.php.language_level.trim();
if !explicit.is_empty() {
return version(explicit).map(dotted);
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
@@ -112,8 +141,10 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
type Version = (u32, u32, u32);
fn dotted((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
}
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
@@ -167,13 +198,18 @@ mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
language_level(tmp.path(), &s)
(language_level(tmp.path(), &s), composer_level(tmp.path()))
}
fn required(constraint: &str) -> Option<String> {
@@ -314,6 +350,23 @@ mod tests {
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
+1
View File
@@ -387,6 +387,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_php_language_level,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
sdk::sdk_python_create_venv,
+11
View File
@@ -24,6 +24,17 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
}
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
#[tauri::command]
#[specta::specta]
pub async fn sdk_php_language_level(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<ide_host::ops::sdk::PhpLanguageLevels> {
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
#[tauri::command]
+33
View File
@@ -750,6 +750,8 @@ export const commands = {
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
/** 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 })),
/**
* SDK / library source folders shown under "External Libraries" in the Project view,
* with the Maven / Gradle dependency jars of the build model.
@@ -5392,6 +5394,37 @@ export type PathCompletion = {
dir: boolean,
};
/** A PHP version and where it comes from. */
export type PhpLanguageLevel = {
/** `X.Y.Z`. */
version: string,
source: PhpLevelSource,
};
/**
* The PHP language level of the project `path` belongs to (a workspace member, by path, as
* the language server configuration picks it).
*/
export type PhpLanguageLevels = {
/** The one Intelephense checks against; `None`: its default. */
effective: PhpLanguageLevel | null,
/** The one Auto picks, from `composer.json`. */
auto: PhpLanguageLevel | null,
/**
* The workspace member `path` belongs to: its own settings choose the level, not the
* workspace's. `None`: the opened project.
*/
member: IpcPath | null,
};
export type PhpLevelSource =
/** `languages.php.languageLevel`. */
"settings" |
/** `config.platform.php` of `composer.json`. */
"platform" |
/** The lowest version `require.php` of `composer.json` allows. */
"require";
export type PhpSettings = {
/** `php` CLI executable; empty = the one on `PATH`. */
interpreter?: string,
@@ -0,0 +1,233 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeAction, registerAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import type { PhpLanguageLevels, ProjectInfo, Settings } from "@/bindings";
type FsChange = { created: string[]; modified: string[]; removed: string[] };
const mocks = vi.hoisted(() => ({
notify: vi.fn(),
level: vi.fn(),
set: vi.fn(),
fsChanged: undefined as ((e: FsChange) => void) | undefined,
component: null as React.ComponentType | null,
}));
vi.mock("@/platform/ipc", () => ({
call: (p: unknown) => p,
isTauri: () => true,
onIdeEvent: (name: string, handler: (e: FsChange) => void) => {
if (name === "fsChanged") mocks.fsChanged = handler;
return () => {};
},
commands: { sdkPhpLanguageLevel: mocks.level, settingsSet: mocks.set },
}));
vi.mock("@/workbench/contributions", () => ({
notifications: () => ({ notify: mocks.notify }),
registerStatusItem: (item: { component: React.ComponentType }) => {
mocks.component = item.component;
return () => {};
},
}));
import { activatePhpStatus } from "../languages/PhpStatus";
const fromComposer: PhpLanguageLevels = {
effective: { version: "7.4.0", source: "require" },
auto: { version: "7.4.0", source: "require" },
member: null,
};
const focus = (language: string, path: string) =>
act(() => {
setContext("editorLangId", language);
setContext("editorPath", path);
});
let dispose: () => void;
beforeEach(() => {
vi.clearAllMocks();
mocks.level.mockResolvedValue(fromComposer);
mocks.set.mockImplementation((_scope: string, _key: string, value: string) =>
Promise.resolve({ languages: { php: { languageLevel: JSON.parse(value) } } }),
);
useProject.setState({ project: { root: "/project" } as ProjectInfo, settings: null });
setContext("projectOpen", true);
setContext("editorLangId", null);
setContext("editorPath", null);
dispose = activatePhpStatus();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
dispose();
});
const mount = () => {
const Component = mocks.component!;
return render(<Component />);
};
const pick = async (label: string) => {
await act(() => executeAction("php.languageLevelPopup"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: label }));
};
describe("PHP language level status", () => {
it("stays hidden for other editors without asking the backend", async () => {
mount();
await focus("typescript", "/project/src/app.ts");
expect(screen.queryByRole("button")).toBeNull();
expect(mocks.level).not.toHaveBeenCalled();
});
it("shows the effective level of the focused PHP file and where it comes from", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
expect(button.textContent).toBe("PHP 7.4");
expect(button.dataset.tooltip).toBe("PHP 7.4.0, the lowest version require.php in composer.json allows");
expect(mocks.level).toHaveBeenCalledWith("/project/src/Invoice.php");
await focus("markdown", "/project/README.md");
expect(screen.queryByRole("button")).toBeNull();
});
it("names config.platform.php and the language server default", async () => {
mocks.level.mockResolvedValueOnce({
effective: { version: "7.2.34", source: "platform" },
auto: null,
member: null,
});
mount();
await focus("php", "/project/a.php");
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.2" });
expect(button.dataset.tooltip).toBe("PHP 7.2.34, from config.platform.php in composer.json");
mocks.level.mockResolvedValueOnce({ effective: null, auto: null, member: null });
await focus("php", "/project/b.php");
const fallback = await screen.findByRole("button", { name: "PHP Language Level: PHP" });
expect(fallback.dataset.tooltip).toBe(
"No PHP version in the settings or composer.json: Intelephense uses its default",
);
});
it("writes the picked level to the project settings", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
await pick("8.1");
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("8.1")),
);
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
expect(button.dataset.tooltip).toBe("PHP 8.1.0, set in the settings");
expect(useProject.getState().settings?.languages?.php?.languageLevel).toBe("8.1");
});
it("shows what Auto resolves to and clears the level with it", async () => {
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings });
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByRole("menuitemcheckbox", { name: "8.1" }).getAttribute("aria-checked")).toBe("true");
const auto = screen.getByRole("menuitemcheckbox", { name: /^Auto/ });
expect(auto.getAttribute("aria-checked")).toBe("false");
expect(auto.textContent).toContain("7.4");
mocks.level.mockResolvedValue(fromComposer);
fireEvent.click(auto);
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("")),
);
expect(await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" })).toBeTruthy();
});
it("re-reads the level once after composer.json of the project or a linked project changes", async () => {
useProject.setState({ project: { root: "/project", workspace: { projects: ["/linked/api"] } } as ProjectInfo });
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
vi.useFakeTimers();
const change = (...modified: string[]) => act(() => mocks.fsChanged!({ created: [], modified, removed: [] }));
await change("/project/vendor/acme/lib/composer.json", "/project/src/composer.json");
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(mocks.level).toHaveBeenCalledTimes(1);
// Each change in a burst restarts the wait.
await change("/project/composer.json");
await act(() => vi.advanceTimersByTimeAsync(200));
await change("/project/composer.json");
await act(() => vi.advanceTimersByTimeAsync(299));
expect(mocks.level).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(1));
expect(mocks.level).toHaveBeenCalledTimes(2);
await change("/linked/api/composer.json");
await act(() => vi.advanceTimersByTimeAsync(300));
expect(mocks.level).toHaveBeenCalledTimes(3);
});
it("re-reads the level when the PHP language level setting changes, not other settings", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
act(() => useProject.setState({ settings: { editor: { fontSize: 15 } } as Settings }));
expect(mocks.level).toHaveBeenCalledTimes(1);
act(() => useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings }));
await waitFor(() => expect(mocks.level).toHaveBeenCalledTimes(2));
});
it("shows a workspace member's level but leaves its choice to the member's settings", async () => {
mocks.level.mockResolvedValue({ ...fromComposer, member: "/project/api" });
mount();
await focus("php", "/project/api/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByText("Set in the settings of the linked project api")).toBeTruthy();
const items = screen.getAllByRole("menuitemcheckbox");
expect(items.map((item) => item.getAttribute("aria-disabled"))).toEqual(items.map(() => "true"));
expect(screen.getByRole("menuitemcheckbox", { name: /^Auto/ }).getAttribute("aria-checked")).toBe("true");
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "8.1" }));
expect(mocks.set).not.toHaveBeenCalled();
});
it("drops the previous file's level and menu when another PHP file is focused", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mocks.level.mockResolvedValueOnce({ ...fromComposer, member: "/project/api" });
mount();
await focus("php", "/project/api/src/Order.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByRole("menu")).toBeTruthy();
mocks.level.mockRejectedValueOnce(new Error("project closed"));
await focus("php", "/project/src/Invoice.php");
expect(screen.queryByRole("button")).toBeNull();
expect(screen.queryByRole("menu")).toBeNull();
await waitFor(() => expect(warn).toHaveBeenCalled());
expect(mocks.level).toHaveBeenLastCalledWith("/project/src/Invoice.php");
expect(screen.queryByRole("button")).toBeNull();
// The menu does not come back with the next level.
await focus("php", "/project/api/src/Order.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
expect(screen.queryByRole("menu")).toBeNull();
warn.mockRestore();
});
it("ignores levels it does not list and reports write failures", async () => {
await executeAction("php.selectLanguageLevel", "9.9");
expect(mocks.set).not.toHaveBeenCalled();
mocks.set.mockRejectedValueOnce(new Error("write failed"));
await executeAction("php.selectLanguageLevel", "8.1");
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
expect(useProject.getState().settings).toBeNull();
});
it("opens the PHP settings page while the widget is hidden", async () => {
const run = vi.fn();
const off = registerAction({ id: "file.settings", title: "Settings…", run });
mount();
await focus("typescript", "/project/src/app.ts");
await executeAction("php.languageLevelPopup");
expect(run.mock.calls.map(([ctx]) => ctx.arg)).toEqual(["languages.php"]);
off();
});
});
+2
View File
@@ -38,6 +38,7 @@ import { installActionProxies, installSettingsHost } from "./window/bridge";
import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut";
import { settingsSearchEntries } from "./searchIndex";
import { activatePythonStatus } from "./languages/PythonStatus";
import { activatePhpStatus } from "./languages/PhpStatus";
import { nativeToAsciiAction } from "./nativeToAscii";
import { isImeKey } from "@/platform/ime";
import { trackIconThemePages } from "./iconThemePages";
@@ -109,6 +110,7 @@ export function activate(): () => void {
trackRuntimeSettings(),
trackIconThemePages(),
activatePythonStatus(),
activatePhpStatus(),
unloadBuiltinPages,
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
registerActions(zoomActions()),
@@ -0,0 +1,188 @@
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { getContext, useContextKeys } from "@/platform/context";
import { call, commands, onIdeEvent, type PhpLanguageLevel, type PhpLanguageLevels } from "@/platform/ipc";
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
import { basename, useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
import { notifications, registerStatusItem } from "@/workbench/contributions";
import { BarButton, Menu, type MenuItem } from "@/ui";
import { phpLanguageLevel } from "./api";
/** What the widget last resolved for a file, so the popup action can read it without a render. */
interface Status extends PhpLanguageLevels {
root: string;
path: string;
}
const usePopup = create<{ open: boolean; status: Status | null }>(() => ({ open: false, status: null }));
const close = () => usePopup.setState({ open: false });
const reset = () => usePopup.setState({ open: false, status: null });
/** Whether the widget is on screen, read straight from the stores (the action runs outside a render). */
function popupShown(): boolean {
const root = useProject.getState().project?.root;
const status = usePopup.getState().status;
const context = getContext();
return !!root && status?.root === root && status.path === context.editorPath && context.editorLangId === "php";
}
/** `8.1.0` as `8.1`, the way the levels are listed. */
const shortVersion = (level: PhpLanguageLevel) => level.version.split(".").slice(0, 2).join(".");
function origin(level: PhpLanguageLevel | null): string {
if (!level) return tx("No PHP version in the settings or composer.json: Intelephense uses its default");
const version = level.version;
switch (level.source) {
case "settings":
return tx`PHP ${version}, set in the settings`;
case "platform":
return tx`PHP ${version}, from config.platform.php in composer.json`;
case "require":
return tx`PHP ${version}, the lowest version require.php in composer.json allows`;
}
}
function PhpStatus() {
const root = useProject((s) => s.project?.root);
const linked = useProject((s) => s.project?.workspace?.projects);
const configured = useProject((s) => s.settings?.languages?.php?.languageLevel ?? "");
const path = useContextKeys((s) =>
s.editorLangId === "php" && typeof s.editorPath === "string" ? s.editorPath : null,
);
const status = usePopup((s) => s.status);
const open = usePopup((s) => s.open);
const anchor = useRef<HTMLButtonElement>(null);
useEffect(() => reset, [root]);
// The menu belongs to the focused file.
useEffect(() => close, [path]);
useEffect(() => {
if (!root || !path) return;
let live = true;
let sequence = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
// Auto reads the composer.json of the project or of the linked project the file is in.
const manifests = new Set([root, ...(linked ?? [])].map((dir) => `${dir}/composer.json`));
const refresh = async () => {
const request = ++sequence;
try {
const level = await phpLanguageLevel(path);
if (live && sequence === request) usePopup.setState({ status: level && { root, path, ...level } });
} catch (e) {
console.warn("Cannot read the PHP language level", e);
}
};
void refresh();
const off = onIdeEvent("fsChanged", (e) => {
if (![...e.created, ...e.modified, ...e.removed].some((file) => manifests.has(file))) return;
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 300);
});
return () => {
live = false;
clearTimeout(timer);
off();
};
}, [root, linked, path, configured]);
// The widget only renders once its project's level is in the store, so the popup action
// sees the same state as the button without waiting for an effect to publish a flag.
if (!path || !popupShown() || !status) return null;
const { effective, auto, member } = status;
const label = effective ? `PHP ${shortVersion(effective)}` : "PHP";
const chosen = effective?.source === "settings" ? shortVersion(effective) : "";
// The choices write the workspace's settings, which a member does not read.
const name = member && basename(member);
const items: MenuItem[] = [
{ kind: "header", label: "PHP Language Level" },
...(name
? [{ kind: "header", label: tx`Set in the settings of the linked project ${name}` } satisfies MenuItem]
: []),
...PHP_LANGUAGE_LEVELS.map((level): MenuItem =>
level
? {
label: level,
verbatim: true,
checked: level === chosen,
disabled: !!member,
run: () => void executeAction("php.selectLanguageLevel", level),
}
: {
label: "Auto",
shortcut: auto ? shortVersion(auto) : undefined,
checked: !chosen,
disabled: !!member,
run: () => void executeAction("php.selectLanguageLevel", ""),
},
),
];
return (
<>
<BarButton
ref={anchor}
tooltip={origin(effective)}
aria-label={tx`PHP Language Level: ${label}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => void executeAction("php.languageLevelPopup")}
>
{label}
</BarButton>
<Menu
open={open}
onClose={close}
anchor={anchor}
placement="top-end"
items={items}
aria-label="PHP Language Level"
/>
</>
);
}
export function activatePhpStatus(): () => void {
const dispose = [
registerStatusItem({
id: "php.languageLevel",
title: "PHP Language Level",
align: "right",
priority: 89,
component: PhpStatus,
}),
registerActions([
{
id: "php.languageLevelPopup",
title: "PHP Language Level",
category: "Settings",
keywords: ["php version"],
when: "projectOpen",
run: () =>
popupShown()
? usePopup.setState((s) => ({ open: !s.open }))
: void executeAction("file.settings", "languages.php"),
},
{
id: "php.selectLanguageLevel",
title: "Select PHP Language Level",
category: "Settings",
hidden: true,
run: async ({ arg }) => {
const project = useProject.getState().project;
if (!project || typeof arg !== "string" || !PHP_LANGUAGE_LEVELS.some((level) => level === arg)) return;
try {
const settings = await call(
commands.settingsSet("project", "languages.php.languageLevel", JSON.stringify(arg)),
);
if (useProject.getState().project === project) useProject.setState({ settings });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot select PHP language level", body: String(e) });
}
},
},
]),
];
return () => {
reset();
dispose.reverse().forEach((d) => d());
};
}
+15 -1
View File
@@ -4,7 +4,16 @@
*/
import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import { call, commands, isTauri, nextRequestId, type PythonPackage, type Sdk, type SdkKind } from "@/platform/ipc";
import {
call,
commands,
isTauri,
nextRequestId,
type PhpLanguageLevels,
type PythonPackage,
type Sdk,
type SdkKind,
} from "@/platform/ipc";
export type { PythonPackage, Sdk, SdkKind };
@@ -97,6 +106,11 @@ export function effectivePython(): Promise<string | null> {
return isTauri() ? call(commands.sdkPythonEffective()) : Promise.resolve(null);
}
/** The PHP language level of the project `path` belongs to; null outside Tauri. */
export function phpLanguageLevel(path: string): Promise<PhpLanguageLevels | null> {
return isTauri() ? call(commands.sdkPhpLanguageLevel(path)) : Promise.resolve(null);
}
/** File / folder chooser (Tauri dialog); null when cancelled or outside Tauri. */
export async function choosePath(opts: {
directory: boolean;
+9
View File
@@ -5761,6 +5761,15 @@
"PHP language level": "Niveau de langage PHP",
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "La version de PHP par rapport à laquelle Intelephense vérifie le code : la syntaxe et les fonctions plus récentes sont signalées. Auto utilise config.platform.php de composer.json, sinon la version la plus basse autorisée par require.php.",
"from composer.json": "depuis composer.json",
"PHP Language Level": "Niveau de langage PHP",
"PHP Language Level: {label}": "Niveau de langage PHP : {label}",
"Select PHP Language Level": "Sélectionner le niveau de langage PHP",
"Cannot select PHP language level": "Impossible de sélectionner le niveau de langage PHP",
"No PHP version in the settings or composer.json: Intelephense uses its default": "Aucune version de PHP dans les paramètres ni dans composer.json : Intelephense utilise sa version par défaut",
"PHP {version}, set in the settings": "PHP {version}, défini dans les paramètres",
"PHP {version}, from config.platform.php in composer.json": "PHP {version}, depuis config.platform.php de composer.json",
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, la version la plus basse autorisée par require.php de composer.json",
"Set in the settings of the linked project {name}": "Défini dans les paramètres du projet lié {name}",
"select files in a git repository to shelve": "sélectionnez les fichiers d'un dépôt git à mettre de côté",
"select files from a single git repository to shelve": "sélectionnez les fichiers d'un seul dépôt git à mettre de côté",
"External file changes sync may be slow": "La synchronisation des modifications externes de fichiers peut être lente",
+9
View File
@@ -5615,6 +5615,15 @@
"PHP language level": "PHP 언어 레벨",
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "Intelephense가 코드를 검사하는 기준 PHP 버전입니다. 더 새로운 문법과 함수가 보고됩니다. 자동은 composer.json의 config.platform.php를 사용하고, 없으면 require.php가 허용하는 가장 낮은 버전을 사용합니다.",
"from composer.json": "composer.json 기준",
"PHP Language Level": "PHP 언어 레벨",
"PHP Language Level: {label}": "PHP 언어 레벨: {label}",
"Select PHP Language Level": "PHP 언어 레벨 선택",
"Cannot select PHP language level": "PHP 언어 레벨을 선택할 수 없습니다",
"No PHP version in the settings or composer.json: Intelephense uses its default": "설정과 composer.json에 PHP 버전이 없습니다. Intelephense가 기본 버전을 사용합니다",
"PHP {version}, set in the settings": "PHP {version}, 설정에서 지정",
"PHP {version}, from config.platform.php in composer.json": "PHP {version}, composer.json의 config.platform.php 기준",
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, composer.json의 require.php가 허용하는 가장 낮은 버전",
"Set in the settings of the linked project {name}": "연결된 프로젝트 {name}의 설정에서 지정",
"select files in a git repository to shelve": "셸브할 git 저장소의 파일을 선택하세요",
"select files from a single git repository to shelve": "셸브할 단일 git 저장소의 파일을 선택하세요",
"External file changes sync may be slow": "외부 파일 변경 사항 동기화가 느릴 수 있습니다",
+9
View File
@@ -5655,6 +5655,15 @@
"PHP language level": "PHP 语言级别",
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "Intelephense 检查代码时所针对的 PHP 版本:更新版本的语法和函数会被报告。“自动”使用 composer.json 中的 config.platform.php,否则使用 require.php 允许的最低版本。",
"from composer.json": "来自 composer.json",
"PHP Language Level": "PHP 语言级别",
"PHP Language Level: {label}": "PHP 语言级别:{label}",
"Select PHP Language Level": "选择 PHP 语言级别",
"Cannot select PHP language level": "无法选择 PHP 语言级别",
"No PHP version in the settings or composer.json: Intelephense uses its default": "设置和 composer.json 中均未指定 PHP 版本:Intelephense 使用其默认版本",
"PHP {version}, set in the settings": "PHP {version},在设置中指定",
"PHP {version}, from config.platform.php in composer.json": "PHP {version},来自 composer.json 中的 config.platform.php",
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version},composer.json 中 require.php 允许的最低版本",
"Set in the settings of the linked project {name}": "在关联项目 {name} 的设置中指定",
"Include paths": "包含路径",
"Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project.": "Intelephense 额外索引其中类和函数的项目外文件夹,例如模块所扩展的平台。可以是绝对路径,或相对于项目的路径。",
"No include paths": "没有包含路径",