From 074be8bf2bc6b64be56ec8242ec45f650d9d510b Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 2 Mar 2026 23:24:51 -0500 Subject: [PATCH 1/2] VS Code extension: scaffold generation with context-aware UX Add "New from Scaffold..." command to the VS Code extension, accessible from the command palette, explorer context menu, and File > New File... menu. Features: - Multi-step Quick Pick wizard mapping scaffold parameter types to VS Code UI - Context-aware cartridge detection: right-clicking inside a cartridge auto-fills the cartridge parameter and filters the scaffold list to relevant templates - Editor context: command palette uses the active editor's file path for detection - Built-in scaffold data copied into extension bundle at build time SDK changes: - Add detectSourceFromPath() for generalized source detection from filesystem paths - Export cartridgePathForDestination() (moved from parameter-resolver to sources) - Add builtInScaffoldsDir option to createScaffoldRegistry() for bundled consumers - Export ScaffoldRegistryOptions and SourceDetectionResult types --- .changeset/vscode-scaffold-generation.md | 5 + .../b2c-tooling-sdk/src/scaffold/index.ts | 4 + .../src/scaffold/parameter-resolver.ts | 25 +- .../b2c-tooling-sdk/src/scaffold/registry.ts | 25 +- .../b2c-tooling-sdk/src/scaffold/sources.ts | 74 +++- packages/b2c-vs-extension/package.json | 27 +- .../scripts/esbuild-bundle.mjs | 13 + packages/b2c-vs-extension/src/extension.ts | 4 + .../b2c-vs-extension/src/scaffold/index.ts | 21 + .../src/scaffold/scaffold-commands.ts | 405 ++++++++++++++++++ 10 files changed, 579 insertions(+), 24 deletions(-) create mode 100644 .changeset/vscode-scaffold-generation.md create mode 100644 packages/b2c-vs-extension/src/scaffold/index.ts create mode 100644 packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts diff --git a/.changeset/vscode-scaffold-generation.md b/.changeset/vscode-scaffold-generation.md new file mode 100644 index 00000000..be5200dd --- /dev/null +++ b/.changeset/vscode-scaffold-generation.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `detectSourceFromPath()` for context-aware scaffold parameter detection, `cartridgePathForDestination()` export, and `builtInScaffoldsDir` option on `createScaffoldRegistry()` for bundled consumers diff --git a/packages/b2c-tooling-sdk/src/scaffold/index.ts b/packages/b2c-tooling-sdk/src/scaffold/index.ts index ae5c6ca9..ba4c01e7 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/index.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/index.ts @@ -100,10 +100,14 @@ export { resolveRemoteSource, isRemoteSource, validateAgainstSource, + cartridgePathForDestination, + detectSourceFromPath, } from './sources.js'; +export type {SourceDetectionResult} from './sources.js'; // Registry export {ScaffoldRegistry, createScaffoldRegistry} from './registry.js'; +export type {ScaffoldRegistryOptions} from './registry.js'; // Engine export { diff --git a/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts index 1d617d42..811f9252 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts @@ -4,11 +4,16 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import path from 'node:path'; import type {B2CInstance} from '../instance/index.js'; import type {Scaffold, ScaffoldParameter, ScaffoldChoice} from './types.js'; import {evaluateCondition} from './validators.js'; -import {resolveLocalSource, resolveRemoteSource, isRemoteSource, validateAgainstSource} from './sources.js'; +import { + resolveLocalSource, + resolveRemoteSource, + isRemoteSource, + validateAgainstSource, + cartridgePathForDestination, +} from './sources.js'; /** * Options for resolving scaffold parameters. @@ -64,22 +69,6 @@ export interface ResolvedParameterSchema { warning?: string; } -/** - * Path to use for scaffold destination so files are generated under outputDir (e.g. working directory). - * Returns a path relative to projectRoot when the cartridge is under projectRoot, so the executor - * joins with outputDir instead of ignoring it. Otherwise returns the absolute path. - */ -function cartridgePathForDestination(absolutePath: string, projectRoot: string): string { - const normalizedRoot = path.resolve(projectRoot); - const normalizedPath = path.resolve(absolutePath); - const relative = path.relative(normalizedRoot, normalizedPath); - // Use relative path only when cartridge is under projectRoot (no leading '..') - if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { - return relative; - } - return absolutePath; -} - /** * Resolve scaffold parameters by: * 1. Validating provided variables against sources diff --git a/packages/b2c-tooling-sdk/src/scaffold/registry.ts b/packages/b2c-tooling-sdk/src/scaffold/registry.ts index 022690b0..2805074c 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/registry.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/registry.ts @@ -126,6 +126,18 @@ function filterScaffolds(scaffolds: Scaffold[], options: ScaffoldDiscoveryOption return filtered; } +/** + * Options for creating a scaffold registry + */ +export interface ScaffoldRegistryOptions { + /** + * Override the built-in scaffolds directory. Useful for bundled environments + * (e.g. VS Code extensions) where the SDK's data files are copied to a + * different location. Defaults to the SDK's own `data/scaffolds/` directory. + */ + builtInScaffoldsDir?: string; +} + /** * Scaffold registry for discovering and managing scaffolds */ @@ -133,6 +145,11 @@ export class ScaffoldRegistry { private providers: ScaffoldProvider[] = []; private transformers: ScaffoldTransformer[] = []; private scaffoldCache: Map = new Map(); + private readonly builtInScaffoldsDir: string; + + constructor(options?: ScaffoldRegistryOptions) { + this.builtInScaffoldsDir = options?.builtInScaffoldsDir ?? SCAFFOLDS_DATA_DIR; + } /** * Add scaffold providers @@ -179,7 +196,7 @@ export class ScaffoldRegistry { } // 2. Built-in scaffolds (lowest priority for built-ins) - const builtInScaffolds = await discoverScaffoldsFromDir(SCAFFOLDS_DATA_DIR, 'built-in'); + const builtInScaffolds = await discoverScaffoldsFromDir(this.builtInScaffoldsDir, 'built-in'); allScaffolds.push(...builtInScaffolds); // 3. User scaffolds (~/.b2c/scaffolds/) @@ -258,7 +275,9 @@ export class ScaffoldRegistry { /** * Create a new scaffold registry instance + * + * @param options - Registry options (e.g. override built-in scaffolds directory) */ -export function createScaffoldRegistry(): ScaffoldRegistry { - return new ScaffoldRegistry(); +export function createScaffoldRegistry(options?: ScaffoldRegistryOptions): ScaffoldRegistry { + return new ScaffoldRegistry(options); } diff --git a/packages/b2c-tooling-sdk/src/scaffold/sources.ts b/packages/b2c-tooling-sdk/src/scaffold/sources.ts index cdd65921..b8be75ea 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/sources.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/sources.ts @@ -4,10 +4,12 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +import fs from 'node:fs'; +import path from 'node:path'; import {findCartridges} from '../operations/code/cartridges.js'; import type {B2CInstance} from '../instance/index.js'; import type {OcapiComponents} from '../clients/index.js'; -import type {ScaffoldChoice, DynamicParameterSource, SourceResult} from './types.js'; +import type {ScaffoldChoice, ScaffoldParameter, DynamicParameterSource, SourceResult} from './types.js'; /** * Common B2C Commerce hook extension points. @@ -129,3 +131,73 @@ export function validateAgainstSource( // For hook-points and other sources, no validation (allow any value) return {valid: true}; } + +/** + * Path to use for scaffold destination so files are generated under outputDir (e.g. working directory). + * Returns a path relative to projectRoot when the cartridge is under projectRoot, so the executor + * joins with outputDir instead of ignoring it. Otherwise returns the absolute path. + */ +export function cartridgePathForDestination(absolutePath: string, projectRoot: string): string { + const normalizedRoot = path.resolve(projectRoot); + const normalizedPath = path.resolve(absolutePath); + const relative = path.relative(normalizedRoot, normalizedPath); + // Use relative path only when cartridge is under projectRoot (no leading '..') + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + return absolutePath; +} + +/** + * Result of detecting a source parameter value from a filesystem path. + */ +export interface SourceDetectionResult { + /** The resolved parameter value (e.g., cartridge name) */ + value: string; + /** Companion variables to set (e.g., { cartridgeNamePath: "cartridges/app_custom" }) */ + companionVariables: Record; +} + +/** + * Detect a parameter's source value from a filesystem context path. + * + * For `cartridges` source: walks up from `contextPath` looking for a `.project` file + * (cartridge marker), stopping at projectRoot. On match returns the cartridge name and + * companion path variable. + * + * @param param - The scaffold parameter with a `source` field + * @param contextPath - Filesystem path providing context (e.g., right-clicked folder) + * @param projectRoot - Project root directory + * @returns Detection result, or undefined if the source could not be detected + */ +export function detectSourceFromPath( + param: ScaffoldParameter, + contextPath: string, + projectRoot: string, +): SourceDetectionResult | undefined { + if (param.source !== 'cartridges') { + return undefined; + } + + const normalizedRoot = path.resolve(projectRoot); + let current = path.resolve(contextPath); + + // Walk up from contextPath, checking for .project at each level + while (current.length >= normalizedRoot.length) { + const projectFile = path.join(current, '.project'); + if (fs.existsSync(projectFile)) { + const cartridgeName = path.basename(current); + const destPath = cartridgePathForDestination(current, projectRoot); + return { + value: cartridgeName, + companionVariables: {[`${param.name}Path`]: destPath}, + }; + } + + const parent = path.dirname(current); + if (parent === current) break; // filesystem root + current = parent; + } + + return undefined; +} diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 0209d8bb..27e84084 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -26,7 +26,8 @@ "onCommand:b2c-dx.promptAgent", "onCommand:b2c-dx.listWebDav", "onCommand:b2c-dx.scapiExplorer", - "onView:b2cSandboxExplorer" + "onView:b2cSandboxExplorer", + "onCommand:b2c-dx.scaffold.generate" ], "main": "./dist/extension.js", "contributes": { @@ -53,6 +54,11 @@ "default": true, "description": "Enable log tailing commands." }, + "b2c-dx.features.scaffold": { + "type": "boolean", + "default": true, + "description": "Enable scaffold generation commands." + }, "b2c-dx.logLevel": { "type": "string", "default": "info", @@ -329,6 +335,11 @@ "title": "Import Site Archive", "icon": "$(cloud-upload)", "category": "B2C DX" + }, + { + "command": "b2c-dx.scaffold.generate", + "title": "New from Scaffold...", + "category": "B2C DX" } ], "menus": { @@ -466,6 +477,12 @@ "group": "3_destructive@1" } ], + "file/newFile": [ + { + "command": "b2c-dx.scaffold.generate", + "group": "navigation" + } + ], "explorer/context": [ { "command": "b2c-dx.webdav.download", @@ -479,9 +496,15 @@ } ], "b2c-dx.submenu": [ + { + "command": "b2c-dx.scaffold.generate", + "when": "explorerResourceIsFolder", + "group": "1_scaffold" + }, { "command": "b2c-dx.content.import", - "when": "explorerResourceIsFolder" + "when": "explorerResourceIsFolder", + "group": "2_import" } ], "commandPalette": [ diff --git a/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs b/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs index 16d08c46..aec1ab0d 100644 --- a/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs +++ b/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs @@ -52,6 +52,17 @@ const REQUIRE_RESOLVE_PACKAGE_JSON_RE = /require\d*\.resolve\s*\(\s*["']@salesforce\/b2c-tooling-sdk\/package\.json["']\s*\)/g; const REQUIRE_RESOLVE_REPLACEMENT = "require('path').join(__dirname, 'package.json')"; +// Copy SDK scaffold templates into dist/ so the extension can find them at runtime. +// The extension passes this path explicitly via createScaffoldRegistry({ builtInScaffoldsDir }). +const sdkRoot = path.join(pkgRoot, '..', 'b2c-tooling-sdk'); + +function copySdkScaffolds() { + const src = path.join(sdkRoot, 'data', 'scaffolds'); + const dest = path.join(pkgRoot, 'dist', 'data', 'scaffolds'); + if (!fs.existsSync(src)) return; + fs.cpSync(src, dest, {recursive: true}); +} + function inlineSdkPackageJson() { const outPath = path.join(pkgRoot, 'dist', 'extension.js'); let str = fs.readFileSync(outPath, 'utf8'); @@ -83,6 +94,7 @@ const buildOptions = { }; if (watchMode) { + copySdkScaffolds(); const ctx = await esbuild.context(buildOptions); await ctx.watch(); console.log('[esbuild] watching for changes...'); @@ -90,6 +102,7 @@ if (watchMode) { const result = await esbuild.build(buildOptions); inlineSdkPackageJson(); + copySdkScaffolds(); if (result.metafile && process.env.ANALYZE_BUNDLE) { const metaPath = path.join(pkgRoot, 'dist', 'meta.json'); diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index c1d80cc5..1c26ff4a 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -18,6 +18,7 @@ import {registerContentTree} from './content-tree/index.js'; import {registerLogs} from './logs/index.js'; import {initializePlugins} from './plugins.js'; import {registerSandboxTree} from './sandbox-tree/index.js'; +import {registerScaffold} from './scaffold/index.js'; import {registerWebDavTree} from './webdav-tree/index.js'; function getWebviewContent(context: vscode.ExtensionContext): string { @@ -910,6 +911,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu if (settings.get('features.logTailing', true)) { registerLogs(context, configProvider); } + if (settings.get('features.scaffold', true)) { + registerScaffold(context, configProvider, log); + } // React to configuration changes const configChangeListener = vscode.workspace.onDidChangeConfiguration((e) => { diff --git a/packages/b2c-vs-extension/src/scaffold/index.ts b/packages/b2c-vs-extension/src/scaffold/index.ts new file mode 100644 index 00000000..dd94f306 --- /dev/null +++ b/packages/b2c-vs-extension/src/scaffold/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import path from 'node:path'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; +import {registerScaffoldCommands} from './scaffold-commands.js'; + +export function registerScaffold( + context: vscode.ExtensionContext, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, +): void { + const builtInScaffoldsDir = path.join(context.extensionPath, 'dist', 'data', 'scaffolds'); + log.appendLine(`[Scaffold] Built-in scaffolds dir: ${builtInScaffoldsDir}`); + const disposables = registerScaffoldCommands(configProvider, log, builtInScaffoldsDir); + context.subscriptions.push(...disposables); +} diff --git a/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts new file mode 100644 index 00000000..5ae259c9 --- /dev/null +++ b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import path from 'node:path'; +import * as vscode from 'vscode'; +import { + createScaffoldRegistry, + generateFromScaffold, + evaluateCondition, + detectSourceFromPath, + resolveLocalSource, + resolveRemoteSource, + isRemoteSource, + resolveOutputDirectory, + type Scaffold, + type ScaffoldParameter, + type ScaffoldChoice, + type ScaffoldGenerateResult, + type SourceResult, +} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {B2CExtensionConfig} from '../config-provider.js'; + +interface ScaffoldQuickPickItem extends vscode.QuickPickItem { + scaffold: Scaffold; +} + +interface ValueQuickPickItem extends vscode.QuickPickItem { + value: string; +} + +interface BooleanQuickPickItem extends vscode.QuickPickItem { + boolValue: boolean; +} + +export function registerScaffoldCommands( + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, + builtInScaffoldsDir: string, +): vscode.Disposable[] { + const generate = vscode.commands.registerCommand('b2c-dx.scaffold.generate', async (uri?: vscode.Uri) => { + try { + await runScaffoldWizard(uri, configProvider, log, builtInScaffoldsDir); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.appendLine(`[Scaffold] Error: ${message}`); + vscode.window.showErrorMessage(`Scaffold generation failed: ${message}`); + } + }); + + return [generate]; +} + +async function runScaffoldWizard( + uri: vscode.Uri | undefined, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, + builtInScaffoldsDir: string, +): Promise { + const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + log.appendLine(`[Scaffold] Starting wizard, projectRoot=${projectRoot}`); + + // Step 1: Discover and select scaffold + const registry = createScaffoldRegistry({builtInScaffoldsDir}); + const scaffolds = await vscode.window.withProgress( + {location: vscode.ProgressLocation.Window, title: 'Loading scaffolds...'}, + () => registry.getScaffolds({projectRoot}), + ); + + log.appendLine(`[Scaffold] Discovered ${scaffolds.length} scaffold(s)`); + for (const s of scaffolds) { + log.appendLine(`[Scaffold] ${s.id} (${s.source}) — ${s.path}`); + } + + if (scaffolds.length === 0) { + vscode.window.showWarningMessage('No scaffolds available.'); + return; + } + + // Determine context path: from URI (context menu) or active editor (command palette) + const contextPath = uri?.fsPath ?? vscode.window.activeTextEditor?.document.uri.fsPath ?? undefined; + if (contextPath) { + log.appendLine(`[Scaffold] Context path: ${contextPath}`); + } + + // Filter scaffolds based on context: inside a cartridge → show only cartridge-targeting scaffolds + const insideCartridge = contextPath + ? findCartridges(projectRoot).some((c) => contextPath === c.src || contextPath.startsWith(c.src + path.sep)) + : false; + + if (insideCartridge) { + log.appendLine('[Scaffold] Context is inside a cartridge — filtering scaffold list'); + } + + const filteredScaffolds = insideCartridge + ? scaffolds.filter((s) => s.manifest.parameters.some((p) => p.source === 'cartridges')) + : scaffolds; + + const displayScaffolds = filteredScaffolds.length > 0 ? filteredScaffolds : scaffolds; + + const scaffoldItems: ScaffoldQuickPickItem[] = displayScaffolds.map((s) => ({ + label: s.manifest.displayName, + description: s.manifest.category, + detail: s.manifest.description, + scaffold: s, + })); + + const picked = await vscode.window.showQuickPick(scaffoldItems, { + title: 'New from Scaffold', + placeHolder: 'Select a scaffold template', + matchOnDetail: true, + }); + + if (!picked) return; + const scaffold = picked.scaffold; + log.appendLine(`[Scaffold] Selected: ${scaffold.id}`); + + // Step 2: Pre-fill source parameters from context, then prompt for the rest + const resolvedVariables: Record = {}; + + let sourceDetected = false; + if (contextPath) { + for (const param of scaffold.manifest.parameters) { + if (!param.source) continue; + const detected = detectSourceFromPath(param, contextPath, projectRoot); + if (detected) { + resolvedVariables[param.name] = detected.value; + Object.assign(resolvedVariables, detected.companionVariables); + sourceDetected = true; + log.appendLine(`[Scaffold] Auto-detected ${param.source}: ${param.name}=${detected.value}`); + } + } + } + + for (const param of scaffold.manifest.parameters) { + // Skip if already pre-filled by context detection + if (resolvedVariables[param.name] !== undefined) continue; + + // Evaluate conditional visibility + if (param.when && !evaluateCondition(param.when, resolvedVariables)) { + log.appendLine(`[Scaffold] Skipping param ${param.name} (when: "${param.when}" is false)`); + continue; + } + + log.appendLine(`[Scaffold] Prompting for param: ${param.name} (type: ${param.type})`); + + // eslint-disable-next-line no-await-in-loop + const value = await promptForParameter(param, scaffold, projectRoot, configProvider, log); + if (value === undefined) { + log.appendLine('[Scaffold] User cancelled'); + return; + } + + resolvedVariables[param.name] = value; + log.appendLine(`[Scaffold] ${param.name} = ${JSON.stringify(value)}`); + + // Set companion path variable for cartridges source + if (param.source === 'cartridges' && typeof value === 'string') { + const result = resolveLocalSource('cartridges', projectRoot); + const cartridgePath = result.pathMap?.get(value); + if (cartridgePath) { + resolvedVariables[`${param.name}Path`] = cartridgePath; + } + } + } + + // Step 3: Resolve output directory + let outputDir: string; + if (sourceDetected) { + // Source detected (e.g., cartridge) → use projectRoot because cartridgeNamePath is relative to it + outputDir = projectRoot; + log.appendLine(`[Scaffold] Output dir from source detection: ${outputDir}`); + } else if (uri) { + outputDir = uri.fsPath; + log.appendLine(`[Scaffold] Output dir from context menu: ${outputDir}`); + } else { + const defaultOutput = resolveOutputDirectory({scaffold, projectRoot}); + const folders = await vscode.window.showOpenDialog({ + title: 'Select output directory', + defaultUri: vscode.Uri.file(defaultOutput), + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Generate Here', + }); + if (!folders || folders.length === 0) return; + outputDir = folders[0].fsPath; + log.appendLine(`[Scaffold] Output dir from dialog: ${outputDir}`); + } + + // Step 4: Generate with progress + log.appendLine(`[Scaffold] Generating ${scaffold.id} into ${outputDir}`); + const result: ScaffoldGenerateResult = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Generating ${scaffold.manifest.displayName}...`, + }, + () => + generateFromScaffold(scaffold, { + outputDir, + variables: resolvedVariables, + dryRun: false, + force: false, + }), + ); + + // Step 5: Show results + const created = result.files.filter((f) => f.action === 'created' || f.action === 'overwritten'); + const skipped = result.files.filter((f) => f.action === 'skipped'); + + log.appendLine(`[Scaffold] Result: ${created.length} created, ${skipped.length} skipped`); + for (const f of result.files) { + log.appendLine(`[Scaffold] ${f.action}: ${f.path}${f.skipReason ? ` (${f.skipReason})` : ''}`); + } + + if (result.postInstructions) { + log.appendLine(`[Scaffold] Post-instructions: ${result.postInstructions}`); + } + + if (created.length === 0 && skipped.length > 0) { + vscode.window.showWarningMessage( + `All ${skipped.length} file(s) already exist and were skipped. Use the CLI with --force to overwrite.`, + ); + return; + } + + const message = `Generated ${created.length} file(s) from ${scaffold.manifest.displayName} scaffold.`; + const action = created.length > 0 ? await vscode.window.showInformationMessage(message, 'Open File') : undefined; + + if (action === 'Open File' && created.length > 0) { + const fileUri = vscode.Uri.file(created[0].absolutePath); + const doc = await vscode.workspace.openTextDocument(fileUri); + await vscode.window.showTextDocument(doc); + } +} + +/** + * Prompt for a single parameter value using VS Code UI. + * Returns undefined if the user cancelled. + */ +async function promptForParameter( + param: ScaffoldParameter, + scaffold: Scaffold, + projectRoot: string, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, +): Promise { + const title = scaffold.manifest.displayName; + + switch (param.type) { + case 'boolean': + return promptBoolean(param, title); + + case 'choice': { + const choices = await resolveChoices(param, projectRoot, configProvider, log); + if (choices.length === 0) { + if (param.source) { + log.appendLine(`[Scaffold] No ${param.source} found, falling back to text input`); + } + return promptString(param, title); + } + return promptChoice(param, choices, title); + } + + case 'multi-choice': { + const choices = await resolveChoices(param, projectRoot, configProvider, log); + if (choices.length === 0) return []; + return promptMultiChoice(param, choices, title); + } + + case 'string': { + if (param.source) { + const choices = await resolveChoices(param, projectRoot, configProvider, log); + if (choices.length > 0) { + return promptChoice(param, choices, title); + } + log.appendLine(`[Scaffold] No ${param.source} found, falling back to text input`); + } + return promptString(param, title); + } + + default: + return undefined; + } +} + +async function promptString(param: ScaffoldParameter, title: string): Promise { + return vscode.window.showInputBox({ + title, + prompt: param.prompt, + value: typeof param.default === 'string' ? param.default : undefined, + validateInput: (val) => { + if (param.required && !val.trim()) return 'This field is required'; + if (param.pattern && val) { + if (!new RegExp(param.pattern).test(val)) { + return param.validationMessage || `Value must match: ${param.pattern}`; + } + } + return null; + }, + }); +} + +async function promptBoolean(param: ScaffoldParameter, title: string): Promise { + const defaultIsTrue = param.default === true; + const items: BooleanQuickPickItem[] = defaultIsTrue + ? [ + {label: 'Yes', description: '(default)', boolValue: true}, + {label: 'No', boolValue: false}, + ] + : [ + {label: 'No', description: param.default === false ? '(default)' : undefined, boolValue: false}, + {label: 'Yes', boolValue: true}, + ]; + + // Default true → Yes first; default false or unset → No first + if (!defaultIsTrue && param.default !== false) { + // No default — show Yes first + items.reverse(); + } + + const picked = await vscode.window.showQuickPick(items, { + title, + placeHolder: param.prompt, + }); + + return picked?.boolValue; +} + +async function promptChoice( + param: ScaffoldParameter, + choices: ScaffoldChoice[], + title: string, +): Promise { + const items: ValueQuickPickItem[] = choices.map((c) => ({ + label: c.label, + description: c.value !== c.label ? c.value : undefined, + value: c.value, + })); + + const picked = await vscode.window.showQuickPick(items, { + title, + placeHolder: param.prompt, + matchOnDescription: true, + }); + + return picked?.value; +} + +async function promptMultiChoice( + param: ScaffoldParameter, + choices: ScaffoldChoice[], + title: string, +): Promise { + const defaults = Array.isArray(param.default) ? param.default : []; + const items: ValueQuickPickItem[] = choices.map((c) => ({ + label: c.label, + description: c.value !== c.label ? c.value : undefined, + value: c.value, + picked: defaults.includes(c.value), + })); + + const picked = await vscode.window.showQuickPick(items, { + title, + placeHolder: param.prompt, + canPickMany: true, + }); + + return picked?.map((p) => p.value); +} + +/** + * Resolve choices for a parameter, handling both local and remote sources. + */ +async function resolveChoices( + param: ScaffoldParameter, + projectRoot: string, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, +): Promise { + if (!param.source) { + return param.choices || []; + } + + if (isRemoteSource(param.source)) { + try { + const instance = configProvider.getInstance(); + if (!instance) { + log.appendLine(`[Scaffold] No B2C instance configured, cannot resolve ${param.source}`); + return []; + } + return await resolveRemoteSource(param.source, instance); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.appendLine(`[Scaffold] Failed to resolve remote source ${param.source}: ${message}`); + return []; + } + } + + const result: SourceResult = resolveLocalSource(param.source, projectRoot); + return result.choices; +} From 27b8c2bb40f1759b2cc2dc1d9a2c9bd87d7dff0c Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 2 Mar 2026 23:38:30 -0500 Subject: [PATCH 2/2] =?UTF-8?q?VS=20Code=20scaffold:=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20icon,=20auto-open,=20step=20progress,=20workspace?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add $(file-code) icon to scaffold command - Auto-open first generated file; offer "Reveal in Explorer" instead of "Open File" - Show postInstructions as user-facing info message - Simplify boolean prompt: always Yes/No order with (default) marker - Guard against missing workspace with early warning - Show step progress in Quick Pick titles (e.g. "Controller (1/3)") - Add when clause to file/newFile entry to hide without workspace --- packages/b2c-vs-extension/package.json | 4 +- .../src/scaffold/scaffold-commands.ts | 72 +++++++++++-------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 27e84084..badb04d1 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -339,6 +339,7 @@ { "command": "b2c-dx.scaffold.generate", "title": "New from Scaffold...", + "icon": "$(file-code)", "category": "B2C DX" } ], @@ -480,7 +481,8 @@ "file/newFile": [ { "command": "b2c-dx.scaffold.generate", - "group": "navigation" + "group": "navigation", + "when": "workspaceFolderCount > 0" } ], "explorer/context": [ diff --git a/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts index 5ae259c9..e4bb7bac 100644 --- a/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts +++ b/packages/b2c-vs-extension/src/scaffold/scaffold-commands.ts @@ -60,7 +60,11 @@ async function runScaffoldWizard( log: vscode.OutputChannel, builtInScaffoldsDir: string, ): Promise { - const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + if (!vscode.workspace.workspaceFolders?.length) { + vscode.window.showWarningMessage('Open a workspace folder to use scaffolds.'); + return; + } + const projectRoot = vscode.workspace.workspaceFolders[0].uri.fsPath; log.appendLine(`[Scaffold] Starting wizard, projectRoot=${projectRoot}`); // Step 1: Discover and select scaffold @@ -135,6 +139,14 @@ async function runScaffoldWizard( } } + // Count visible params for step progress (best-effort — conditional params may change) + const visibleParams = scaffold.manifest.parameters.filter((p) => { + if (resolvedVariables[p.name] !== undefined) return false; + if (p.when && !evaluateCondition(p.when, resolvedVariables)) return false; + return true; + }); + + let stepIndex = 0; for (const param of scaffold.manifest.parameters) { // Skip if already pre-filled by context detection if (resolvedVariables[param.name] !== undefined) continue; @@ -145,10 +157,12 @@ async function runScaffoldWizard( continue; } + stepIndex++; + const stepTitle = `${scaffold.manifest.displayName} (${stepIndex}/${visibleParams.length})`; log.appendLine(`[Scaffold] Prompting for param: ${param.name} (type: ${param.type})`); // eslint-disable-next-line no-await-in-loop - const value = await promptForParameter(param, scaffold, projectRoot, configProvider, log); + const value = await promptForParameter(param, scaffold, projectRoot, configProvider, log, stepTitle); if (value === undefined) { log.appendLine('[Scaffold] User cancelled'); return; @@ -227,13 +241,24 @@ async function runScaffoldWizard( return; } - const message = `Generated ${created.length} file(s) from ${scaffold.manifest.displayName} scaffold.`; - const action = created.length > 0 ? await vscode.window.showInformationMessage(message, 'Open File') : undefined; - - if (action === 'Open File' && created.length > 0) { + if (created.length > 0) { + // Open the first created file immediately const fileUri = vscode.Uri.file(created[0].absolutePath); const doc = await vscode.workspace.openTextDocument(fileUri); await vscode.window.showTextDocument(doc); + + // Show message with Reveal action for the output directory + const action = await vscode.window.showInformationMessage( + `Generated ${created.length} file(s) from ${scaffold.manifest.displayName} scaffold.`, + 'Reveal in Explorer', + ); + if (action === 'Reveal in Explorer') { + await vscode.commands.executeCommand('revealInExplorer', fileUri); + } + } + + if (result.postInstructions) { + vscode.window.showInformationMessage(result.postInstructions); } } @@ -247,12 +272,13 @@ async function promptForParameter( projectRoot: string, configProvider: B2CExtensionConfig, log: vscode.OutputChannel, + title?: string, ): Promise { - const title = scaffold.manifest.displayName; + const stepTitle = title ?? scaffold.manifest.displayName; switch (param.type) { case 'boolean': - return promptBoolean(param, title); + return promptBoolean(param, stepTitle); case 'choice': { const choices = await resolveChoices(param, projectRoot, configProvider, log); @@ -260,26 +286,26 @@ async function promptForParameter( if (param.source) { log.appendLine(`[Scaffold] No ${param.source} found, falling back to text input`); } - return promptString(param, title); + return promptString(param, stepTitle); } - return promptChoice(param, choices, title); + return promptChoice(param, choices, stepTitle); } case 'multi-choice': { const choices = await resolveChoices(param, projectRoot, configProvider, log); if (choices.length === 0) return []; - return promptMultiChoice(param, choices, title); + return promptMultiChoice(param, choices, stepTitle); } case 'string': { if (param.source) { const choices = await resolveChoices(param, projectRoot, configProvider, log); if (choices.length > 0) { - return promptChoice(param, choices, title); + return promptChoice(param, choices, stepTitle); } log.appendLine(`[Scaffold] No ${param.source} found, falling back to text input`); } - return promptString(param, title); + return promptString(param, stepTitle); } default: @@ -305,22 +331,10 @@ async function promptString(param: ScaffoldParameter, title: string): Promise { - const defaultIsTrue = param.default === true; - const items: BooleanQuickPickItem[] = defaultIsTrue - ? [ - {label: 'Yes', description: '(default)', boolValue: true}, - {label: 'No', boolValue: false}, - ] - : [ - {label: 'No', description: param.default === false ? '(default)' : undefined, boolValue: false}, - {label: 'Yes', boolValue: true}, - ]; - - // Default true → Yes first; default false or unset → No first - if (!defaultIsTrue && param.default !== false) { - // No default — show Yes first - items.reverse(); - } + const items: BooleanQuickPickItem[] = [ + {label: 'Yes', description: param.default === true ? '(default)' : undefined, boolValue: true}, + {label: 'No', description: param.default === false ? '(default)' : undefined, boolValue: false}, + ]; const picked = await vscode.window.showQuickPick(items, { title,