Skip to content

Commit 8faf831

Browse files
authored
VS Code extension: scaffold generation with context-aware UX (#230)
* 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 * VS Code scaffold: UX polish — icon, auto-open, step progress, workspace guard - 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
1 parent 8c6665b commit 8faf831

File tree

10 files changed

+595
-24
lines changed

10 files changed

+595
-24
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': minor
3+
---
4+
5+
Add `detectSourceFromPath()` for context-aware scaffold parameter detection, `cartridgePathForDestination()` export, and `builtInScaffoldsDir` option on `createScaffoldRegistry()` for bundled consumers

packages/b2c-tooling-sdk/src/scaffold/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,14 @@ export {
100100
resolveRemoteSource,
101101
isRemoteSource,
102102
validateAgainstSource,
103+
cartridgePathForDestination,
104+
detectSourceFromPath,
103105
} from './sources.js';
106+
export type {SourceDetectionResult} from './sources.js';
104107

105108
// Registry
106109
export {ScaffoldRegistry, createScaffoldRegistry} from './registry.js';
110+
export type {ScaffoldRegistryOptions} from './registry.js';
107111

108112
// Engine
109113
export {

packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
66

7-
import path from 'node:path';
87
import type {B2CInstance} from '../instance/index.js';
98
import type {Scaffold, ScaffoldParameter, ScaffoldChoice} from './types.js';
109
import {evaluateCondition} from './validators.js';
11-
import {resolveLocalSource, resolveRemoteSource, isRemoteSource, validateAgainstSource} from './sources.js';
10+
import {
11+
resolveLocalSource,
12+
resolveRemoteSource,
13+
isRemoteSource,
14+
validateAgainstSource,
15+
cartridgePathForDestination,
16+
} from './sources.js';
1217

1318
/**
1419
* Options for resolving scaffold parameters.
@@ -64,22 +69,6 @@ export interface ResolvedParameterSchema {
6469
warning?: string;
6570
}
6671

67-
/**
68-
* Path to use for scaffold destination so files are generated under outputDir (e.g. working directory).
69-
* Returns a path relative to projectRoot when the cartridge is under projectRoot, so the executor
70-
* joins with outputDir instead of ignoring it. Otherwise returns the absolute path.
71-
*/
72-
function cartridgePathForDestination(absolutePath: string, projectRoot: string): string {
73-
const normalizedRoot = path.resolve(projectRoot);
74-
const normalizedPath = path.resolve(absolutePath);
75-
const relative = path.relative(normalizedRoot, normalizedPath);
76-
// Use relative path only when cartridge is under projectRoot (no leading '..')
77-
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
78-
return relative;
79-
}
80-
return absolutePath;
81-
}
82-
8372
/**
8473
* Resolve scaffold parameters by:
8574
* 1. Validating provided variables against sources

packages/b2c-tooling-sdk/src/scaffold/registry.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,30 @@ function filterScaffolds(scaffolds: Scaffold[], options: ScaffoldDiscoveryOption
126126
return filtered;
127127
}
128128

129+
/**
130+
* Options for creating a scaffold registry
131+
*/
132+
export interface ScaffoldRegistryOptions {
133+
/**
134+
* Override the built-in scaffolds directory. Useful for bundled environments
135+
* (e.g. VS Code extensions) where the SDK's data files are copied to a
136+
* different location. Defaults to the SDK's own `data/scaffolds/` directory.
137+
*/
138+
builtInScaffoldsDir?: string;
139+
}
140+
129141
/**
130142
* Scaffold registry for discovering and managing scaffolds
131143
*/
132144
export class ScaffoldRegistry {
133145
private providers: ScaffoldProvider[] = [];
134146
private transformers: ScaffoldTransformer[] = [];
135147
private scaffoldCache: Map<string, Scaffold[]> = new Map();
148+
private readonly builtInScaffoldsDir: string;
149+
150+
constructor(options?: ScaffoldRegistryOptions) {
151+
this.builtInScaffoldsDir = options?.builtInScaffoldsDir ?? SCAFFOLDS_DATA_DIR;
152+
}
136153

137154
/**
138155
* Add scaffold providers
@@ -179,7 +196,7 @@ export class ScaffoldRegistry {
179196
}
180197

181198
// 2. Built-in scaffolds (lowest priority for built-ins)
182-
const builtInScaffolds = await discoverScaffoldsFromDir(SCAFFOLDS_DATA_DIR, 'built-in');
199+
const builtInScaffolds = await discoverScaffoldsFromDir(this.builtInScaffoldsDir, 'built-in');
183200
allScaffolds.push(...builtInScaffolds);
184201

185202
// 3. User scaffolds (~/.b2c/scaffolds/)
@@ -258,7 +275,9 @@ export class ScaffoldRegistry {
258275

259276
/**
260277
* Create a new scaffold registry instance
278+
*
279+
* @param options - Registry options (e.g. override built-in scaffolds directory)
261280
*/
262-
export function createScaffoldRegistry(): ScaffoldRegistry {
263-
return new ScaffoldRegistry();
281+
export function createScaffoldRegistry(options?: ScaffoldRegistryOptions): ScaffoldRegistry {
282+
return new ScaffoldRegistry(options);
264283
}

packages/b2c-tooling-sdk/src/scaffold/sources.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
66

7+
import fs from 'node:fs';
8+
import path from 'node:path';
79
import {findCartridges} from '../operations/code/cartridges.js';
810
import type {B2CInstance} from '../instance/index.js';
911
import type {OcapiComponents} from '../clients/index.js';
10-
import type {ScaffoldChoice, DynamicParameterSource, SourceResult} from './types.js';
12+
import type {ScaffoldChoice, ScaffoldParameter, DynamicParameterSource, SourceResult} from './types.js';
1113

1214
/**
1315
* Common B2C Commerce hook extension points.
@@ -129,3 +131,73 @@ export function validateAgainstSource(
129131
// For hook-points and other sources, no validation (allow any value)
130132
return {valid: true};
131133
}
134+
135+
/**
136+
* Path to use for scaffold destination so files are generated under outputDir (e.g. working directory).
137+
* Returns a path relative to projectRoot when the cartridge is under projectRoot, so the executor
138+
* joins with outputDir instead of ignoring it. Otherwise returns the absolute path.
139+
*/
140+
export function cartridgePathForDestination(absolutePath: string, projectRoot: string): string {
141+
const normalizedRoot = path.resolve(projectRoot);
142+
const normalizedPath = path.resolve(absolutePath);
143+
const relative = path.relative(normalizedRoot, normalizedPath);
144+
// Use relative path only when cartridge is under projectRoot (no leading '..')
145+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
146+
return relative;
147+
}
148+
return absolutePath;
149+
}
150+
151+
/**
152+
* Result of detecting a source parameter value from a filesystem path.
153+
*/
154+
export interface SourceDetectionResult {
155+
/** The resolved parameter value (e.g., cartridge name) */
156+
value: string;
157+
/** Companion variables to set (e.g., { cartridgeNamePath: "cartridges/app_custom" }) */
158+
companionVariables: Record<string, string>;
159+
}
160+
161+
/**
162+
* Detect a parameter's source value from a filesystem context path.
163+
*
164+
* For `cartridges` source: walks up from `contextPath` looking for a `.project` file
165+
* (cartridge marker), stopping at projectRoot. On match returns the cartridge name and
166+
* companion path variable.
167+
*
168+
* @param param - The scaffold parameter with a `source` field
169+
* @param contextPath - Filesystem path providing context (e.g., right-clicked folder)
170+
* @param projectRoot - Project root directory
171+
* @returns Detection result, or undefined if the source could not be detected
172+
*/
173+
export function detectSourceFromPath(
174+
param: ScaffoldParameter,
175+
contextPath: string,
176+
projectRoot: string,
177+
): SourceDetectionResult | undefined {
178+
if (param.source !== 'cartridges') {
179+
return undefined;
180+
}
181+
182+
const normalizedRoot = path.resolve(projectRoot);
183+
let current = path.resolve(contextPath);
184+
185+
// Walk up from contextPath, checking for .project at each level
186+
while (current.length >= normalizedRoot.length) {
187+
const projectFile = path.join(current, '.project');
188+
if (fs.existsSync(projectFile)) {
189+
const cartridgeName = path.basename(current);
190+
const destPath = cartridgePathForDestination(current, projectRoot);
191+
return {
192+
value: cartridgeName,
193+
companionVariables: {[`${param.name}Path`]: destPath},
194+
};
195+
}
196+
197+
const parent = path.dirname(current);
198+
if (parent === current) break; // filesystem root
199+
current = parent;
200+
}
201+
202+
return undefined;
203+
}

packages/b2c-vs-extension/package.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"onCommand:b2c-dx.promptAgent",
2727
"onCommand:b2c-dx.listWebDav",
2828
"onCommand:b2c-dx.scapiExplorer",
29-
"onView:b2cSandboxExplorer"
29+
"onView:b2cSandboxExplorer",
30+
"onCommand:b2c-dx.scaffold.generate"
3031
],
3132
"main": "./dist/extension.js",
3233
"contributes": {
@@ -53,6 +54,11 @@
5354
"default": true,
5455
"description": "Enable log tailing commands."
5556
},
57+
"b2c-dx.features.scaffold": {
58+
"type": "boolean",
59+
"default": true,
60+
"description": "Enable scaffold generation commands."
61+
},
5662
"b2c-dx.logLevel": {
5763
"type": "string",
5864
"default": "info",
@@ -329,6 +335,12 @@
329335
"title": "Import Site Archive",
330336
"icon": "$(cloud-upload)",
331337
"category": "B2C DX"
338+
},
339+
{
340+
"command": "b2c-dx.scaffold.generate",
341+
"title": "New from Scaffold...",
342+
"icon": "$(file-code)",
343+
"category": "B2C DX"
332344
}
333345
],
334346
"menus": {
@@ -466,6 +478,13 @@
466478
"group": "3_destructive@1"
467479
}
468480
],
481+
"file/newFile": [
482+
{
483+
"command": "b2c-dx.scaffold.generate",
484+
"group": "navigation",
485+
"when": "workspaceFolderCount > 0"
486+
}
487+
],
469488
"explorer/context": [
470489
{
471490
"command": "b2c-dx.webdav.download",
@@ -479,9 +498,15 @@
479498
}
480499
],
481500
"b2c-dx.submenu": [
501+
{
502+
"command": "b2c-dx.scaffold.generate",
503+
"when": "explorerResourceIsFolder",
504+
"group": "1_scaffold"
505+
},
482506
{
483507
"command": "b2c-dx.content.import",
484-
"when": "explorerResourceIsFolder"
508+
"when": "explorerResourceIsFolder",
509+
"group": "2_import"
485510
}
486511
],
487512
"commandPalette": [

packages/b2c-vs-extension/scripts/esbuild-bundle.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ const REQUIRE_RESOLVE_PACKAGE_JSON_RE =
5252
/require\d*\.resolve\s*\(\s*["']@salesforce\/b2c-tooling-sdk\/package\.json["']\s*\)/g;
5353
const REQUIRE_RESOLVE_REPLACEMENT = "require('path').join(__dirname, 'package.json')";
5454

55+
// Copy SDK scaffold templates into dist/ so the extension can find them at runtime.
56+
// The extension passes this path explicitly via createScaffoldRegistry({ builtInScaffoldsDir }).
57+
const sdkRoot = path.join(pkgRoot, '..', 'b2c-tooling-sdk');
58+
59+
function copySdkScaffolds() {
60+
const src = path.join(sdkRoot, 'data', 'scaffolds');
61+
const dest = path.join(pkgRoot, 'dist', 'data', 'scaffolds');
62+
if (!fs.existsSync(src)) return;
63+
fs.cpSync(src, dest, {recursive: true});
64+
}
65+
5566
function inlineSdkPackageJson() {
5667
const outPath = path.join(pkgRoot, 'dist', 'extension.js');
5768
let str = fs.readFileSync(outPath, 'utf8');
@@ -83,13 +94,15 @@ const buildOptions = {
8394
};
8495

8596
if (watchMode) {
97+
copySdkScaffolds();
8698
const ctx = await esbuild.context(buildOptions);
8799
await ctx.watch();
88100
console.log('[esbuild] watching for changes...');
89101
} else {
90102
const result = await esbuild.build(buildOptions);
91103

92104
inlineSdkPackageJson();
105+
copySdkScaffolds();
93106

94107
if (result.metafile && process.env.ANALYZE_BUNDLE) {
95108
const metaPath = path.join(pkgRoot, 'dist', 'meta.json');

packages/b2c-vs-extension/src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {registerContentTree} from './content-tree/index.js';
1818
import {registerLogs} from './logs/index.js';
1919
import {initializePlugins} from './plugins.js';
2020
import {registerSandboxTree} from './sandbox-tree/index.js';
21+
import {registerScaffold} from './scaffold/index.js';
2122
import {registerWebDavTree} from './webdav-tree/index.js';
2223

2324
function getWebviewContent(context: vscode.ExtensionContext): string {
@@ -910,6 +911,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
910911
if (settings.get<boolean>('features.logTailing', true)) {
911912
registerLogs(context, configProvider);
912913
}
914+
if (settings.get<boolean>('features.scaffold', true)) {
915+
registerScaffold(context, configProvider, log);
916+
}
913917

914918
// React to configuration changes
915919
const configChangeListener = vscode.workspace.onDidChangeConfiguration((e) => {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
import path from 'node:path';
8+
import * as vscode from 'vscode';
9+
import type {B2CExtensionConfig} from '../config-provider.js';
10+
import {registerScaffoldCommands} from './scaffold-commands.js';
11+
12+
export function registerScaffold(
13+
context: vscode.ExtensionContext,
14+
configProvider: B2CExtensionConfig,
15+
log: vscode.OutputChannel,
16+
): void {
17+
const builtInScaffoldsDir = path.join(context.extensionPath, 'dist', 'data', 'scaffolds');
18+
log.appendLine(`[Scaffold] Built-in scaffolds dir: ${builtInScaffoldsDir}`);
19+
const disposables = registerScaffoldCommands(configProvider, log, builtInScaffoldsDir);
20+
context.subscriptions.push(...disposables);
21+
}

0 commit comments

Comments
 (0)