Skip to content

Commit 1378386

Browse files
committed
Centralize config resolution in VS Code extension
Replace 7 independent resolveConfigWithPlugins() call sites with a single B2CExtensionConfig singleton that caches resolved config and exposes getInstance()/getConfig()/getConfigError()/reset(). - Add dw.json file watcher (RelativePattern + onDidSaveTextDocument) that auto-resets config and fires onDidReset event - WebDAV tree subscribes to onDidReset for automatic refresh on config changes - Silently handle FileNotFound in tree re-expansion after server switch - Delete WebDavConfigProvider (fully replaced by B2CExtensionConfig)
1 parent a71b468 commit 1378386

7 files changed

Lines changed: 213 additions & 127 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
import {
7+
resolveConfig,
8+
type NormalizedConfig,
9+
type ResolveConfigOptions,
10+
type ResolvedB2CConfig,
11+
} from '@salesforce/b2c-tooling-sdk/config';
12+
import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance';
13+
import * as fs from 'fs';
14+
import * as path from 'path';
15+
import * as vscode from 'vscode';
16+
import {getPluginConfigSources} from './plugins.js';
17+
18+
/**
19+
* Resolves configuration with plugin sources automatically injected.
20+
*/
21+
function resolveConfigWithPlugins(
22+
overrides: Partial<NormalizedConfig> = {},
23+
options: ResolveConfigOptions = {},
24+
): ResolvedB2CConfig {
25+
const {sourcesBefore, sourcesAfter} = getPluginConfigSources();
26+
return resolveConfig(overrides, {
27+
...options,
28+
sourcesBefore: [...sourcesBefore, ...(options.sourcesBefore ?? [])],
29+
sourcesAfter: [...(options.sourcesAfter ?? []), ...sourcesAfter],
30+
});
31+
}
32+
33+
const DW_JSON = 'dw.json';
34+
35+
/**
36+
* Centralized B2C config provider for the VS Code extension.
37+
*
38+
* Resolves config from dw.json / env vars once, caches the result,
39+
* and exposes an event so all features can react to config changes.
40+
* Watches for dw.json changes via both a FileSystemWatcher (external edits,
41+
* creates, deletes) and onDidSaveTextDocument (in-editor saves).
42+
*/
43+
export class B2CExtensionConfig implements vscode.Disposable {
44+
private config: ResolvedB2CConfig | null = null;
45+
private instance: B2CInstance | null = null;
46+
private configError: string | null = null;
47+
private resolved = false;
48+
49+
private readonly _onDidReset = new vscode.EventEmitter<void>();
50+
readonly onDidReset = this._onDidReset.event;
51+
52+
private readonly disposables: vscode.Disposable[] = [];
53+
54+
constructor(private readonly log: vscode.OutputChannel) {
55+
// Watch for dw.json saves made within VS Code (most reliable for in-editor edits)
56+
this.disposables.push(
57+
vscode.workspace.onDidSaveTextDocument((doc) => {
58+
if (path.basename(doc.fileName) === DW_JSON) {
59+
this.log.appendLine(`[Config] dw.json saved in editor: ${doc.fileName}`);
60+
this.reset();
61+
}
62+
}),
63+
);
64+
65+
// FileSystemWatcher per workspace folder for external changes and create/delete.
66+
// RelativePattern is more reliable than a bare glob string on macOS.
67+
for (const folder of vscode.workspace.workspaceFolders ?? []) {
68+
const pattern = new vscode.RelativePattern(folder, `**/${DW_JSON}`);
69+
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
70+
watcher.onDidChange((uri) => {
71+
this.log.appendLine(`[Config] dw.json changed (fs watcher): ${uri.fsPath}`);
72+
this.reset();
73+
});
74+
watcher.onDidCreate((uri) => {
75+
this.log.appendLine(`[Config] dw.json created: ${uri.fsPath}`);
76+
this.reset();
77+
});
78+
watcher.onDidDelete((uri) => {
79+
this.log.appendLine(`[Config] dw.json deleted: ${uri.fsPath}`);
80+
this.reset();
81+
});
82+
this.disposables.push(watcher);
83+
this.log.appendLine(`[Config] File watcher registered for ${folder.uri.fsPath}/**/${DW_JSON}`);
84+
}
85+
}
86+
87+
getConfig(): ResolvedB2CConfig | null {
88+
if (!this.resolved) {
89+
this.resolve();
90+
}
91+
return this.config;
92+
}
93+
94+
getInstance(): B2CInstance | null {
95+
if (!this.resolved) {
96+
this.resolve();
97+
}
98+
return this.instance;
99+
}
100+
101+
getConfigError(): string | null {
102+
if (!this.resolved) {
103+
this.resolve();
104+
}
105+
return this.configError;
106+
}
107+
108+
reset(): void {
109+
this.log.appendLine('[Config] Resetting cached config (will re-resolve on next access)');
110+
this.config = null;
111+
this.instance = null;
112+
this.configError = null;
113+
this.resolved = false;
114+
this._onDidReset.fire();
115+
}
116+
117+
/**
118+
* Uncached config resolution for a specific directory.
119+
* Used by deploy-cartridge where the project directory differs from the workspace root.
120+
*/
121+
resolveForDirectory(workingDirectory: string, overrides: Partial<NormalizedConfig> = {}): ResolvedB2CConfig {
122+
return resolveConfigWithPlugins(overrides, {workingDirectory});
123+
}
124+
125+
dispose(): void {
126+
this._onDidReset.dispose();
127+
for (const d of this.disposables) {
128+
d.dispose();
129+
}
130+
}
131+
132+
private resolve(): void {
133+
this.resolved = true;
134+
try {
135+
let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
136+
if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) {
137+
workingDirectory = '';
138+
}
139+
this.log.appendLine(`[Config] Resolving config from ${workingDirectory || '(no working directory)'}`);
140+
const config = resolveConfigWithPlugins({}, {workingDirectory});
141+
this.config = config;
142+
143+
if (!config.hasB2CInstanceConfig()) {
144+
this.configError = 'No B2C Commerce instance configured.';
145+
this.instance = null;
146+
this.log.appendLine('[Config] No B2C Commerce instance configured');
147+
return;
148+
}
149+
150+
this.instance = config.createB2CInstance();
151+
this.configError = null;
152+
this.log.appendLine(`[Config] Resolved instance: ${this.instance.config.hostname}`);
153+
} catch (err) {
154+
const message = err instanceof Error ? err.message : String(err);
155+
this.configError = message;
156+
this.config = null;
157+
this.instance = null;
158+
this.log.appendLine(`[Config] Resolution failed: ${message}`);
159+
}
160+
}
161+
}

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

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,6 @@
55
*/
66
import {createSlasClient, getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
77
import {createOdsClient, createScapiSchemasClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk/clients';
8-
import {
9-
resolveConfig,
10-
type NormalizedConfig,
11-
type ResolveConfigOptions,
12-
type ResolvedB2CConfig,
13-
} from '@salesforce/b2c-tooling-sdk/config';
148
import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging';
159
import {findAndDeployCartridges, getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code';
1610
import {getPathKeys, type OpenApiSchemaInput} from '@salesforce/b2c-tooling-sdk/schemas';
@@ -23,24 +17,10 @@ const execAsync = promisify(exec);
2317
import * as fs from 'fs';
2418
import * as path from 'path';
2519
import * as vscode from 'vscode';
26-
import {initializePlugins, getPluginConfigSources} from './plugins.js';
20+
import {B2CExtensionConfig} from './config-provider.js';
21+
import {initializePlugins} from './plugins.js';
2722
import {registerWebDavTree} from './webdav-tree/index.js';
2823

29-
/**
30-
* Resolves configuration with plugin sources automatically injected.
31-
*/
32-
function resolveConfigWithPlugins(
33-
overrides: Partial<NormalizedConfig> = {},
34-
options: ResolveConfigOptions = {},
35-
): ResolvedB2CConfig {
36-
const {sourcesBefore, sourcesAfter} = getPluginConfigSources();
37-
return resolveConfig(overrides, {
38-
...options,
39-
sourcesBefore: [...sourcesBefore, ...(options.sourcesBefore ?? [])],
40-
sourcesAfter: [...(options.sourcesAfter ?? []), ...sourcesAfter],
41-
});
42-
}
43-
4424
/**
4525
* Recursively finds all files under dir whose names end with .json (metadata files).
4626
* Returns paths relative to dir.
@@ -201,6 +181,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
201181
// before the first resolveConfig() call. Failures are non-fatal.
202182
await initializePlugins();
203183

184+
const configProvider = new B2CExtensionConfig(log);
185+
context.subscriptions.push(configProvider);
186+
204187
const disposable = vscode.commands.registerCommand('b2c-dx.openUI', () => {
205188
vscode.window.showInformationMessage('B2C DX: Opening Page Designer Assistant.');
206189

@@ -331,11 +314,10 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
331314
{enableScripts: true},
332315
);
333316
let prefill: {tenantId: string; channelId: string; shortCode?: string} | undefined;
334-
try {
335-
const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath;
336-
const config = resolveConfigWithPlugins({}, {workingDirectory});
337-
const hostname = config.values.hostname;
338-
const shortCode = config.values.shortCode;
317+
const prefillConfig = configProvider.getConfig();
318+
if (prefillConfig) {
319+
const hostname = prefillConfig.values.hostname;
320+
const shortCode = prefillConfig.values.shortCode;
339321
const firstPart = hostname && typeof hostname === 'string' ? (hostname.split('.')[0] ?? '') : '';
340322
const tenantId = firstPart ? firstPart.replace(/-/g, '_') : '';
341323
if (tenantId || shortCode) {
@@ -345,8 +327,6 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
345327
shortCode: typeof shortCode === 'string' ? shortCode : undefined,
346328
};
347329
}
348-
} catch {
349-
// Prefill is optional; leave undefined if config fails
350330
}
351331
panel.webview.html = getScapiExplorerWebviewContent(context, prefill);
352332
panel.webview.onDidReceiveMessage(
@@ -364,8 +344,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
364344
curlText?: string;
365345
}) => {
366346
const getConfig = () => {
367-
const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath;
368-
return resolveConfigWithPlugins({}, {workingDirectory});
347+
const config = configProvider.getConfig();
348+
if (!config) throw new Error('No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.');
349+
return config;
369350
};
370351

371352
if (msg.type === 'scapiFetchSchemas') {
@@ -748,8 +729,13 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
748729
vscode.window.showErrorMessage('B2C DX: Tenant Id and Channel Id are required to create a SLAS client.');
749730
return;
750731
}
751-
const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath;
752-
const config = resolveConfigWithPlugins({}, {workingDirectory});
732+
const config = configProvider.getConfig();
733+
if (!config) {
734+
vscode.window.showErrorMessage(
735+
'B2C DX: No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.',
736+
);
737+
return;
738+
}
753739
const shortCode = config.values.shortCode;
754740
if (!shortCode) {
755741
vscode.window.showErrorMessage(
@@ -861,26 +847,23 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
861847
{enableScripts: true},
862848
);
863849
let defaultRealm = '';
864-
try {
865-
const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath;
866-
const config = resolveConfigWithPlugins({}, {workingDirectory});
867-
// First part of hostname, e.g. 'zyoc' from 'zyoc-003.unified.demandware.net'
868-
const hostname = config.values.hostname;
850+
const odsConfig = configProvider.getConfig();
851+
if (odsConfig) {
852+
const hostname = odsConfig.values.hostname;
869853
const firstSegment = (hostname && typeof hostname === 'string' ? hostname : '').split('.')[0] ?? '';
870854
defaultRealm = firstSegment.split('-')[0] ?? '';
871-
} catch {
872-
// leave defaultRealm empty
873855
}
874856
panel.webview.html = getOdsManagementWebviewContent(context, {defaultRealm});
875857

876-
async function getOdsConfig() {
877-
const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath;
878-
return resolveConfigWithPlugins({}, {workingDirectory});
858+
function getOdsConfig() {
859+
const config = configProvider.getConfig();
860+
if (!config) throw new Error('No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.');
861+
return config;
879862
}
880863

881864
async function fetchSandboxList(): Promise<{sandboxes: unknown[]; error?: string}> {
882865
try {
883-
const config = await getOdsConfig();
866+
const config = getOdsConfig();
884867
if (!config.hasOAuthConfig()) {
885868
return {sandboxes: [], error: 'OAuth credentials required. Set clientId and clientSecret in dw.json.'};
886869
}
@@ -914,7 +897,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
914897
if (msg.type === 'odsGetDefaultRealm') {
915898
let defaultRealm = '';
916899
try {
917-
const config = await getOdsConfig();
900+
const config = getOdsConfig();
918901
const hostname = config.values.hostname;
919902
const firstSegment = (hostname && typeof hostname === 'string' ? hostname : '').split('.')[0] ?? '';
920903
defaultRealm = firstSegment.split('-')[0] ?? '';
@@ -926,7 +909,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
926909
}
927910
if (msg.type === 'odsSandboxClick' && msg.sandboxId) {
928911
try {
929-
const config = await getOdsConfig();
912+
const config = getOdsConfig();
930913
if (!config.hasOAuthConfig()) {
931914
panel.webview.postMessage({
932915
type: 'odsSandboxDetailsError',
@@ -967,7 +950,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
967950
}
968951
if (msg.type === 'odsDeleteClick' && msg.sandboxId) {
969952
try {
970-
const config = await getOdsConfig();
953+
const config = getOdsConfig();
971954
if (!config.hasOAuthConfig()) {
972955
vscode.window.showErrorMessage('B2C DX: OAuth credentials required for ODS. Configure dw.json.');
973956
return;
@@ -995,7 +978,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
995978
}
996979
if (msg.type === 'odsCreateSandbox' && msg.realm !== undefined && msg.ttl !== undefined) {
997980
try {
998-
const config = await getOdsConfig();
981+
const config = getOdsConfig();
999982
if (!config.hasOAuthConfig()) {
1000983
vscode.window.showErrorMessage('B2C DX: OAuth credentials required for ODS. Configure dw.json.');
1001984
return;
@@ -1113,7 +1096,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
11131096
vscode.window.showErrorMessage(message);
11141097
return;
11151098
}
1116-
const config = resolveConfigWithPlugins({}, {workingDirectory: projectDirectory});
1099+
const config = configProvider.resolveForDirectory(projectDirectory);
11171100
if (!config.hasB2CInstanceConfig()) {
11181101
const message =
11191102
'B2C DX: No instance config for deploy. Configure SFCC_* env vars or dw.json in the project.';
@@ -1181,7 +1164,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu
11811164
},
11821165
);
11831166

1184-
registerWebDavTree(context);
1167+
registerWebDavTree(context, configProvider);
11851168

11861169
context.subscriptions.push(
11871170
disposable,

packages/b2c-vs-extension/src/webdav-tree/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +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
import * as vscode from 'vscode';
7-
import {WebDavConfigProvider} from './webdav-config.js';
7+
import type {B2CExtensionConfig} from '../config-provider.js';
88
import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js';
99
import {WebDavTreeDataProvider} from './webdav-tree-provider.js';
1010
import {registerWebDavCommands} from './webdav-commands.js';
1111

12-
export function registerWebDavTree(context: vscode.ExtensionContext): void {
13-
const configProvider = new WebDavConfigProvider();
12+
export function registerWebDavTree(context: vscode.ExtensionContext, configProvider: B2CExtensionConfig): void {
1413
const fsProvider = new WebDavFileSystemProvider(configProvider);
1514

1615
const fsRegistration = vscode.workspace.registerFileSystemProvider(WEBDAV_SCHEME, fsProvider, {
@@ -26,5 +25,11 @@ export function registerWebDavTree(context: vscode.ExtensionContext): void {
2625

2726
const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider);
2827

28+
// Auto-refresh when config changes (dw.json edit, manual reset, future instance switch)
29+
configProvider.onDidReset(() => {
30+
fsProvider.clearCache();
31+
treeProvider.refresh();
32+
});
33+
2934
context.subscriptions.push(fsRegistration, treeView, ...commandDisposables);
3035
}

0 commit comments

Comments
 (0)