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 <[email protected]>
Цей коміт міститься в:
Daniel AdámekтаDanielADK authored and GitHub committed 2026-11-05 07:45:26 +08:00
1 джерело 742e504719
коміт 2ba731f0ec
14 files changed
+665 -12

No files matched your search

+188
Переглянути файл
@@ -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
Переглянути файл
@@ -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;