mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
DanielADK
parent
742e504719
commit
2ba731f0ec
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}}}"#;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "외부 파일 변경 사항 동기화가 느릴 수 있습니다",
|
||||
|
||||
@@ -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": "没有包含路径",
|
||||
|
||||
Reference in New Issue
Block a user