Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'editors/code/src/new_project.ts')
| -rw-r--r-- | editors/code/src/new_project.ts | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/editors/code/src/new_project.ts b/editors/code/src/new_project.ts new file mode 100644 index 0000000000..f5f6d86da0 --- /dev/null +++ b/editors/code/src/new_project.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode"; + +import type { Ctx, Cmd } from "./ctx"; +import * as ra from "./lsp_ext"; +import { cargoPath } from "./toolchain"; +import { log, spawnAsync } from "./util"; + +type NewProjectKind = "bin" | "lib"; +type NewProjectOpenAction = "open" | "openNewWindow" | "addToWorkspace"; + +type NewProjectTemplate = { + detail: string; + id: NewProjectKind; + label: string; +}; + +type NewProjectCargo = { + cargo: string; + cargoEnv: NodeJS.ProcessEnv; +}; + +const NEW_PROJECT_TEMPLATES: readonly NewProjectTemplate[] = [ + { + id: "bin", + label: "Binary Application", + detail: "Create a Cargo binary package (`cargo new --bin`)", + }, + { + id: "lib", + label: "Library", + detail: "Create a Cargo library package (`cargo new --lib`)", + }, +] as const; + +export function newProject(ctx: Ctx): Cmd { + return async () => { + const cargo = await resolveNewProjectCargo(ctx); + + const selectedKind = await promptForNewProjectTemplate(); + if (!selectedKind) { + return; + } + + const parentFolder = await promptForNewProjectParentFolder(); + if (!parentFolder) { + return; + } + + const projectName = await promptForNewProjectName(parentFolder); + if (!projectName) { + return; + } + + if (!(await createNewProject(cargo, parentFolder, selectedKind, projectName))) { + return; + } + + const projectUri = vscode.Uri.joinPath(parentFolder, projectName); + const defaultAction = determineNewProjectOpenAction( + ctx.config.projectCreationOpenAfterCreate, + Boolean(vscode.workspace.workspaceFolders?.length), + ); + const action = + defaultAction === "ask" + ? await promptForNewProjectOpenAction( + projectName, + Boolean(vscode.workspace.workspaceFolders?.length), + ) + : defaultAction; + + if (action) { + await executeNewProjectOpenAction(ctx, action, projectUri); + } + }; +} + +async function resolveNewProjectCargo(ctx: Ctx): Promise<NewProjectCargo> { + // Use the same effective environment rust-analyzer uses elsewhere so project creation sees + // toolchain wrappers, PATH overrides, and CARGO_HOME changes from configuration. + const cargoEnv = { ...process.env, ...ctx.config.serverExtraEnv }; + return { cargo: await cargoPath(cargoEnv), cargoEnv }; +} + +async function promptForNewProjectTemplate(): Promise<NewProjectKind | undefined> { + const selected = await vscode.window.showQuickPick(NEW_PROJECT_TEMPLATES, { + placeHolder: "Select a Rust project kind", + }); + return selected?.id; +} + +async function promptForNewProjectParentFolder(): Promise<vscode.Uri | undefined> { + const selectedFolder = await vscode.window.showOpenDialog({ + title: "Select the parent folder for the new Rust project", + openLabel: "Select parent folder", + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }); + if (!selectedFolder?.length) { + return undefined; + } + return selectedFolder[0]; +} + +const CARGO_MANIFEST_NAME_PATTERN = /^[\p{Alphabetic}\p{Number}_-]+$/u; + +// Keep local validation focused on stable checks that can be reported in the input box, then let +// `cargo new` remain the source of truth for package-name-specific identifier and keyword rules. +export function validateNewProjectName( + value: string, + existingNames: readonly string[], +): string | undefined { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) { + return "Project name cannot be empty."; + } + if (trimmedValue.includes("/") || trimmedValue.includes("\\")) { + return "Project name cannot contain '/' or '\\' characters."; + } + if (trimmedValue === "." || trimmedValue === "..") { + return "Project name cannot be '.' or '..'."; + } + if (!CARGO_MANIFEST_NAME_PATTERN.test(trimmedValue)) { + return "Project name can contain only alphanumeric characters, '-' or '_'."; + } + if (existingNames.includes(trimmedValue)) { + return "A file or folder with this name already exists."; + } + return undefined; +} + +async function promptForNewProjectName(parentFolder: vscode.Uri): Promise<string | undefined> { + let existingNames: string[] = []; + try { + const entries = await vscode.workspace.fs.readDirectory(parentFolder); + existingNames = entries.map(([name]) => name); + } catch (error) { + log.error("Failed to read project parent folder", error); + void vscode.window.showErrorMessage("Failed to read the selected parent folder."); + return undefined; + } + + const projectName = await vscode.window.showInputBox({ + prompt: `Enter the new project name to create inside ${parentFolder.fsPath}`, + validateInput: async (value) => validateNewProjectName(value, existingNames), + }); + return projectName?.trim(); +} + +export function cargoNewArgs(kind: NewProjectKind, name: string): string[] { + return ["new", kind === "bin" ? "--bin" : "--lib", name]; +} + +async function createNewProject( + cargo: NewProjectCargo, + parentFolder: vscode.Uri, + kind: NewProjectKind, + projectName: string, +): Promise<boolean> { + const args = cargoNewArgs(kind, projectName); + const createResult = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creating Rust project ${projectName}`, + }, + async () => + spawnAsync(cargo.cargo, args, { + cwd: parentFolder.fsPath, + env: cargo.cargoEnv, + }), + ); + + if (createResult.error || createResult.status !== 0) { + const details = formatProcessDetails(createResult); + await showNewProjectError("Failed to create Rust project.", details || undefined, { + cargo: cargo.cargo, + args, + cwd: parentFolder.fsPath, + error: createResult.error?.message, + status: createResult.status, + stderr: createResult.stderr || undefined, + stdout: createResult.stdout || undefined, + }); + return false; + } + + return true; +} + +function formatProcessDetails(result: { error?: Error; stderr: string; stdout: string }): string { + return [result.stderr, result.stdout, result.error?.message] + .filter((value): value is string => Boolean(value && value.trim().length > 0)) + .join("\n") + .trim(); +} + +async function showNewProjectError( + message: string, + details: string | undefined, + logContext: { + cargo: string; + args: string[]; + cwd?: string; + error?: string; + status: number | null; + stderr?: string; + stdout?: string; + }, +): Promise<void> { + // Keep command-failure logging focused on the invocation and process output. Environment + // variables may contain secrets such as API keys, tokens, and credentials, so failure logs + // must not dump the merged env here. + const commandLine = [logContext.cargo, ...logContext.args].join(" "); + log.error(message); + log.error(`command: ${commandLine}`); + if (logContext.cwd) { + log.error(`cwd: ${logContext.cwd}`); + } + log.error(`exit status: ${String(logContext.status)}`); + if (logContext.error) { + log.error(`error: ${logContext.error}`); + } + if (logContext.stderr) { + log.error(`stderr:\n${logContext.stderr}`); + } + if (logContext.stdout) { + log.error(`stdout:\n${logContext.stdout}`); + } + const selection = await vscode.window.showErrorMessage( + details ? `${message}\n${details}` : message, + "Open Extension Logs", + ); + if (selection === "Open Extension Logs") { + log.show(); + } +} + +export function determineNewProjectOpenAction( + configuredAction: string | undefined, + hasWorkspaceFolders: boolean, +): "ask" | NewProjectOpenAction { + switch (configuredAction) { + case "open": + case "openNewWindow": + return configuredAction; + case "addToWorkspace": + // Adding to a workspace only makes sense when one is already open. Falling back to + // "open" keeps the setting usable in empty windows without adding another prompt path. + return hasWorkspaceFolders ? configuredAction : "open"; + default: + return "ask"; + } +} + +async function promptForNewProjectOpenAction( + projectName: string, + hasWorkspaceFolders: boolean, +): Promise<NewProjectOpenAction | undefined> { + let message = `Would you like to open ${projectName}?`; + const open = "Open"; + const openNewWindow = "Open in New Window"; + const choices = [open, openNewWindow]; + + const addToWorkspace = "Add to VS Code Workspace"; + if (hasWorkspaceFolders) { + message = `Would you like to open ${projectName}, or add it to the current VS Code workspace?`; + choices.push(addToWorkspace); + } + + const result = await vscode.window.showInformationMessage( + message, + { modal: true, detail: "The default action can be configured in settings." }, + ...choices, + ); + + const actionMap: Record<string, NewProjectOpenAction> = { + [open]: "open", + [openNewWindow]: "openNewWindow", + [addToWorkspace]: "addToWorkspace", + }; + return result ? actionMap[result] : undefined; +} + +async function executeNewProjectOpenAction( + ctx: Ctx, + action: NewProjectOpenAction, + projectUri: vscode.Uri, +): Promise<void> { + if (action === "open") { + await vscode.commands.executeCommand("vscode.openFolder", projectUri, { + forceReuseWindow: true, + }); + return; + } + + if (action === "openNewWindow") { + await vscode.commands.executeCommand("vscode.openFolder", projectUri, { + forceNewWindow: true, + }); + return; + } + + const index = vscode.workspace.workspaceFolders?.length ?? 0; + vscode.workspace.updateWorkspaceFolders(index, 0, { uri: projectUri }); + // Reuse the existing workspace window when requested, but nudge rust-analyzer afterwards so + // the newly added Cargo project is discovered immediately instead of waiting for a later + // background refresh. + if (ctx.client?.isRunning()) { + await ctx.client.sendRequest(ra.reloadWorkspace); + } +} |