Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"onCommand:mcpp.refreshCompilationDatabase",
"onCommand:mcpp.checkModuleSupport",
"onCommand:mcpp.showMenu",
"onCommand:mcpp.newProject",
"onCommand:mcpp.build",
"onCommand:mcpp.run",
"onCommand:mcpp.test",
Expand Down Expand Up @@ -68,6 +69,10 @@
"command": "mcpp.showMenu",
"title": "mcpp: 打开快捷菜单"
},
{
"command": "mcpp.newProject",
"title": "mcpp: 新建工程"
},
{
"command": "mcpp.build",
"title": "mcpp: 构建"
Expand Down
55 changes: 55 additions & 0 deletions src/cliController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";

import * as vscode from "vscode";
Expand All @@ -23,6 +25,7 @@ import {
type TaskCompletion,
} from "./tasks";
import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands";
import { runNewProjectFlow, validateNewProjectName } from "./newProject";

export interface McppCliControllerOptions {
output: vscode.OutputChannel;
Expand Down Expand Up @@ -82,6 +85,7 @@ export class McppCliController {
const disposables: vscode.Disposable[] = [
this.status,
vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())),
vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())),
vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))),
vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))),
vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))),
Expand Down Expand Up @@ -493,6 +497,57 @@ export class McppCliController {
}
}

public async newProject(): Promise<void> {
if (!this.requireTrusted()) {
return;
}

const input = await vscode.window.showInputBox({
title: "新建 mcpp 工程(1/2)",
prompt: "输入项目名,将在所选位置创建同名项目文件夹",
placeHolder: "hello-mcpp",
validateInput: validateNewProjectName,
});
if (input === undefined) {
return;
}
const projectName = input.trim();

const picked = await vscode.window.showOpenDialog({
title: "选择项目位置(2/2)",
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: "在此创建项目",
});
const location = picked?.[0];
if (location === undefined) {
return;
}

const projectRoot = join(location.fsPath, projectName);
const confirmCreate = "创建并打开";
await runNewProjectFlow(projectName, location.fsPath, projectRoot, {
exists: existsSync,
confirm: async (message) =>
(await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate))
=== confirmCreate,
run: async (name, cwd) => {
const executable = this.mcppExecutable(undefined);
const args = mcppCommandArguments("new", name);
const result = await runProcess(executable, args, cwd);
this.appendShortCommand("新建工程", executable, args, result);
return result.exitCode;
},
openFolder: async (path) => {
await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path));
},
showError: async (message) => {
await vscode.window.showErrorMessage(message);
},
});
}

private guarded(operation: () => Promise<void>): () => Promise<void> {
return async () => {
try {
Expand Down
1 change: 1 addition & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const CLI_COMMANDS = {
showMenu: "mcpp.showMenu",
newProject: "mcpp.newProject",
build: "mcpp.build",
run: "mcpp.run",
test: "mcpp.test",
Expand Down
90 changes: 90 additions & 0 deletions src/newProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export interface NewProjectActions {
exists(path: string): boolean;
confirm(message: string): Promise<boolean>;
run(name: string, cwd: string): Promise<number>;
openFolder(path: string): Promise<void>;
showError(message: string): Promise<void> | void;
}

export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened";

/**
* 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程——
* 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程),
* 避免与缺少 CDB 时的 configure 重复执行。
*/
export async function runNewProjectFlow(
projectName: string,
location: string,
projectRoot: string,
actions: NewProjectActions,
): Promise<NewProjectOutcome> {
if (actions.exists(projectRoot)) {
await actions.showError(`目标路径已存在:${projectRoot}。请更换项目名或位置。`);
return "exists";
}
const confirmed = await actions.confirm(
`将在 ${location} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`,
);
if (!confirmed) {
return "declined";
}
const exitCode = await actions.run(projectName, location);
if (exitCode !== 0) {
await actions.showError(
`mcpp new ${projectName} 失败(退出码 ${exitCode})。请查看 mcpp 输出频道。`,
);
return "failed";
}
await actions.openFolder(projectRoot);
return "opened";
}

const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/;
const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT";

/**
* 新建工程的项目名校验。返回错误提示;undefined 表示合法。
*
* 项目名随后作为 `mcpp new <name>` 的 argv 传入:参数数组只能防 shell 注入,
* 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝
* `-` 前缀以及 `.`、`..`。
*
* mcpp 模板把项目名直接写进 mcpp.toml 的 `name = "{}"` 和 main.cpp,不做
* TOML/C++ 转义,所以拒绝双引号和控制字符;Windows 保留字符、保留设备名和
* 尾随点一并按跨平台策略拒绝。根本修复应在 mcpp CLI 自身完成。
*/
export function validateNewProjectName(input: string): string | undefined {
const name = input.trim();
if (name.length === 0) {
return "项目名不能为空";
}
if (/[\\/]/.test(name)) {
return "项目名不能包含路径分隔符";
}
if (name.startsWith("-")) {
return "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项";
}
if (name === "." || name === "..") {
return "项目名不能是 . 或 ..";
}
// mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。
if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) {
return "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷";
}
if (CONTROL_CHARS.test(name)) {
return "项目名不能包含控制字符";
}
if (WINDOWS_RESERVED_CHARS.test(name)) {
return '项目名不能包含 <>:"|?* 等保留字符';
}
if (name.endsWith(".")) {
return "项目名不能以 . 结尾(Windows 不支持)";
}
if (WINDOWS_DEVICE_NAMES.test(name)) {
return "项目名不能是 Windows 保留设备名";
}
return undefined;
}
30 changes: 30 additions & 0 deletions test/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ test("declares the official clangd dependency and mcpp commands", () => {
manifest.contributes?.commands?.map((command) => command.command),
[
"mcpp.showMenu",
"mcpp.newProject",
"mcpp.build",
"mcpp.run",
"mcpp.test",
Expand Down Expand Up @@ -231,6 +232,35 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => {
assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s);
});

test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => {
const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8");
const start = source.indexOf("public async newProject");
const end = source.indexOf("private guarded", start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);

// 控制流本身由 test/newProject.test.ts 对 runNewProjectFlow 的行为级测试覆盖;
// 这里只验证控制器把 UI/进程依赖注入流程函数。
const method = source.slice(start, end);
assert.match(method, /validateNewProjectName/);
assert.match(method, /runNewProjectFlow/);
const flow = method.indexOf("runNewProjectFlow");
const exists = method.indexOf("existsSync", flow);
const confirm = method.indexOf("showWarningMessage", flow);
const create = method.indexOf("runProcess", flow);
const open = method.indexOf('executeCommand("vscode.openFolder"', flow);
assert.ok(exists >= 0 && exists < confirm);
assert.ok(confirm >= 0 && confirm < create);
assert.ok(create >= 0 && create < open);
});

test("新建工程契约是创建并打开,不自动构建", () => {
const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8");
const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8");
assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/);
assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/);
});

test("声明 GitHub 仓库和扩展图标", () => {
const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest;
assert.equal(manifest.icon, "images/logo.png");
Expand Down
1 change: 1 addition & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ test("状态栏快捷菜单名称与模块状态易于区分", () => {
test("CLI 命令覆盖项目、工具链和 IDE", () => {
assert.deepEqual(Object.values(CLI_COMMANDS), [
"mcpp.showMenu",
"mcpp.newProject",
"mcpp.build",
"mcpp.run",
"mcpp.test",
Expand Down
Loading