Skip to content

Commit 694acd8

Browse files
authored
Merge pull request #3 from Ximiaw/feat/new-project
feat: add mcpp.newProject command to scaffold and open projects
2 parents 1a8ae33 + bfbaffe commit 694acd8

7 files changed

Lines changed: 334 additions & 0 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"onCommand:mcpp.refreshCompilationDatabase",
2929
"onCommand:mcpp.checkModuleSupport",
3030
"onCommand:mcpp.showMenu",
31+
"onCommand:mcpp.newProject",
3132
"onCommand:mcpp.build",
3233
"onCommand:mcpp.run",
3334
"onCommand:mcpp.test",
@@ -68,6 +69,10 @@
6869
"command": "mcpp.showMenu",
6970
"title": "mcpp: 打开快捷菜单"
7071
},
72+
{
73+
"command": "mcpp.newProject",
74+
"title": "mcpp: 新建工程"
75+
},
7176
{
7277
"command": "mcpp.build",
7378
"title": "mcpp: 构建"

src/cliController.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { existsSync } from "node:fs";
2+
import { join } from "node:path";
13
import process from "node:process";
24

35
import * as vscode from "vscode";
@@ -23,6 +25,7 @@ import {
2325
type TaskCompletion,
2426
} from "./tasks";
2527
import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands";
28+
import { runNewProjectFlow, validateNewProjectName } from "./newProject";
2629

2730
export interface McppCliControllerOptions {
2831
output: vscode.OutputChannel;
@@ -82,6 +85,7 @@ export class McppCliController {
8285
const disposables: vscode.Disposable[] = [
8386
this.status,
8487
vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())),
88+
vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())),
8589
vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))),
8690
vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))),
8791
vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))),
@@ -493,6 +497,57 @@ export class McppCliController {
493497
}
494498
}
495499

500+
public async newProject(): Promise<void> {
501+
if (!this.requireTrusted()) {
502+
return;
503+
}
504+
505+
const input = await vscode.window.showInputBox({
506+
title: "新建 mcpp 工程(1/2)",
507+
prompt: "输入项目名,将在所选位置创建同名项目文件夹",
508+
placeHolder: "hello-mcpp",
509+
validateInput: validateNewProjectName,
510+
});
511+
if (input === undefined) {
512+
return;
513+
}
514+
const projectName = input.trim();
515+
516+
const picked = await vscode.window.showOpenDialog({
517+
title: "选择项目位置(2/2)",
518+
canSelectFiles: false,
519+
canSelectFolders: true,
520+
canSelectMany: false,
521+
openLabel: "在此创建项目",
522+
});
523+
const location = picked?.[0];
524+
if (location === undefined) {
525+
return;
526+
}
527+
528+
const projectRoot = join(location.fsPath, projectName);
529+
const confirmCreate = "创建并打开";
530+
await runNewProjectFlow(projectName, location.fsPath, projectRoot, {
531+
exists: existsSync,
532+
confirm: async (message) =>
533+
(await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate))
534+
=== confirmCreate,
535+
run: async (name, cwd) => {
536+
const executable = this.mcppExecutable(undefined);
537+
const args = mcppCommandArguments("new", name);
538+
const result = await runProcess(executable, args, cwd);
539+
this.appendShortCommand("新建工程", executable, args, result);
540+
return result.exitCode;
541+
},
542+
openFolder: async (path) => {
543+
await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path));
544+
},
545+
showError: async (message) => {
546+
await vscode.window.showErrorMessage(message);
547+
},
548+
});
549+
}
550+
496551
private guarded(operation: () => Promise<void>): () => Promise<void> {
497552
return async () => {
498553
try {

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const CLI_COMMANDS = {
22
showMenu: "mcpp.showMenu",
3+
newProject: "mcpp.newProject",
34
build: "mcpp.build",
45
run: "mcpp.run",
56
test: "mcpp.test",

src/newProject.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
export interface NewProjectActions {
2+
exists(path: string): boolean;
3+
confirm(message: string): Promise<boolean>;
4+
run(name: string, cwd: string): Promise<number>;
5+
openFolder(path: string): Promise<void>;
6+
showError(message: string): Promise<void> | void;
7+
}
8+
9+
export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened";
10+
11+
/**
12+
* 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程——
13+
* 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程),
14+
* 避免与缺少 CDB 时的 configure 重复执行。
15+
*/
16+
export async function runNewProjectFlow(
17+
projectName: string,
18+
location: string,
19+
projectRoot: string,
20+
actions: NewProjectActions,
21+
): Promise<NewProjectOutcome> {
22+
if (actions.exists(projectRoot)) {
23+
await actions.showError(`目标路径已存在:${projectRoot}。请更换项目名或位置。`);
24+
return "exists";
25+
}
26+
const confirmed = await actions.confirm(
27+
`将在 ${location} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`,
28+
);
29+
if (!confirmed) {
30+
return "declined";
31+
}
32+
const exitCode = await actions.run(projectName, location);
33+
if (exitCode !== 0) {
34+
await actions.showError(
35+
`mcpp new ${projectName} 失败(退出码 ${exitCode})。请查看 mcpp 输出频道。`,
36+
);
37+
return "failed";
38+
}
39+
await actions.openFolder(projectRoot);
40+
return "opened";
41+
}
42+
43+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
44+
const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/;
45+
const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
46+
const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT";
47+
48+
/**
49+
* 新建工程的项目名校验。返回错误提示;undefined 表示合法。
50+
*
51+
* 项目名随后作为 `mcpp new <name>` 的 argv 传入:参数数组只能防 shell 注入,
52+
* 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝
53+
* `-` 前缀以及 `.`、`..`。
54+
*
55+
* mcpp 模板把项目名直接写进 mcpp.toml 的 `name = "{}"` 和 main.cpp,不做
56+
* TOML/C++ 转义,所以拒绝双引号和控制字符;Windows 保留字符、保留设备名和
57+
* 尾随点一并按跨平台策略拒绝。根本修复应在 mcpp CLI 自身完成。
58+
*/
59+
export function validateNewProjectName(input: string): string | undefined {
60+
const name = input.trim();
61+
if (name.length === 0) {
62+
return "项目名不能为空";
63+
}
64+
if (/[\\/]/.test(name)) {
65+
return "项目名不能包含路径分隔符";
66+
}
67+
if (name.startsWith("-")) {
68+
return "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项";
69+
}
70+
if (name === "." || name === "..") {
71+
return "项目名不能是 . 或 ..";
72+
}
73+
// mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。
74+
if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) {
75+
return "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷";
76+
}
77+
if (CONTROL_CHARS.test(name)) {
78+
return "项目名不能包含控制字符";
79+
}
80+
if (WINDOWS_RESERVED_CHARS.test(name)) {
81+
return '项目名不能包含 <>:"|?* 等保留字符';
82+
}
83+
if (name.endsWith(".")) {
84+
return "项目名不能以 . 结尾(Windows 不支持)";
85+
}
86+
if (WINDOWS_DEVICE_NAMES.test(name)) {
87+
return "项目名不能是 Windows 保留设备名";
88+
}
89+
return undefined;
90+
}

test/artifacts.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ test("declares the official clangd dependency and mcpp commands", () => {
3939
manifest.contributes?.commands?.map((command) => command.command),
4040
[
4141
"mcpp.showMenu",
42+
"mcpp.newProject",
4243
"mcpp.build",
4344
"mcpp.run",
4445
"mcpp.test",
@@ -231,6 +232,35 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => {
231232
assert.match(method, / target .* mcpp /s);
232233
});
233234

235+
test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => {
236+
const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8");
237+
const start = source.indexOf("public async newProject");
238+
const end = source.indexOf("private guarded", start);
239+
assert.notEqual(start, -1);
240+
assert.notEqual(end, -1);
241+
242+
// 控制流本身由 test/newProject.test.ts 对 runNewProjectFlow 的行为级测试覆盖;
243+
// 这里只验证控制器把 UI/进程依赖注入流程函数。
244+
const method = source.slice(start, end);
245+
assert.match(method, /validateNewProjectName/);
246+
assert.match(method, /runNewProjectFlow/);
247+
const flow = method.indexOf("runNewProjectFlow");
248+
const exists = method.indexOf("existsSync", flow);
249+
const confirm = method.indexOf("showWarningMessage", flow);
250+
const create = method.indexOf("runProcess", flow);
251+
const open = method.indexOf('executeCommand("vscode.openFolder"', flow);
252+
assert.ok(exists >= 0 && exists < confirm);
253+
assert.ok(confirm >= 0 && confirm < create);
254+
assert.ok(create >= 0 && create < open);
255+
});
256+
257+
test("新建工程契约是创建并打开,不自动构建", () => {
258+
const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8");
259+
const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8");
260+
assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/);
261+
assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/);
262+
});
263+
234264
test("声明 GitHub 仓库和扩展图标", () => {
235265
const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest;
236266
assert.equal(manifest.icon, "images/logo.png");

test/commands.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ test("状态栏快捷菜单名称与模块状态易于区分", () => {
1010
test("CLI 命令覆盖项目、工具链和 IDE", () => {
1111
assert.deepEqual(Object.values(CLI_COMMANDS), [
1212
"mcpp.showMenu",
13+
"mcpp.newProject",
1314
"mcpp.build",
1415
"mcpp.run",
1516
"mcpp.test",

0 commit comments

Comments
 (0)