дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611)
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.
Цей коміт міститься в:
1 джерело
7ad02da722
коміт
8689271e7c
9 files changed
+361
-1
No files matched your search
@@ -776,6 +776,11 @@ export const commands = {
|
||||
sdkPhpEffective: () => typedError<string | null, IdeError>(__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<PhpLanguageLevels, IdeError>(__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 = {
|
||||
/**
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>()),
|
||||
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 = <T,>(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");
|
||||
});
|
||||
@@ -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<string, ComposerInstallState | null>;
|
||||
ignored: boolean;
|
||||
installing: Set<string>;
|
||||
}
|
||||
|
||||
const initial = (): ComposerState => ({ byPath: new Map(), ignored: false, installing: new Set() });
|
||||
const useComposer = create<ComposerState>(initial);
|
||||
|
||||
const requested = new Set<string>();
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 依赖"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача