From 2ba731f0ec6ef136611d643aa84f73c67ce2df40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ad=C3=A1mek?= Date: Thu, 5 Nov 2026 00:45:26 +0100 Subject: [PATCH] feat(php): switch the PHP language level from the status bar (#1371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- crates/ide-host/src/ops/sdk.rs | 40 +++ crates/ide-host/tests/workspaces.rs | 51 ++++ crates/ide-sdk/src/apply.rs | 4 +- crates/ide-sdk/src/php.rs | 71 +++++- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sdk.rs | 11 + src/bindings.ts | 33 +++ .../settings/__tests__/phpStatus.test.tsx | 233 ++++++++++++++++++ src/features/settings/index.ts | 2 + src/features/settings/languages/PhpStatus.tsx | 188 ++++++++++++++ src/features/settings/languages/api.ts | 16 +- src/platform/locales/text/fr.json | 9 + src/platform/locales/text/ko.json | 9 + src/platform/locales/text/zh.json | 9 + 14 files changed, 665 insertions(+), 12 deletions(-) create mode 100644 src/features/settings/__tests__/phpStatus.test.tsx create mode 100644 src/features/settings/languages/PhpStatus.tsx diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 2db3148d..535426d8 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult, + /// The one Auto picks, from `composer.json`. + pub auto: Option, + /// The workspace member `path` belongs to: its own settings choose the level, not the + /// workspace's. `None`: the opened project. + pub member: Option, +} + +pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult { + 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> { @@ -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 diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index cfa22542..1b86eeca 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -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"); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index ae794676..fb77ab2e 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -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) { diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index 950221c7..4e1bc6c4 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -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 { +/// 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 { 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 { 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 { 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 { + levels(composer, explicit).0.map(|l| l.version) + } + + /// The effective level and the composer one. + fn levels(composer: Option<&str>, explicit: &str) -> (Option, Option) { 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 { @@ -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"}}}"#; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 036b93c0..d5418678 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -387,6 +387,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 050c2c10..db2692f5 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -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::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] diff --git a/src/bindings.ts b/src/bindings.ts index 364df910..cb271201 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -750,6 +750,8 @@ export const commands = { sdkDetect: (kind: SdkKind) => typedError(__TAURI_INVOKE("sdk_detect", { kind })), /** The interpreter the project uses: the configured one, else its virtualenv. */ sdkPythonEffective: () => typedError(__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(__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, diff --git a/src/features/settings/__tests__/phpStatus.test.tsx b/src/features/settings/__tests__/phpStatus.test.tsx new file mode 100644 index 00000000..197b1539 --- /dev/null +++ b/src/features/settings/__tests__/phpStatus.test.tsx @@ -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(); +}; + +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(); + }); +}); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 56fd54bc..9f1b3d03 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -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()), diff --git a/src/features/settings/languages/PhpStatus.tsx b/src/features/settings/languages/PhpStatus.tsx new file mode 100644 index 00000000..122af644 --- /dev/null +++ b/src/features/settings/languages/PhpStatus.tsx @@ -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(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 | 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 ( + <> + void executeAction("php.languageLevelPopup")} + > + {label} + + + + ); +} + +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()); + }; +} diff --git a/src/features/settings/languages/api.ts b/src/features/settings/languages/api.ts index b56ba951..64551173 100644 --- a/src/features/settings/languages/api.ts +++ b/src/features/settings/languages/api.ts @@ -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 { 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 { + 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; diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index da25700b..c72eb66c 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -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", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 8bb1f841..893c520c 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -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": "외부 파일 변경 사항 동기화가 느릴 수 있습니다", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e666421b..62cf3f0c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "没有包含路径",