mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00: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.
This commit is contained in:
@@ -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<Option<ComposerInstallState>> {
|
||||
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<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,43 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
.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::<Value>(&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<Version> {
|
||||
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"), "<?php").unwrap();
|
||||
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
|
||||
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
|
||||
std::fs::write(app.join("composer.lock"), lock).unwrap();
|
||||
let installed = |packages: &str, dev: bool| {
|
||||
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
|
||||
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
|
||||
composer_install(&file, root)
|
||||
};
|
||||
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
|
||||
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
|
||||
assert_eq!(installed(&format!("{a},{t}"), true), None);
|
||||
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
|
||||
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
|
||||
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
|
||||
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
|
||||
// Composer 1 lists the packages at the top level.
|
||||
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
|
||||
assert_eq!(composer_install(&file, root), None);
|
||||
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
|
||||
}
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<Option<ide_host::ops::sdk::ComposerInstallState>> {
|
||||
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]
|
||||
|
||||
@@ -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
Block a user