From 8689271e7cdffa631a5598aa00de9b3231612aeb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:43:22 +0800 Subject: [PATCH] feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP files and composer.json of a Composer project show a banner when vendor/autoload.php is missing or a package of composer.lock is not installed at its locked version (packages-dev only unless the install used --no-dev; config.vendor-dir honoured). Install runs composer install in the Run tool window with the PHP of Settings › PHP first on PATH; Ignore hides it until the project is reopened. Rechecked when composer.json, composer.lock or vendor change; not shown in an untrusted project. A Composer Install action does the same. --- crates/ide-host/src/ops/sdk.rs | 31 +++++ crates/ide-sdk/src/php.rs | 70 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sdk.rs | 11 ++ src/bindings.ts | 18 +++ src/features/run/composerInstall.test.tsx | 75 +++++++++++ src/features/run/composerInstall.ts | 149 ++++++++++++++++++++++ src/features/run/index.ts | 2 + src/platform/locales/text/zh.json | 5 +- 9 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/features/run/composerInstall.test.tsx create mode 100644 src/features/run/composerInstall.ts diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 78c5aee0..33947a3b 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -96,6 +96,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> .map_err(join_err) } +/// The Composer project `path` belongs to, when its dependencies need `composer install`. +#[derive(Clone, Debug, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ComposerInstallState { + pub root: ide_core::IpcPath, + pub reason: ide_sdk::php::ComposerInstall, +} + +/// `None` in an untrusted project too: `composer install` runs the project's scripts. +pub async fn sdk_php_composer_install( + host: &Host, + window: &str, + path: PathBuf, +) -> IdeResult> { + let project = host.project(window)?; + if !project.is_trusted() { + return Ok(None); + } + let root = project.root.clone(); + tokio::task::spawn_blocking(move || { + ide_sdk::php::composer_install(&path, &root) + .map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason }) + }) + .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> { @@ -211,6 +238,10 @@ pub(crate) fn register(d: &mut Dispatcher) { "sdk_php_language_level", |c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await }, ); + d.add( + "sdk_php_composer_install", + |c| async move { sdk_php_composer_install(&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-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index ff03cee8..dfc29fe3 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -52,6 +52,43 @@ pub fn composer_level(root: &Path) -> Option { .map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require)) } +/// Why `composer install` is due in a Composer project. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum ComposerInstall { + /// No `vendor/autoload.php`. + Missing, + /// A package of `composer.lock` is not installed, or at another version. + Outdated, +} + +/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and +/// whether its dependencies need `composer install`. +pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> { + let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?; + let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?; + let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor")); + if !vendor.join("autoload.php").is_file() { + return Some((dir.to_path_buf(), ComposerInstall::Missing)); + } + let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::(&t).ok()); + let lock = read(dir.join("composer.lock"))?; + let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null); + // Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself. + let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default(); + let versions: std::collections::HashMap<&str, &str> = + packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect(); + let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true); + let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] }; + let outdated = sections + .iter() + .filter_map(|section| lock.get(*section).and_then(Value::as_array)) + .flatten() + .filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))) + .any(|(name, version)| versions.get(name) != Some(&version)); + outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated)) +} + /// Gives each PHP version its own Intelephense workspace storage (below `data_dir`). /// Intelephense keeps the built-in functions of the version a workspace was first indexed /// with in that storage, and reuses them after the version changes. Without a version, or @@ -226,6 +263,39 @@ fn lower_bound(alternative: &str) -> Option { mod tests { use super::*; + #[test] + fn composer_install_is_due_without_vendor_or_when_the_lock_differs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let app = root.join("app"); + std::fs::create_dir_all(app.join("src")).unwrap(); + let file = app.join("src/Kernel.php"); + assert_eq!(composer_install(&file, root), None, "not a Composer project"); + std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap(); + assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing))); + std::fs::create_dir_all(app.join("lib/composer")).unwrap(); + std::fs::write(app.join("lib/autoload.php"), ", explicit: &str) -> Option { levels(composer, explicit).0.map(|l| l.version) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 72ed8e0e..f4f3dba7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -400,6 +400,7 @@ pub fn builder() -> tauri_specta::Builder { sdk::sdk_python_effective, sdk::sdk_php_effective, sdk::sdk_php_language_level, + sdk::sdk_php_composer_install, 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 bb3db7d3..c006c26b 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -42,6 +42,17 @@ pub async fn sdk_php_language_level( ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await } +/// The Composer project `path` belongs to, when its dependencies need `composer install`. +#[tauri::command] +#[specta::specta] +pub async fn sdk_php_composer_install( + window: Window, + state: State<'_, AppState>, + path: PathBuf, +) -> IdeResult> { + ide_host::ops::sdk::sdk_php_composer_install(&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 902eda11..ae6e9dee 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -776,6 +776,11 @@ export const commands = { sdkPhpEffective: () => typedError(__TAURI_INVOKE("sdk_php_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 })), + /** The Composer project `path` belongs to, when its dependencies need `composer install`. */ + sdkPhpComposerInstall: (path: string) => typedError<{ + root: IpcPath, + reason: ComposerInstall, +} | null, IdeError>(__TAURI_INVOKE("sdk_php_composer_install", { path })), /** * SDK / library source folders shown under "External Libraries" in the Project view, * with the Maven / Gradle dependency jars of the build model. @@ -2378,6 +2383,19 @@ export type ComposeOp = "start" | "stop" | "restart" | /** Stops and removes the project's containers and networks. */ "down"; +/** Why `composer install` is due in a Composer project. */ +export type ComposerInstall = +/** No `vendor/autoload.php`. */ +"missing" | +/** A package of `composer.lock` is not installed, or at another version. */ +"outdated"; + +/** The Composer project `path` belongs to, when its dependencies need `composer install`. */ +export type ComposerInstallState = { + root: IpcPath, + reason: ComposerInstall, +}; + /** The user's answer on an install or consent page. */ export type Consent = { /** diff --git a/src/features/run/composerInstall.test.tsx b/src/features/run/composerInstall.test.tsx new file mode 100644 index 00000000..9f26a422 --- /dev/null +++ b/src/features/run/composerInstall.test.tsx @@ -0,0 +1,75 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ + sdkPhpComposerInstall: vi.fn(), + runApplySettings: vi.fn(), +})); +const run = vi.hoisted(() => ({ startRun: vi.fn() })); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, + commands: ipc, +})); +vi.mock("./store", () => run); + +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { useEditorBanners } from "@/workbench/contributions"; +import { composerInstallConfig, registerComposerInstall } from "./composerInstall"; + +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +/** The banner hook of the file at `path`, as an open editor renders it. */ +const render = (path: string) => { + const { useBanner } = useEditorBanners.getState().banners.get("php.composerInstall")!; + return renderHook(() => useBanner(path)); +}; + +let dispose: () => void; +beforeEach(() => { + useProject.setState({ project: { root: "/p", trusted: true } as never }); + ipc.sdkPhpComposerInstall.mockImplementation((path: string) => + ok(path.startsWith("/p/app/") ? { root: "/p/app", reason: "missing" } : null), + ); + ipc.runApplySettings.mockImplementation((configs: RunConfig[]) => + ok(configs.map((c) => ({ ...c, env: { PATH: "/php/bin" } }))), + ); + dispose = registerComposerInstall(); +}); +afterEach(() => { + cleanup(); + dispose(); + vi.clearAllMocks(); + useProject.setState({ project: null }); +}); + +it("offers composer install above the PHP files and composer.json of a project without vendor/", async () => { + const php = render("/p/app/src/Kernel.php"); + await waitFor(() => expect(php.result.current?.text).toBe("Composer dependencies are not installed")); + const json = render("/p/app/composer.json"); + await waitFor(() => expect(json.result.current?.actions?.map((a) => a.label)).toEqual(["Install", "Ignore"])); + expect(render("/p/app/README.md").result.current).toBeNull(); + expect(ipc.sdkPhpComposerInstall).not.toHaveBeenCalledWith("/p/app/README.md"); + + act(() => php.result.current!.actions![0]!.run()); + await waitFor(() => expect(run.startRun).toHaveBeenCalled()); + // Settings apply first, so the configured PHP is on PATH. + expect(ipc.runApplySettings).toHaveBeenCalledWith([composerInstallConfig("/p/app")]); + expect(run.startRun.mock.calls[0]![0]).toMatchObject({ command: "composer", args: ["install"], cwd: "/p/app" }); + expect(run.startRun.mock.calls[0]![0].env).toEqual({ PATH: "/php/bin" }); +}); + +it("hides the banner once ignored, and in an untrusted project", async () => { + const php = render("/p/app/src/Kernel.php"); + await waitFor(() => expect(php.result.current).not.toBeNull()); + act(() => php.result.current!.actions![1]!.run()); + expect(php.result.current).toBeNull(); + expect(render("/p/app/composer.json").result.current).toBeNull(); + + cleanup(); + dispose(); + useProject.setState({ project: { root: "/q", trusted: false } as never }); + dispose = registerComposerInstall(); + expect(render("/q/index.php").result.current).toBeNull(); + expect(ipc.sdkPhpComposerInstall).not.toHaveBeenCalledWith("/q/index.php"); +}); diff --git a/src/features/run/composerInstall.ts b/src/features/run/composerInstall.ts new file mode 100644 index 00000000..521edb0f --- /dev/null +++ b/src/features/run/composerInstall.ts @@ -0,0 +1,149 @@ +/** + * "Composer dependencies are not installed" above the PHP files and `composer.json` of a Composer + * project without `vendor/`, or whose installed packages differ from `composer.lock`, with Install + * (`composer install` on the configured PHP) and Ignore (until the project is reopened). + */ +import { useEffect } from "react"; +import { create } from "zustand"; +import { registerActions } from "@/platform/actions"; +import { call, commands, isTauri, onIdeEvent, type ComposerInstallState, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { maybeEditorService, registerEditorBanner } from "@/workbench/contributions"; +import { startRun } from "./store"; + +interface ComposerState { + /** By file: its Composer project needing an install, `null` when none does. */ + byPath: Map; + ignored: boolean; + installing: Set; +} + +const initial = (): ComposerState => ({ byPath: new Map(), ignored: false, installing: new Set() }); +const useComposer = create(initial); + +const requested = new Set(); + +function isComposerFile(path: string) { + return /\.(php|phtml)$/i.test(path) || path.endsWith("/composer.json"); +} + +function check(path: string) { + if (requested.has(path) || !isTauri()) return; + requested.add(path); + const root = useProject.getState().project?.root; + void call(commands.sdkPhpComposerInstall(path)) + .then((state) => { + if (useProject.getState().project?.root !== root || !requested.has(path)) return; + useComposer.setState((s) => ({ byPath: new Map(s.byPath).set(path, state) })); + }) + .catch(() => requested.delete(path)); +} + +/** Checks every open file again (after `composer.json`, `composer.lock` or the vendor folder changed). */ +function recheck() { + const paths = [...useComposer.getState().byPath.keys()]; + requested.clear(); + for (const path of paths) check(path); +} + +export function composerInstallConfig(root: string): RunConfig { + return { + id: `composer:install:${root}`, + name: "composer install", + kind: "composer", + command: "composer", + args: ["install"], + cwd: root, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + }; +} + +async function install(root: string) { + if (useComposer.getState().installing.has(root)) return; + useComposer.setState((s) => ({ installing: new Set(s.installing).add(root) })); + const done = () => + useComposer.setState((s) => { + const installing = new Set(s.installing); + installing.delete(root); + return { installing }; + }); + try { + // Puts the PHP of Settings › PHP first on PATH. + const [config] = await call(commands.runApplySettings([composerInstallConfig(root)])); + await startRun(config ?? composerInstallConfig(root), undefined, undefined, { + onExit: () => { + done(); + recheck(); + }, + }); + } catch (e) { + done(); + throw e; + } +} + +function useComposerBanner(path: string) { + const relevant = isComposerFile(path); + const trusted = useProject((s) => !!s.project?.trusted); + const state = useComposer((s) => (relevant && trusted && !s.ignored ? s.byPath.get(path) : null)); + const installing = useComposer((s) => !!state && s.installing.has(state.root)); + useEffect(() => { + if (relevant && trusted) check(path); + }, [path, relevant, trusted]); + if (!state || installing) return null; + return { + text: + state.reason === "missing" + ? "Composer dependencies are not installed" + : "Installed Composer packages differ from composer.lock", + level: "warning" as const, + actions: [ + { label: "Install", run: () => void install(state.root) }, + { label: "Ignore", run: () => useComposer.setState({ ignored: true }) }, + ], + }; +} + +const WATCHED = /(^|\/)(composer\.json|composer\.lock)$|\/vendor(\/|$)/; + +function reset() { + requested.clear(); + useComposer.setState(initial()); +} + +export function registerComposerInstall(): () => void { + reset(); + const disposers = [ + registerEditorBanner({ id: "php.composerInstall", order: 10, useBanner: useComposerBanner }), + registerActions([ + { + id: "php.composerInstall", + title: "Composer Install", + category: "PHP", + when: "projectOpen", + run: async ({ arg }) => { + const path = typeof arg === "string" ? arg : (maybeEditorService()?.activePath() ?? ""); + const root = useProject.getState().project?.root; + if (!root) return; + const state = path ? await call(commands.sdkPhpComposerInstall(path)).catch(() => null) : null; + await install(state?.root ?? root); + }, + }, + ]), + onIdeEvent("fsChanged", (e) => { + if ([...e.created, ...e.modified, ...e.removed].some((p) => WATCHED.test(p))) recheck(); + }), + onIdeEvent("excludedDirsChanged", (e) => { + if (e.dirs.some((d) => WATCHED.test(d))) recheck(); + }), + onIdeEvent("projectTrustChanged", () => recheck()), + useProject.subscribe((s, prev) => { + if (s.project?.root === prev.project?.root) return; + reset(); + }), + ]; + return () => disposers.forEach((d) => d()); +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 10ffa611..c6c4d3e3 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -37,6 +37,7 @@ import { RunDialogsOverlay } from "./dialogs"; import { registerExternalTools } from "./externalTools"; import { EditConfigsOverlay } from "./EditConfigsDialog"; import { openImport } from "./importConfigs"; +import { registerComposerInstall } from "./composerInstall"; import { registerGoActions } from "./goActions"; import { invalidateGoModules } from "./goModules"; import { invalidatePythonPackages } from "./pythonPackages"; @@ -292,6 +293,7 @@ export function activate(): () => void { registerRunAnythingProvider(runConfigsRunAnything), registerBuiltinTypes(), registerGoActions(), + registerComposerInstall(), registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }), registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }), registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e62b76bb..d24ee23e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6296,5 +6296,8 @@ "Connect a device over USB, or start an emulator. Logcat uses the Android SDK's adb.": "通过 USB 连接设备,或启动模拟器。Logcat 使用 Android SDK 的 adb。", "No lines match the filter": "没有符合过滤条件的日志行", "Streaming": "正在接收", - "Idle": "空闲" + "Idle": "空闲", + "Composer dependencies are not installed": "未安装 Composer 依赖", + "Installed Composer packages differ from composer.lock": "已安装的 Composer 包与 composer.lock 不一致", + "Composer Install": "安装 Composer 依赖" }