From 7418a9f57eaf3edad63c80a6d7242b43e646ef14 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 19 Feb 2026 21:20:36 -0500 Subject: [PATCH 01/16] Replace WebDAV Browser webview with native TreeView Replace the 561-line HTML webview (webdav.html) with a VS Code native TreeDataProvider sidebar. This gives the extension its first Activity Bar presence and provides collapsible tree browsing, context menus, keyboard navigation, theming, and accessibility for free. - Add Activity Bar container "B2C-DX WebDAV" with sidebar tree view - Lazy-load directory contents via PROPFIND with caching - Context menu commands: New Folder, Upload, Delete, Download, Open File - Open files via temp storage with native VS Code editors (syntax highlighting, image viewer, etc.) - Welcome view when no B2C instance is configured - Replace "*" activation event with targeted onView activation - Remove webdav.html and all inline webview message handling (~240 lines) --- packages/b2c-vs-extension/.vscodeignore | 1 - packages/b2c-vs-extension/media/b2c-icon.svg | 4 + packages/b2c-vs-extension/package.json | 102 +++- packages/b2c-vs-extension/src/extension.ts | 282 +-------- .../b2c-vs-extension/src/webdav-tree/index.ts | 23 + .../src/webdav-tree/webdav-commands.ts | 201 +++++++ .../src/webdav-tree/webdav-config.ts | 65 ++ .../src/webdav-tree/webdav-tree-provider.ts | 137 +++++ packages/b2c-vs-extension/src/webdav.html | 561 ------------------ 9 files changed, 534 insertions(+), 842 deletions(-) create mode 100644 packages/b2c-vs-extension/media/b2c-icon.svg create mode 100644 packages/b2c-vs-extension/src/webdav-tree/index.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts delete mode 100644 packages/b2c-vs-extension/src/webdav.html diff --git a/packages/b2c-vs-extension/.vscodeignore b/packages/b2c-vs-extension/.vscodeignore index 70e6d349..32f022e2 100644 --- a/packages/b2c-vs-extension/.vscodeignore +++ b/packages/b2c-vs-extension/.vscodeignore @@ -2,7 +2,6 @@ .vscode-test/** src/** !src/webview.html -!src/webdav.html !src/storefront-next-cartridge.html !src/scapi-explorer.html !src/ods-management.html diff --git a/packages/b2c-vs-extension/media/b2c-icon.svg b/packages/b2c-vs-extension/media/b2c-icon.svg new file mode 100644 index 00000000..ec49c250 --- /dev/null +++ b/packages/b2c-vs-extension/media/b2c-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index a3f140ab..fe5c5a8e 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -18,8 +18,7 @@ "vscode": "^1.105.1" }, "activationEvents": [ - "*", - "onStartupFinished", + "onView:b2cWebdavExplorer", "onCommand:b2c-dx.openUI", "onCommand:b2c-dx.handleStorefrontNextCartridge", "onCommand:b2c-dx.promptAgent", @@ -29,6 +28,31 @@ ], "main": "./dist/extension.js", "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "b2c-dx", + "title": "B2C-DX WebDAV", + "icon": "media/b2c-icon.svg" + } + ] + }, + "views": { + "b2c-dx": [ + { + "id": "b2cWebdavExplorer", + "name": "Browser", + "icon": "media/b2c-icon.svg", + "contextualTitle": "B2C Commerce" + } + ] + }, + "viewsWelcome": [ + { + "view": "b2cWebdavExplorer", + "contents": "No B2C Commerce instance configured.\n\nCreate a dw.json file in your workspace or set SFCC_* environment variables.\n\n[Refresh](command:b2c-dx.webdav.refresh)" + } + ], "commands": [ { "command": "b2c-dx.openUI", @@ -59,8 +83,80 @@ "command": "b2c-dx.odsManagement", "title": "On Demand Sandbox (ods) Management", "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.refresh", + "title": "Refresh", + "icon": "$(refresh)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.newFolder", + "title": "New Folder", + "icon": "$(new-folder)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.uploadFile", + "title": "Upload File", + "icon": "$(cloud-upload)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.delete", + "title": "Delete", + "icon": "$(trash)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.download", + "title": "Download", + "icon": "$(cloud-download)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.openFile", + "title": "Open File", + "icon": "$(go-to-file)", + "category": "B2C DX" } - ] + ], + "menus": { + "view/title": [ + { + "command": "b2c-dx.webdav.refresh", + "when": "view == b2cWebdavExplorer", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "b2c-dx.webdav.newFolder", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@1" + }, + { + "command": "b2c-dx.webdav.uploadFile", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@2" + }, + { + "command": "b2c-dx.webdav.openFile", + "when": "view == b2cWebdavExplorer && viewItem == file", + "group": "1_open@1" + }, + { + "command": "b2c-dx.webdav.download", + "when": "view == b2cWebdavExplorer && viewItem == file", + "group": "1_open@2" + }, + { + "command": "b2c-dx.webdav.delete", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", + "group": "2_destructive@1" + } + ] + } }, "scripts": { "build": "node scripts/esbuild-bundle.mjs", diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 76cbed4a..16803b98 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -15,21 +15,10 @@ import {promisify} from 'util'; const execAsync = promisify(exec); -/** Standard B2C Commerce WebDAV root directories. */ -const WEBDAV_ROOTS: Record = { - IMPEX: 'Impex', - TEMP: 'Temp', - CARTRIDGES: 'Cartridges', - REALMDATA: 'Realmdata', - CATALOGS: 'Catalogs', - LIBRARIES: 'Libraries', - STATIC: 'Static', - LOGS: 'Logs', - SECURITYLOGS: 'Securitylogs', -}; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import {registerWebDavTree} from './webdav-tree/index.js'; /** * Recursively finds all files under dir whose names end with .json (metadata files). @@ -81,31 +70,6 @@ function getOdsManagementWebviewContent(context: vscode.ExtensionContext, prefil return html; } -const WEBDAV_ROOT_LABELS: Record = { - impex: 'Impex directory (default)', - temp: 'Temporary files', - cartridges: 'Code cartridges', - realmdata: 'Realm data', - catalogs: 'Product catalogs', - libraries: 'Content libraries', - static: 'Static resources', - logs: 'Log files', - securitylogs: 'Security log files', -}; - -function getWebdavWebviewContent( - context: vscode.ExtensionContext, - roots: {key: string; path: string; label: string}[], -): string { - const htmlPath = path.join(context.extensionPath, 'src', 'webdav.html'); - const raw = fs.readFileSync(htmlPath, 'utf-8'); - const rootsJson = JSON.stringify(roots); - return raw.replace( - 'const roots = window.WEBDAV_ROOTS || [];', - `window.WEBDAV_ROOTS = ${rootsJson};\n const roots = window.WEBDAV_ROOTS;`, - ); -} - /** PascalCase for use in template content (class names, types, etc.). e.g. "first page" β†’ "FirstPage" */ function pageNameToPageId(pageName: string): string { return pageName @@ -303,246 +267,8 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann } }); - type WebDavPropfindEntry = {href: string; displayName?: string; contentLength?: number; isCollection?: boolean}; - - const listWebDavDisposable = vscode.commands.registerCommand('b2c-dx.listWebDav', async () => { - let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { - workingDirectory = context.extensionPath; - } - const dwPath = findDwJson(workingDirectory); - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); - - if (!config.hasB2CInstanceConfig()) { - vscode.window.showErrorMessage( - 'B2C DX: No instance config. Configure SFCC_* env vars or dw.json in the workspace.', - ); - return; - } - - const roots = (Object.keys(WEBDAV_ROOTS) as string[]).map((key) => { - const pathVal = (WEBDAV_ROOTS as Record)[key]; - const keyLower = key.toLowerCase(); - return { - key: keyLower, - path: pathVal, - label: WEBDAV_ROOT_LABELS[keyLower] ?? pathVal, - }; - }); - - const panel = vscode.window.createWebviewPanel('b2c-dx-webdav', 'B2C WebDAV Browser', vscode.ViewColumn.One, { - enableScripts: true, - }); - panel.webview.html = getWebdavWebviewContent(context, roots); - - const instance = config.createB2CInstance() as { - webdav: { - propfind: (path: string, depth: '1') => Promise; - mkcol: (path: string) => Promise; - delete: (path: string) => Promise; - put: (path: string, content: Buffer | Blob | string, contentType?: string) => Promise; - get: (path: string) => Promise; - }; - }; - - const getDisplayName = (e: WebDavPropfindEntry): string => - e.displayName ?? e.href.split('/').filter(Boolean).at(-1) ?? e.href; - - panel.webview.onDidReceiveMessage( - async (msg: {type: string; path?: string; name?: string; isCollection?: boolean}) => { - if (msg.type === 'listPath' && msg.path !== undefined) { - const listPath = msg.path as string; - try { - const entries = await instance.webdav.propfind(listPath, '1'); - const normalizedPath = listPath.replace(/\/$/, ''); - const filtered = entries.filter((entry: WebDavPropfindEntry) => { - const entryPath = decodeURIComponent(entry.href); - return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); - }); - panel.webview.postMessage({ - type: 'listResult', - path: listPath, - entries: filtered.map((e: WebDavPropfindEntry) => ({ - name: getDisplayName(e), - isCollection: Boolean(e.isCollection), - contentLength: e.contentLength, - })), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({ - type: 'listResult', - path: listPath, - entries: [], - error: message, - }); - } - return; - } - if (msg.type === 'requestMkdir' && msg.path !== undefined) { - const parentPath = msg.path as string; - const name = await vscode.window.showInputBox({ - title: 'New folder', - prompt: parentPath ? `Create directory under ${parentPath}` : 'Create directory at root', - placeHolder: 'Folder name', - validateInput: (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return 'Enter a folder name'; - if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; - return null; - }, - }); - if (name === undefined) return; - const trimmed = name.trim(); - if (!trimmed) return; - const fullPath = parentPath ? `${parentPath}/${trimmed}` : trimmed; - try { - await instance.webdav.mkcol(fullPath); - panel.webview.postMessage({type: 'mkdirResult', success: true, path: fullPath}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'mkdirResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestDelete' && msg.path !== undefined) { - const pathToDelete = msg.path as string; - const name = msg.name ?? pathToDelete.split('/').pop() ?? pathToDelete; - const isDir = msg.isCollection === true; - const detail = isDir ? 'This directory and its contents will be deleted.' : 'This file will be deleted.'; - const choice = await vscode.window.showWarningMessage( - `Delete "${name}"? ${detail}`, - {modal: true}, - 'Delete', - 'Cancel', - ); - if (choice !== 'Delete') return; - try { - await instance.webdav.delete(pathToDelete); - panel.webview.postMessage({type: 'deleteResult', success: true}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'deleteResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestUpload' && msg.path !== undefined) { - const destPath = msg.path as string; - const uris = await vscode.window.showOpenDialog({ - title: 'Select file to upload', - canSelectFiles: true, - canSelectMany: false, - canSelectFolders: false, - }); - if (!uris?.length) return; - const uri = uris[0]; - const fileName = path.basename(uri.fsPath); - const fullPath = destPath ? `${destPath}/${fileName}` : fileName; - try { - const content = fs.readFileSync(uri.fsPath); - const ext = path.extname(fileName).toLowerCase(); - const mime: Record = { - '.json': 'application/json', - '.xml': 'application/xml', - '.zip': 'application/zip', - '.js': 'application/javascript', - '.ts': 'application/typescript', - '.html': 'text/html', - '.css': 'text/css', - '.txt': 'text/plain', - }; - const contentType = mime[ext]; - await instance.webdav.put(fullPath, content, contentType); - panel.webview.postMessage({type: 'uploadResult', success: true, path: fullPath}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'uploadResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestFileContent' && msg.path !== undefined) { - const filePath = msg.path as string; - const fileName = msg.name ?? filePath.split('/').pop() ?? filePath; - const ext = path.extname(fileName).toLowerCase(); - const imageExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg']); - const textExtensions = new Set([ - '.json', - '.js', - '.ts', - '.mjs', - '.cjs', - '.html', - '.htm', - '.css', - '.xml', - '.txt', - '.md', - '.log', - '.yml', - '.yaml', - '.env', - '.sh', - '.bat', - '.csv', - '.isml', - ]); - const isImage = imageExtensions.has(ext); - const isText = textExtensions.has(ext) || ext === ''; - try { - const buffer = await instance.webdav.get(filePath); - const arr = new Uint8Array(buffer); - if (isImage) { - const base64 = Buffer.from(arr).toString('base64'); - const mime: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.svg': 'image/svg+xml', - }; - const contentType = mime[ext] ?? 'application/octet-stream'; - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'image', - contentType, - base64, - }); - } else if (isText) { - const text = new TextDecoder('utf-8', {fatal: false}).decode(arr); - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'text', - text, - }); - } else { - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'binary', - error: 'Binary file cannot be previewed.', - }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'error', - error: message, - }); - } - } - }, - ); + const listWebDavDisposable = vscode.commands.registerCommand('b2c-dx.listWebDav', () => { + vscode.commands.executeCommand('b2cWebdavExplorer.focus'); }); function resolveStorefrontNextProjectDir(): string | undefined { @@ -1437,6 +1163,8 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann }, ); + registerWebDavTree(context); + context.subscriptions.push( disposable, promptAgentDisposable, diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts new file mode 100644 index 00000000..46783bd7 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -0,0 +1,23 @@ +/* + * 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 * as vscode from 'vscode'; +import {WebDavConfigProvider} from './webdav-config.js'; +import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; +import {registerWebDavCommands} from './webdav-commands.js'; + +export function registerWebDavTree(context: vscode.ExtensionContext): void { + const configProvider = new WebDavConfigProvider(); + const treeProvider = new WebDavTreeDataProvider(configProvider); + + const treeView = vscode.window.createTreeView('b2cWebdavExplorer', { + treeDataProvider: treeProvider, + showCollapseAll: true, + }); + + const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider); + + context.subscriptions.push(treeView, ...commandDisposables); +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts new file mode 100644 index 00000000..2b12adc0 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -0,0 +1,201 @@ +/* + * 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 * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; +import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; + +export function registerWebDavCommands( + context: vscode.ExtensionContext, + configProvider: WebDavConfigProvider, + treeProvider: WebDavTreeDataProvider, +): vscode.Disposable[] { + const refresh = vscode.commands.registerCommand('b2c-dx.webdav.refresh', () => { + configProvider.reset(); + treeProvider.refresh(); + }); + + const newFolder = vscode.commands.registerCommand('b2c-dx.webdav.newFolder', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const name = await vscode.window.showInputBox({ + title: 'New Folder', + prompt: `Create directory under ${node.webdavPath}`, + placeHolder: 'Folder name', + validateInput: (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return 'Enter a folder name'; + if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; + return null; + }, + }); + if (!name) return; + + const fullPath = `${node.webdavPath}/${name.trim()}`; + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Creating folder ${name.trim()}...`}, + async () => { + try { + await instance.webdav.mkcol(fullPath); + treeProvider.refreshNode(node); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to create folder: ${message}`); + } + }, + ); + }); + + const uploadFile = vscode.commands.registerCommand('b2c-dx.webdav.uploadFile', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const uris = await vscode.window.showOpenDialog({ + title: 'Select file to upload', + canSelectFiles: true, + canSelectMany: false, + canSelectFolders: false, + }); + if (!uris?.length) return; + + const uri = uris[0]; + const fileName = path.basename(uri.fsPath); + const fullPath = `${node.webdavPath}/${fileName}`; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName}...`}, + async () => { + try { + const content = fs.readFileSync(uri.fsPath); + const ext = path.extname(fileName).toLowerCase(); + const mime: Record = { + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.html': 'text/html', + '.css': 'text/css', + '.txt': 'text/plain', + }; + await instance.webdav.put(fullPath, content, mime[ext]); + treeProvider.refreshNode(node); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Upload failed: ${message}`); + } + }, + ); + }); + + const deleteItem = vscode.commands.registerCommand('b2c-dx.webdav.delete', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const detail = node.isCollection + ? 'This directory and its contents will be deleted.' + : 'This file will be deleted.'; + const choice = await vscode.window.showWarningMessage( + `Delete "${node.fileName}"? ${detail}`, + {modal: true}, + 'Delete', + 'Cancel', + ); + if (choice !== 'Delete') return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Deleting ${node.fileName}...`}, + async () => { + try { + await instance.webdav.delete(node.webdavPath); + // Refresh parent by refreshing the whole tree β€” the parent node + // is not directly available from the child. + treeProvider.refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Delete failed: ${message}`); + } + }, + ); + }); + + const download = vscode.commands.registerCommand('b2c-dx.webdav.download', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri + ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, node.fileName) + : undefined; + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + saveLabel: 'Download', + }); + if (!saveUri) return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Downloading ${node.fileName}...`}, + async () => { + try { + const buffer = await instance.webdav.get(node.webdavPath); + await vscode.workspace.fs.writeFile(saveUri, new Uint8Array(buffer)); + vscode.window.showInformationMessage(`Downloaded to ${saveUri.fsPath}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Download failed: ${message}`); + } + }, + ); + }); + + const openFile = vscode.commands.registerCommand('b2c-dx.webdav.openFile', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const previewDir = vscode.Uri.joinPath(context.globalStorageUri, 'webdav-preview'); + const tempFileUri = vscode.Uri.joinPath(previewDir, node.webdavPath); + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Opening ${node.fileName}...`}, + async () => { + try { + const buffer = await instance.webdav.get(node.webdavPath); + // Ensure parent directories exist + const parentDir = vscode.Uri.joinPath(tempFileUri, '..'); + await vscode.workspace.fs.createDirectory(parentDir); + await vscode.workspace.fs.writeFile(tempFileUri, new Uint8Array(buffer)); + await vscode.commands.executeCommand('vscode.open', tempFileUri); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to open file: ${message}`); + } + }, + ); + }); + + return [refresh, newFolder, uploadFile, deleteItem, download, openFile]; +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts new file mode 100644 index 00000000..436fe051 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts @@ -0,0 +1,65 @@ +/* + * 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 {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +/** + * Manages B2CInstance lifecycle for the WebDAV tree view. + * Resolves config from dw.json / env vars, caches the instance, + * and exposes error state for the welcome view. + */ +export class WebDavConfigProvider { + private instance: B2CInstance | null = null; + private configError: string | null = null; + private resolved = false; + + getInstance(): B2CInstance | null { + if (!this.resolved) { + this.resolve(); + } + return this.instance; + } + + getConfigError(): string | null { + if (!this.resolved) { + this.resolve(); + } + return this.configError; + } + + reset(): void { + this.instance = null; + this.configError = null; + this.resolved = false; + } + + private resolve(): void { + this.resolved = true; + try { + let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { + workingDirectory = ''; + } + const dwPath = workingDirectory ? findDwJson(workingDirectory) : undefined; + const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + + if (!config.hasB2CInstanceConfig()) { + this.configError = 'No B2C Commerce instance configured.'; + this.instance = null; + return; + } + + this.instance = config.createB2CInstance(); + this.configError = null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.configError = message; + this.instance = null; + } + } +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts new file mode 100644 index 00000000..e863d9d6 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts @@ -0,0 +1,137 @@ +/* + * 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 * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; + +/** Standard B2C Commerce WebDAV root directories. */ +const WEBDAV_ROOTS: {key: string; path: string}[] = [ + {key: 'Impex', path: 'Impex'}, + {key: 'Temp', path: 'Temp'}, + {key: 'Cartridges', path: 'Cartridges'}, + {key: 'Realmdata', path: 'Realmdata'}, + {key: 'Catalogs', path: 'Catalogs'}, + {key: 'Libraries', path: 'Libraries'}, + {key: 'Static', path: 'Static'}, + {key: 'Logs', path: 'Logs'}, + {key: 'Securitylogs', path: 'Securitylogs'}, +]; + +function formatFileSize(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return ''; + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const k = 1024; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1); + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +export class WebDavTreeItem extends vscode.TreeItem { + constructor( + readonly nodeType: 'root' | 'directory' | 'file', + readonly webdavPath: string, + readonly fileName: string, + readonly isCollection: boolean, + readonly contentLength?: number, + ) { + super( + fileName, + nodeType === 'file' ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, + ); + + this.contextValue = nodeType; + this.tooltip = webdavPath; + + if (nodeType === 'root') { + this.iconPath = new vscode.ThemeIcon('database'); + } else if (nodeType === 'directory') { + this.iconPath = vscode.ThemeIcon.Folder; + } else { + this.iconPath = vscode.ThemeIcon.File; + this.resourceUri = vscode.Uri.parse(`webdav://b2c/${webdavPath}`); + if (contentLength !== undefined) { + this.description = formatFileSize(contentLength); + } + this.command = { + command: 'b2c-dx.webdav.openFile', + title: 'Open File', + arguments: [this], + }; + } + } +} + +export class WebDavTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private childrenCache = new Map(); + + constructor(private configProvider: WebDavConfigProvider) {} + + refresh(): void { + this.childrenCache.clear(); + this._onDidChangeTreeData.fire(); + } + + refreshNode(node: WebDavTreeItem): void { + this.childrenCache.delete(node.webdavPath); + this._onDidChangeTreeData.fire(node); + } + + getTreeItem(element: WebDavTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: WebDavTreeItem): Promise { + if (!element) { + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + return WEBDAV_ROOTS.map((r) => new WebDavTreeItem('root', r.path, r.key, true)); + } + + const cached = this.childrenCache.get(element.webdavPath); + if (cached) { + return cached; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + + try { + const entries = await instance.webdav.propfind(element.webdavPath, '1'); + const normalizedPath = element.webdavPath.replace(/\/$/, ''); + const filtered = entries.filter((entry) => { + const entryPath = decodeURIComponent(entry.href); + return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + }); + + const children = filtered + .map((entry) => { + const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; + const childPath = `${element.webdavPath}/${displayName}`; + const nodeType = entry.isCollection ? 'directory' : 'file'; + return new WebDavTreeItem(nodeType, childPath, displayName, entry.isCollection, entry.contentLength); + }) + .sort((a, b) => { + if (a.isCollection !== b.isCollection) { + return a.isCollection ? -1 : 1; + } + return a.fileName.localeCompare(b.fileName); + }); + + this.childrenCache.set(element.webdavPath, children); + return children; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to list ${element.webdavPath}: ${message}`); + return []; + } + } +} diff --git a/packages/b2c-vs-extension/src/webdav.html b/packages/b2c-vs-extension/src/webdav.html deleted file mode 100644 index 4f9d39d7..00000000 --- a/packages/b2c-vs-extension/src/webdav.html +++ /dev/null @@ -1,561 +0,0 @@ - - - - - - B2C WebDAV Browser - - - -
-

B2C WebDAV Browser

- - -
- - - -
- -
-
- Preview - -
-
-
- - - - From 7a826772062dd588952d015a04a5779b18ad9a4a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 19 Feb 2026 22:30:26 -0500 Subject: [PATCH 02/16] Add FileSystemProvider, New File command, and workspace folder mount - Add WebDavFileSystemProvider with caching, stat/readDirectory/readFile/ writeFile/createDirectory/delete operations against WebDAV - Root path handling returns synthetic directory listing of the 9 well-known B2C Commerce roots (avoids PROPFIND on "/") - Tree provider delegates to FS provider instead of calling WebDAV directly; root nodes use standard folder icons via resourceUri - New File command: prompts for filename, creates empty file, opens in editor - Mount/Unmount Workspace commands: add/remove b2c-webdav:/ as a VS Code workspace folder for native Explorer integration - Context key b2c-dx.webdav.mounted tracks mount state for menu visibility - Download command available in native Explorer context menu for b2c-webdav files --- packages/b2c-vs-extension/package.json | 41 +++ .../b2c-vs-extension/src/webdav-tree/index.ts | 22 +- .../src/webdav-tree/webdav-commands.ts | 110 ++++--- .../src/webdav-tree/webdav-fs-provider.ts | 287 ++++++++++++++++++ .../src/webdav-tree/webdav-tree-provider.ts | 102 +++---- 5 files changed, 444 insertions(+), 118 deletions(-) create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index fe5c5a8e..acc1b4a8 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -19,6 +19,7 @@ }, "activationEvents": [ "onView:b2cWebdavExplorer", + "onFileSystem:b2c-webdav", "onCommand:b2c-dx.openUI", "onCommand:b2c-dx.handleStorefrontNextCartridge", "onCommand:b2c-dx.promptAgent", @@ -119,6 +120,24 @@ "title": "Open File", "icon": "$(go-to-file)", "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.newFile", + "title": "New File", + "icon": "$(new-file)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "title": "Open as Workspace Folder", + "icon": "$(root-folder-opened)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.unmountWorkspace", + "title": "Remove Workspace Folder", + "icon": "$(root-folder)", + "category": "B2C DX" } ], "menus": { @@ -127,9 +146,24 @@ "command": "b2c-dx.webdav.refresh", "when": "view == b2cWebdavExplorer", "group": "navigation" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "when": "view == b2cWebdavExplorer && !b2c-dx.webdav.mounted", + "group": "navigation" + }, + { + "command": "b2c-dx.webdav.unmountWorkspace", + "when": "view == b2cWebdavExplorer && b2c-dx.webdav.mounted", + "group": "navigation" } ], "view/item/context": [ + { + "command": "b2c-dx.webdav.newFile", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@0" + }, { "command": "b2c-dx.webdav.newFolder", "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", @@ -155,6 +189,13 @@ "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", "group": "2_destructive@1" } + ], + "explorer/context": [ + { + "command": "b2c-dx.webdav.download", + "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder", + "group": "navigation" + } ] } }, diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts index 46783bd7..41fb5178 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/index.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -5,19 +5,35 @@ */ import * as vscode from 'vscode'; import {WebDavConfigProvider} from './webdav-config.js'; +import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js'; import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; import {registerWebDavCommands} from './webdav-commands.js'; +function syncMountedContext(): void { + const mounted = (vscode.workspace.workspaceFolders ?? []).some((f) => f.uri.scheme === WEBDAV_SCHEME); + vscode.commands.executeCommand('setContext', 'b2c-dx.webdav.mounted', mounted); +} + export function registerWebDavTree(context: vscode.ExtensionContext): void { const configProvider = new WebDavConfigProvider(); - const treeProvider = new WebDavTreeDataProvider(configProvider); + const fsProvider = new WebDavFileSystemProvider(configProvider); + + const fsRegistration = vscode.workspace.registerFileSystemProvider(WEBDAV_SCHEME, fsProvider, { + isCaseSensitive: true, + }); + + const treeProvider = new WebDavTreeDataProvider(configProvider, fsProvider); const treeView = vscode.window.createTreeView('b2cWebdavExplorer', { treeDataProvider: treeProvider, showCollapseAll: true, }); - const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider); + const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider); + + // Sync the mounted context key on activation and when workspace folders change + syncMountedContext(); + const folderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => syncMountedContext()); - context.subscriptions.push(treeView, ...commandDisposables); + context.subscriptions.push(fsRegistration, treeView, folderWatcher, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 2b12adc0..261b1bc5 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -7,25 +7,23 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; +import {type WebDavFileSystemProvider, WEBDAV_SCHEME, webdavPathToUri} from './webdav-fs-provider.js'; import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; export function registerWebDavCommands( - context: vscode.ExtensionContext, + _context: vscode.ExtensionContext, configProvider: WebDavConfigProvider, treeProvider: WebDavTreeDataProvider, + fsProvider: WebDavFileSystemProvider, ): vscode.Disposable[] { const refresh = vscode.commands.registerCommand('b2c-dx.webdav.refresh', () => { + fsProvider.clearCache(); configProvider.reset(); treeProvider.refresh(); }); const newFolder = vscode.commands.registerCommand('b2c-dx.webdav.newFolder', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const name = await vscode.window.showInputBox({ title: 'New Folder', @@ -45,8 +43,7 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Creating folder ${name.trim()}...`}, async () => { try { - await instance.webdav.mkcol(fullPath); - treeProvider.refreshNode(node); + await fsProvider.createDirectory(webdavPathToUri(fullPath)); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Failed to create folder: ${message}`); @@ -57,11 +54,6 @@ export function registerWebDavCommands( const uploadFile = vscode.commands.registerCommand('b2c-dx.webdav.uploadFile', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const uris = await vscode.window.showOpenDialog({ title: 'Select file to upload', @@ -80,19 +72,10 @@ export function registerWebDavCommands( async () => { try { const content = fs.readFileSync(uri.fsPath); - const ext = path.extname(fileName).toLowerCase(); - const mime: Record = { - '.json': 'application/json', - '.xml': 'application/xml', - '.zip': 'application/zip', - '.js': 'application/javascript', - '.ts': 'application/typescript', - '.html': 'text/html', - '.css': 'text/css', - '.txt': 'text/plain', - }; - await instance.webdav.put(fullPath, content, mime[ext]); - treeProvider.refreshNode(node); + await fsProvider.writeFile(webdavPathToUri(fullPath), new Uint8Array(content), { + create: true, + overwrite: true, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Upload failed: ${message}`); @@ -103,11 +86,6 @@ export function registerWebDavCommands( const deleteItem = vscode.commands.registerCommand('b2c-dx.webdav.delete', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const detail = node.isCollection ? 'This directory and its contents will be deleted.' @@ -124,10 +102,7 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Deleting ${node.fileName}...`}, async () => { try { - await instance.webdav.delete(node.webdavPath); - // Refresh parent by refreshing the whole tree β€” the parent node - // is not directly available from the child. - treeProvider.refresh(); + await fsProvider.delete(webdavPathToUri(node.webdavPath)); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Delete failed: ${message}`); @@ -138,11 +113,6 @@ export function registerWebDavCommands( const download = vscode.commands.registerCommand('b2c-dx.webdav.download', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, node.fileName) @@ -157,8 +127,8 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Downloading ${node.fileName}...`}, async () => { try { - const buffer = await instance.webdav.get(node.webdavPath); - await vscode.workspace.fs.writeFile(saveUri, new Uint8Array(buffer)); + const content = await fsProvider.readFile(webdavPathToUri(node.webdavPath)); + await vscode.workspace.fs.writeFile(saveUri, content); vscode.window.showInformationMessage(`Downloaded to ${saveUri.fsPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -170,32 +140,56 @@ export function registerWebDavCommands( const openFile = vscode.commands.registerCommand('b2c-dx.webdav.openFile', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } + const uri = webdavPathToUri(node.webdavPath); + await vscode.commands.executeCommand('vscode.open', uri); + }); - const previewDir = vscode.Uri.joinPath(context.globalStorageUri, 'webdav-preview'); - const tempFileUri = vscode.Uri.joinPath(previewDir, node.webdavPath); + const newFile = vscode.commands.registerCommand('b2c-dx.webdav.newFile', async (node: WebDavTreeItem) => { + if (!node) return; + const name = await vscode.window.showInputBox({ + title: 'New File', + prompt: `Create file under ${node.webdavPath}`, + placeHolder: 'File name', + validateInput: (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return 'Enter a file name'; + if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; + return null; + }, + }); + if (!name) return; + + const fullPath = `${node.webdavPath}/${name.trim()}`; + const uri = webdavPathToUri(fullPath); await vscode.window.withProgress( - {location: vscode.ProgressLocation.Notification, title: `Opening ${node.fileName}...`}, + {location: vscode.ProgressLocation.Notification, title: `Creating file ${name.trim()}...`}, async () => { try { - const buffer = await instance.webdav.get(node.webdavPath); - // Ensure parent directories exist - const parentDir = vscode.Uri.joinPath(tempFileUri, '..'); - await vscode.workspace.fs.createDirectory(parentDir); - await vscode.workspace.fs.writeFile(tempFileUri, new Uint8Array(buffer)); - await vscode.commands.executeCommand('vscode.open', tempFileUri); + await fsProvider.writeFile(uri, new Uint8Array(0), {create: true, overwrite: false}); + await vscode.commands.executeCommand('vscode.open', uri); } catch (err) { const message = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`WebDAV: Failed to open file: ${message}`); + vscode.window.showErrorMessage(`WebDAV: Failed to create file: ${message}`); } }, ); }); - return [refresh, newFolder, uploadFile, deleteItem, download, openFile]; + const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', () => { + vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length ?? 0, 0, { + uri: vscode.Uri.parse(`${WEBDAV_SCHEME}:/`), + name: 'B2C Commerce WebDAV', + }); + }); + + const unmountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.unmountWorkspace', () => { + const folders = vscode.workspace.workspaceFolders ?? []; + const idx = folders.findIndex((f) => f.uri.scheme === WEBDAV_SCHEME); + if (idx >= 0) { + vscode.workspace.updateWorkspaceFolders(idx, 1); + } + }); + + return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace, unmountWorkspace]; } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts new file mode 100644 index 00000000..e4abb26f --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts @@ -0,0 +1,287 @@ +/* + * 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 * as path from 'path'; +import * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; + +export const WEBDAV_SCHEME = 'b2c-webdav'; + +/** Standard B2C Commerce WebDAV root directories. */ +export const WEBDAV_ROOTS: {key: string; path: string}[] = [ + {key: 'Impex', path: 'Impex'}, + {key: 'Temp', path: 'Temp'}, + {key: 'Cartridges', path: 'Cartridges'}, + {key: 'Realmdata', path: 'Realmdata'}, + {key: 'Catalogs', path: 'Catalogs'}, + {key: 'Libraries', path: 'Libraries'}, + {key: 'Static', path: 'Static'}, + {key: 'Logs', path: 'Logs'}, + {key: 'Securitylogs', path: 'Securitylogs'}, +]; + +const CACHE_TTL_MS = 30_000; + +const MIME_BY_EXT: Record = { + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.html': 'text/html', + '.css': 'text/css', + '.txt': 'text/plain', +}; + +interface CachedStat { + stat: vscode.FileStat; + timestamp: number; +} + +interface CachedDir { + entries: [string, vscode.FileType][]; + timestamp: number; +} + +/** Convert a b2c-webdav URI to a WebDAV path (strip leading slash). */ +function uriToWebdavPath(uri: vscode.Uri): string { + return uri.path.replace(/^\//, ''); +} + +/** Build a b2c-webdav URI from a WebDAV path. */ +export function webdavPathToUri(webdavPath: string): vscode.Uri { + return vscode.Uri.parse(`${WEBDAV_SCHEME}:/${webdavPath}`); +} + +function isStale(timestamp: number): boolean { + return Date.now() - timestamp > CACHE_TTL_MS; +} + +function mapHttpError(err: unknown, uri: vscode.Uri): vscode.FileSystemError { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('404') || message.includes('Not Found')) { + return vscode.FileSystemError.FileNotFound(uri); + } + if ( + message.includes('401') || + message.includes('403') || + message.includes('Unauthorized') || + message.includes('Forbidden') + ) { + return vscode.FileSystemError.NoPermissions(uri); + } + return vscode.FileSystemError.Unavailable(message); +} + +export class WebDavFileSystemProvider implements vscode.FileSystemProvider { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private statCache = new Map(); + private dirCache = new Map(); + + constructor(private configProvider: WebDavConfigProvider) {} + + watch(): vscode.Disposable { + // WebDAV has no push notifications β€” return no-op disposable. + return new vscode.Disposable(() => {}); + } + + async stat(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + + // Synthetic root directory β€” avoids PROPFIND on "/" + if (!webdavPath) { + return {type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0}; + } + + const cached = this.statCache.get(webdavPath); + if (cached && !isStale(cached.timestamp)) { + return cached.stat; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const entries = await instance.webdav.propfind(webdavPath, '0'); + if (!entries.length) { + throw vscode.FileSystemError.FileNotFound(uri); + } + const entry = entries[0]; + const mtime = entry.lastModified ? entry.lastModified.getTime() : 0; + const fileStat: vscode.FileStat = { + type: entry.isCollection ? vscode.FileType.Directory : vscode.FileType.File, + ctime: mtime, + mtime, + size: entry.contentLength ?? 0, + }; + this.statCache.set(webdavPath, {stat: fileStat, timestamp: Date.now()}); + return fileStat; + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const webdavPath = uriToWebdavPath(uri); + + // Synthetic root listing β€” return the well-known roots + if (!webdavPath) { + return WEBDAV_ROOTS.map((r) => [r.key, vscode.FileType.Directory] as [string, vscode.FileType]); + } + + const cached = this.dirCache.get(webdavPath); + if (cached && !isStale(cached.timestamp)) { + return cached.entries; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const allEntries = await instance.webdav.propfind(webdavPath, '1'); + + // Filter out the self-entry + const normalizedPath = webdavPath.replace(/\/$/, ''); + const children = allEntries.filter((entry) => { + const entryPath = decodeURIComponent(entry.href); + return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + }); + + const now = Date.now(); + const result: [string, vscode.FileType][] = []; + + for (const entry of children) { + const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; + const childPath = `${webdavPath}/${displayName}`; + const fileType = entry.isCollection ? vscode.FileType.Directory : vscode.FileType.File; + const mtime = entry.lastModified ? entry.lastModified.getTime() : 0; + + // Populate stat cache for each child + this.statCache.set(childPath, { + stat: { + type: fileType, + ctime: mtime, + mtime, + size: entry.contentLength ?? 0, + }, + timestamp: now, + }); + + result.push([displayName, fileType]); + } + + this.dirCache.set(webdavPath, {entries: result, timestamp: now}); + return result; + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async readFile(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const buffer = await instance.webdav.get(webdavPath); + return new Uint8Array(buffer); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: {create: boolean; overwrite: boolean}, + ): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const ext = path.extname(webdavPath).toLowerCase(); + const contentType = MIME_BY_EXT[ext]; + await instance.webdav.put(webdavPath, Buffer.from(content), contentType); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Changed, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async createDirectory(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + await instance.webdav.mkcol(webdavPath); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Created, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async delete(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + await instance.webdav.delete(webdavPath); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Deleted, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + rename(): never { + throw vscode.FileSystemError.NoPermissions('Rename not supported'); + } + + /** Clear cached data for a path and its parent directory. If no path, clear everything. */ + clearCache(webdavPath?: string): void { + if (!webdavPath) { + this.statCache.clear(); + this.dirCache.clear(); + return; + } + this.statCache.delete(webdavPath); + this.dirCache.delete(webdavPath); + // Also invalidate parent + const parentPath = webdavPath.includes('/') ? webdavPath.substring(0, webdavPath.lastIndexOf('/')) : ''; + if (parentPath) { + this.statCache.delete(parentPath); + this.dirCache.delete(parentPath); + } + } + + private fireDid(type: vscode.FileChangeType, uri: vscode.Uri): void { + this._onDidChangeFile.fire([{type, uri}]); + } +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts index e863d9d6..3dc9b18f 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts @@ -5,19 +5,7 @@ */ import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; - -/** Standard B2C Commerce WebDAV root directories. */ -const WEBDAV_ROOTS: {key: string; path: string}[] = [ - {key: 'Impex', path: 'Impex'}, - {key: 'Temp', path: 'Temp'}, - {key: 'Cartridges', path: 'Cartridges'}, - {key: 'Realmdata', path: 'Realmdata'}, - {key: 'Catalogs', path: 'Catalogs'}, - {key: 'Libraries', path: 'Libraries'}, - {key: 'Static', path: 'Static'}, - {key: 'Logs', path: 'Logs'}, - {key: 'Securitylogs', path: 'Securitylogs'}, -]; +import {type WebDavFileSystemProvider, WEBDAV_ROOTS, webdavPathToUri} from './webdav-fs-provider.js'; function formatFileSize(bytes: number | undefined): string { if (bytes === undefined || bytes === null) return ''; @@ -44,20 +32,21 @@ export class WebDavTreeItem extends vscode.TreeItem { this.contextValue = nodeType; this.tooltip = webdavPath; + const resourceUri = webdavPathToUri(webdavPath); + if (nodeType === 'root') { - this.iconPath = new vscode.ThemeIcon('database'); + this.resourceUri = resourceUri; } else if (nodeType === 'directory') { - this.iconPath = vscode.ThemeIcon.Folder; + this.resourceUri = resourceUri; } else { - this.iconPath = vscode.ThemeIcon.File; - this.resourceUri = vscode.Uri.parse(`webdav://b2c/${webdavPath}`); + this.resourceUri = resourceUri; if (contentLength !== undefined) { this.description = formatFileSize(contentLength); } this.command = { - command: 'b2c-dx.webdav.openFile', + command: 'vscode.open', title: 'Open File', - arguments: [this], + arguments: [resourceUri], }; } } @@ -67,20 +56,21 @@ export class WebDavTreeDataProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private childrenCache = new Map(); - - constructor(private configProvider: WebDavConfigProvider) {} + constructor( + private configProvider: WebDavConfigProvider, + private fsProvider: WebDavFileSystemProvider, + ) { + // Auto-refresh the tree when the FS provider fires change events + this.fsProvider.onDidChangeFile(() => { + this._onDidChangeTreeData.fire(); + }); + } refresh(): void { - this.childrenCache.clear(); + this.fsProvider.clearCache(); this._onDidChangeTreeData.fire(); } - refreshNode(node: WebDavTreeItem): void { - this.childrenCache.delete(node.webdavPath); - this._onDidChangeTreeData.fire(node); - } - getTreeItem(element: WebDavTreeItem): vscode.TreeItem { return element; } @@ -94,39 +84,37 @@ export class WebDavTreeDataProvider implements vscode.TreeDataProvider new WebDavTreeItem('root', r.path, r.key, true)); } - const cached = this.childrenCache.get(element.webdavPath); - if (cached) { - return cached; - } + try { + const uri = webdavPathToUri(element.webdavPath); + const entries = await this.fsProvider.readDirectory(uri); + + const children: WebDavTreeItem[] = []; + for (const [name, fileType] of entries) { + const childPath = `${element.webdavPath}/${name}`; + const isCollection = fileType === vscode.FileType.Directory; + const nodeType = isCollection ? 'directory' : 'file'; + + let contentLength: number | undefined; + if (!isCollection) { + try { + const childStat = await this.fsProvider.stat(webdavPathToUri(childPath)); + contentLength = childStat.size; + } catch { + // Stat may fail β€” show item without size + } + } - const instance = this.configProvider.getInstance(); - if (!instance) { - return []; - } + children.push(new WebDavTreeItem(nodeType, childPath, name, isCollection, contentLength)); + } - try { - const entries = await instance.webdav.propfind(element.webdavPath, '1'); - const normalizedPath = element.webdavPath.replace(/\/$/, ''); - const filtered = entries.filter((entry) => { - const entryPath = decodeURIComponent(entry.href); - return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + // Sort: directories first, then alphabetical + children.sort((a, b) => { + if (a.isCollection !== b.isCollection) { + return a.isCollection ? -1 : 1; + } + return a.fileName.localeCompare(b.fileName); }); - const children = filtered - .map((entry) => { - const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; - const childPath = `${element.webdavPath}/${displayName}`; - const nodeType = entry.isCollection ? 'directory' : 'file'; - return new WebDavTreeItem(nodeType, childPath, displayName, entry.isCollection, entry.contentLength); - }) - .sort((a, b) => { - if (a.isCollection !== b.isCollection) { - return a.isCollection ? -1 : 1; - } - return a.fileName.localeCompare(b.fileName); - }); - - this.childrenCache.set(element.webdavPath, children); return children; } catch (err) { const message = err instanceof Error ? err.message : String(err); From d670a05457f4f473f472ff1a2ca214c284134b66 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 20 Feb 2026 18:11:21 -0500 Subject: [PATCH 03/16] Mount individual folders instead of entire WebDAV root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change "Open as Workspace Folder" to operate on the right-clicked node (root or directory) rather than mounting b2c-webdav:/ globally. The workspace folder is named "WebDAV: " for clarity. Move mount command from view title bar to context menu. Remove unmountWorkspace command and mounted context key tracking β€” VS Code's native "Remove Folder from Workspace" handles unmounting. --- packages/b2c-vs-extension/package.json | 21 +++++-------------- .../b2c-vs-extension/src/webdav-tree/index.ts | 11 +--------- .../src/webdav-tree/webdav-commands.ts | 20 +++++++----------- 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index acc1b4a8..8aad31f5 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -132,12 +132,6 @@ "title": "Open as Workspace Folder", "icon": "$(root-folder-opened)", "category": "B2C DX" - }, - { - "command": "b2c-dx.webdav.unmountWorkspace", - "title": "Remove Workspace Folder", - "icon": "$(root-folder)", - "category": "B2C DX" } ], "menus": { @@ -146,16 +140,6 @@ "command": "b2c-dx.webdav.refresh", "when": "view == b2cWebdavExplorer", "group": "navigation" - }, - { - "command": "b2c-dx.webdav.mountWorkspace", - "when": "view == b2cWebdavExplorer && !b2c-dx.webdav.mounted", - "group": "navigation" - }, - { - "command": "b2c-dx.webdav.unmountWorkspace", - "when": "view == b2cWebdavExplorer && b2c-dx.webdav.mounted", - "group": "navigation" } ], "view/item/context": [ @@ -188,6 +172,11 @@ "command": "b2c-dx.webdav.delete", "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", "group": "2_destructive@1" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "3_workspace@1" } ], "explorer/context": [ diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts index 41fb5178..72fde9fd 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/index.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -9,11 +9,6 @@ import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js'; import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; import {registerWebDavCommands} from './webdav-commands.js'; -function syncMountedContext(): void { - const mounted = (vscode.workspace.workspaceFolders ?? []).some((f) => f.uri.scheme === WEBDAV_SCHEME); - vscode.commands.executeCommand('setContext', 'b2c-dx.webdav.mounted', mounted); -} - export function registerWebDavTree(context: vscode.ExtensionContext): void { const configProvider = new WebDavConfigProvider(); const fsProvider = new WebDavFileSystemProvider(configProvider); @@ -31,9 +26,5 @@ export function registerWebDavTree(context: vscode.ExtensionContext): void { const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider); - // Sync the mounted context key on activation and when workspace folders change - syncMountedContext(); - const folderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => syncMountedContext()); - - context.subscriptions.push(fsRegistration, treeView, folderWatcher, ...commandDisposables); + context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 261b1bc5..4630ef96 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; -import {type WebDavFileSystemProvider, WEBDAV_SCHEME, webdavPathToUri} from './webdav-fs-provider.js'; +import {type WebDavFileSystemProvider, webdavPathToUri} from './webdav-fs-provider.js'; import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; export function registerWebDavCommands( @@ -176,20 +176,14 @@ export function registerWebDavCommands( ); }); - const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', () => { + const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', (node: WebDavTreeItem) => { + if (!node) return; + const uri = webdavPathToUri(node.webdavPath); vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length ?? 0, 0, { - uri: vscode.Uri.parse(`${WEBDAV_SCHEME}:/`), - name: 'B2C Commerce WebDAV', + uri, + name: `WebDAV: ${node.webdavPath}`, }); }); - const unmountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.unmountWorkspace', () => { - const folders = vscode.workspace.workspaceFolders ?? []; - const idx = folders.findIndex((f) => f.uri.scheme === WEBDAV_SCHEME); - if (idx >= 0) { - vscode.workspace.updateWorkspaceFolders(idx, 1); - } - }); - - return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace, unmountWorkspace]; + return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace]; } From 1f663c7c0323918a48ee55dc3fb3522749700ed3 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 20 Feb 2026 20:07:33 -0500 Subject: [PATCH 04/16] wip content --- .../src/operations/jobs/site-archive.ts | 61 +++++- packages/b2c-vs-extension/package.json | 115 +++++++++- .../src/content-tree/content-commands.ts | 206 ++++++++++++++++++ .../src/content-tree/content-config.ts | 111 ++++++++++ .../src/content-tree/content-fs-provider.ts | 180 +++++++++++++++ .../src/content-tree/content-tree-provider.ts | 191 ++++++++++++++++ .../src/content-tree/index.ts | 38 ++++ packages/b2c-vs-extension/src/extension.ts | 2 + 8 files changed, 900 insertions(+), 4 deletions(-) create mode 100644 packages/b2c-vs-extension/src/content-tree/content-commands.ts create mode 100644 packages/b2c-vs-extension/src/content-tree/content-config.ts create mode 100644 packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts create mode 100644 packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts create mode 100644 packages/b2c-vs-extension/src/content-tree/index.ts diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index e4254d1b..28a6899f 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -98,8 +98,9 @@ export async function siteArchiveImport( if (!archiveName) { throw new Error('archiveName is required when importing from a Buffer'); } - zipFilename = archiveName.endsWith('.zip') ? archiveName : `${archiveName}.zip`; - archiveContent = target; + const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName; + zipFilename = `${baseName}.zip`; + archiveContent = await ensureArchiveStructure(target, baseName, logger); } else { // File path - check if directory or zip file const targetPath = target as string; @@ -236,6 +237,62 @@ async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise, +): Promise { + let zip: JSZip; + try { + zip = await JSZip.loadAsync(buffer); + } catch { + // If we can't parse the zip, pass it through as-is + logger.debug('Could not parse zip buffer for structure check; passing through as-is'); + return buffer; + } + + // Determine the unique top-level directory names + const topLevelEntries = new Set(); + for (const filePath of Object.keys(zip.files)) { + const topLevel = filePath.split('/')[0]; + topLevelEntries.add(topLevel); + } + + if (topLevelEntries.size === 1 && topLevelEntries.has(archiveDirName)) { + return buffer; // Already correctly structured + } + + // Re-wrap all entries under archiveDirName/ + logger.debug( + {archiveDirName, topLevelEntries: [...topLevelEntries]}, + `Re-wrapping archive contents under ${archiveDirName}/`, + ); + + const newZip = new JSZip(); + const rootFolder = newZip.folder(archiveDirName)!; + + for (const [filePath, entry] of Object.entries(zip.files)) { + if (!entry.dir) { + const content = await entry.async('nodebuffer'); + rootFolder.file(filePath, content); + } + } + + return newZip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: {level: 9}, + }); +} + /** * Configuration for sites in export. */ diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 8aad31f5..35211ef0 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -19,7 +19,9 @@ }, "activationEvents": [ "onView:b2cWebdavExplorer", + "onView:b2cContentExplorer", "onFileSystem:b2c-webdav", + "onFileSystem:b2c-content", "onCommand:b2c-dx.openUI", "onCommand:b2c-dx.handleStorefrontNextCartridge", "onCommand:b2c-dx.promptAgent", @@ -33,7 +35,7 @@ "activitybar": [ { "id": "b2c-dx", - "title": "B2C-DX WebDAV", + "title": "B2C-DX", "icon": "media/b2c-icon.svg" } ] @@ -42,9 +44,15 @@ "b2c-dx": [ { "id": "b2cWebdavExplorer", - "name": "Browser", + "name": "WebDAV Browser", "icon": "media/b2c-icon.svg", "contextualTitle": "B2C Commerce" + }, + { + "id": "b2cContentExplorer", + "name": "Libraries", + "icon": "media/b2c-icon.svg", + "contextualTitle": "B2C Commerce Content" } ] }, @@ -52,6 +60,10 @@ { "view": "b2cWebdavExplorer", "contents": "No B2C Commerce instance configured.\n\nCreate a dw.json file in your workspace or set SFCC_* environment variables.\n\n[Refresh](command:b2c-dx.webdav.refresh)" + }, + { + "view": "b2cContentExplorer", + "contents": "No content libraries configured.\n\nSet \"contentLibrary\" in dw.json or add a library manually.\n\n[Add Library](command:b2c-dx.content.addLibrary)" } ], "commands": [ @@ -132,6 +144,60 @@ "title": "Open as Workspace Folder", "icon": "$(root-folder-opened)", "category": "B2C DX" + }, + { + "command": "b2c-dx.content.refresh", + "title": "Refresh", + "icon": "$(refresh)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.addLibrary", + "title": "Add Library", + "icon": "$(add)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.removeLibrary", + "title": "Remove Library", + "icon": "$(remove)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.export", + "title": "Export", + "icon": "$(cloud-download)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.exportNoAssets", + "title": "Export without Assets", + "icon": "$(file-code)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.exportAssets", + "title": "Export Assets Only", + "icon": "$(file-media)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.filter", + "title": "Filter", + "icon": "$(filter)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.clearFilter", + "title": "Clear Filter", + "icon": "$(clear-all)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.import", + "title": "B2C-DX: Import Site Archive", + "icon": "$(cloud-upload)", + "category": "B2C DX" } ], "menus": { @@ -140,6 +206,26 @@ "command": "b2c-dx.webdav.refresh", "when": "view == b2cWebdavExplorer", "group": "navigation" + }, + { + "command": "b2c-dx.content.refresh", + "when": "view == b2cContentExplorer", + "group": "navigation" + }, + { + "command": "b2c-dx.content.addLibrary", + "when": "view == b2cContentExplorer", + "group": "navigation" + }, + { + "command": "b2c-dx.content.filter", + "when": "view == b2cContentExplorer && !b2cContentFilterActive", + "group": "navigation" + }, + { + "command": "b2c-dx.content.clearFilter", + "when": "view == b2cContentExplorer && b2cContentFilterActive", + "group": "navigation" } ], "view/item/context": [ @@ -177,6 +263,26 @@ "command": "b2c-dx.webdav.mountWorkspace", "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", "group": "3_workspace@1" + }, + { + "command": "b2c-dx.content.export", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "group": "1_export@1" + }, + { + "command": "b2c-dx.content.exportNoAssets", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "group": "1_export@2" + }, + { + "command": "b2c-dx.content.exportAssets", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "group": "1_export@3" + }, + { + "command": "b2c-dx.content.removeLibrary", + "when": "view == b2cContentExplorer && viewItem == library", + "group": "2_manage@1" } ], "explorer/context": [ @@ -184,6 +290,11 @@ "command": "b2c-dx.webdav.download", "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder", "group": "navigation" + }, + { + "command": "b2c-dx.content.import", + "when": "explorerResourceIsFolder", + "group": "7_modification@9" } ] } diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts new file mode 100644 index 00000000..c68a48bd --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -0,0 +1,206 @@ +/* + * 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 {siteArchiveImport} from '@salesforce/b2c-tooling-sdk'; +import {exportContent} from '@salesforce/b2c-tooling-sdk/operations/content'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type {ContentConfigProvider} from './content-config.js'; +import type {ContentFileSystemProvider} from './content-fs-provider.js'; +import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; + +export function registerContentCommands( + _context: vscode.ExtensionContext, + configProvider: ContentConfigProvider, + treeProvider: ContentTreeDataProvider, + _fsProvider: ContentFileSystemProvider, +): vscode.Disposable[] { + const refresh = vscode.commands.registerCommand('b2c-dx.content.refresh', () => { + configProvider.clearCache(); + configProvider.reset(); + treeProvider.refresh(); + }); + + const addLibrary = vscode.commands.registerCommand('b2c-dx.content.addLibrary', async () => { + const id = await vscode.window.showInputBox({ + title: 'Add Content Library', + prompt: 'Enter the library ID (or site ID for site-private libraries)', + placeHolder: 'e.g., SharedLibrary', + validateInput: (value: string) => { + if (!value.trim()) return 'Enter a library ID'; + return null; + }, + }); + if (!id) return; + + const choice = await vscode.window.showQuickPick(['Shared Library', 'Site-Private Library'], { + title: 'Library Type', + placeHolder: 'Select the library type', + }); + if (!choice) return; + + const isSiteLibrary = choice === 'Site-Private Library'; + configProvider.addLibrary(id.trim(), isSiteLibrary); + treeProvider.refresh(); + }); + + const removeLibrary = vscode.commands.registerCommand('b2c-dx.content.removeLibrary', (node: ContentTreeItem) => { + if (!node || node.nodeType !== 'library') return; + configProvider.removeLibrary(node.libraryId); + treeProvider.refresh(); + }); + + async function runExport( + node: ContentTreeItem, + {offline, assetsOnly}: {offline: boolean; assetsOnly: boolean}, + ): Promise { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('No B2C Commerce instance configured.'); + return; + } + + const dialogTitle = assetsOnly ? 'Select directory for static assets' : 'Select export directory'; + const folders = await vscode.window.showOpenDialog({ + title: dialogTitle, + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Export Here', + }); + if (!folders?.length) return; + + const outputPath = folders[0].fsPath; + const label = assetsOnly ? 'static assets for' : offline ? '(without assets)' : ''; + const progressTitle = `Exporting ${label ? `${label} ` : ''}${node.contentId}...`; + + let result; + try { + result = await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false}, + async (progress) => { + return exportContent(instance, [node.contentId], node.libraryId, outputPath, { + isSiteLibrary: node.isSiteLibrary, + offline, + onAssetProgress: (_asset, index, total) => { + progress.report({ + message: `Downloading asset ${index + 1}/${total}`, + increment: (1 / total) * 100, + }); + }, + }); + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Export failed: ${message}`); + return; + } + + let msg: string; + if (assetsOnly) { + if (result.downloadedAssets.length === 0) { + vscode.window.showInformationMessage('No static assets found for this content item.'); + return; + } + msg = `Downloaded ${result.downloadedAssets.length} static asset(s)`; + } else { + const parts = [ + result.pageCount > 0 ? `${result.pageCount} page(s)` : '', + result.contentCount > 0 ? `${result.contentCount} content asset(s)` : '', + result.componentCount > 0 ? `${result.componentCount} component(s)` : '', + result.downloadedAssets.length > 0 ? `${result.downloadedAssets.length} static asset(s)` : '', + ].filter(Boolean); + msg = `Exported ${parts.join(', ')}`; + } + + const outputUri = vscode.Uri.file(outputPath); + const isInWorkspace = vscode.workspace.getWorkspaceFolder(outputUri) !== undefined; + const actions = isInWorkspace ? ['Reveal in Explorer', 'Reveal in Finder'] : ['Reveal in Finder']; + const action = await vscode.window.showInformationMessage(msg, ...actions); + if (action === 'Reveal in Explorer') { + await vscode.commands.executeCommand('revealInExplorer', outputUri); + } else if (action === 'Reveal in Finder') { + await vscode.commands.executeCommand('revealFileInOS', outputUri); + } + } + + const exportCmd = vscode.commands.registerCommand('b2c-dx.content.export', async (node: ContentTreeItem) => { + if (!node) return; + await runExport(node, {offline: false, assetsOnly: false}); + }); + + const exportNoAssets = vscode.commands.registerCommand( + 'b2c-dx.content.exportNoAssets', + async (node: ContentTreeItem) => { + if (!node) return; + await runExport(node, {offline: true, assetsOnly: false}); + }, + ); + + const exportAssets = vscode.commands.registerCommand('b2c-dx.content.exportAssets', async (node: ContentTreeItem) => { + if (!node) return; + await runExport(node, {offline: false, assetsOnly: true}); + }); + + const filter = vscode.commands.registerCommand('b2c-dx.content.filter', async () => { + const current = treeProvider.getFilter(); + const value = await vscode.window.showInputBox({ + title: 'Filter Content', + prompt: 'Filter pages and content assets by name', + placeHolder: 'e.g., homepage', + value: current ?? '', + }); + if (value === undefined) return; // cancelled + treeProvider.setFilter(value || undefined); + }); + + const clearFilter = vscode.commands.registerCommand('b2c-dx.content.clearFilter', () => { + treeProvider.setFilter(undefined); + }); + + const importCmd = vscode.commands.registerCommand('b2c-dx.content.import', async (uri?: vscode.Uri) => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('No B2C Commerce instance configured.'); + return; + } + + let importPath: string; + if (uri) { + importPath = uri.fsPath; + } else { + const folders = await vscode.window.showOpenDialog({ + title: 'Select site archive directory to import', + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Import', + }); + if (!folders?.length) return; + importPath = folders[0].fsPath; + } + + try { + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Importing ${path.basename(importPath)}...`}, + async () => { + await siteArchiveImport(instance, importPath); + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Import failed: ${message}`); + return; + } + + configProvider.clearCache(); + treeProvider.refresh(); + vscode.window.showInformationMessage('Site archive imported successfully.'); + }); + + return [refresh, addLibrary, removeLibrary, exportCmd, exportNoAssets, exportAssets, filter, clearFilter, importCmd]; +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-config.ts b/packages/b2c-vs-extension/src/content-tree/content-config.ts new file mode 100644 index 00000000..d7fb66cd --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-config.ts @@ -0,0 +1,111 @@ +/* + * 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 {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import type {Library} from '@salesforce/b2c-tooling-sdk/operations/content'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +export interface BrowsedLibrary { + id: string; + isSiteLibrary: boolean; +} + +export class ContentConfigProvider { + private instance: B2CInstance | null = null; + private configError: string | null = null; + private resolved = false; + private libraries: BrowsedLibrary[] = []; + private libraryCache = new Map(); + private contentLibrary: string | undefined; + + getInstance(): B2CInstance | null { + if (!this.resolved) { + this.resolve(); + } + return this.instance; + } + + getConfigError(): string | null { + if (!this.resolved) { + this.resolve(); + } + return this.configError; + } + + getContentLibrary(): string | undefined { + if (!this.resolved) { + this.resolve(); + } + return this.contentLibrary; + } + + getLibraries(): BrowsedLibrary[] { + return this.libraries; + } + + addLibrary(id: string, isSiteLibrary: boolean): void { + if (!this.libraries.some((l) => l.id === id && l.isSiteLibrary === isSiteLibrary)) { + this.libraries.push({id, isSiteLibrary}); + } + } + + removeLibrary(id: string): void { + this.libraries = this.libraries.filter((l) => l.id !== id); + this.libraryCache.delete(id); + } + + getCachedLibrary(id: string): Library | undefined { + return this.libraryCache.get(id); + } + + setCachedLibrary(id: string, library: Library): void { + this.libraryCache.set(id, library); + } + + invalidateLibrary(id: string): void { + this.libraryCache.delete(id); + } + + clearCache(): void { + this.libraryCache.clear(); + } + + reset(): void { + this.instance = null; + this.configError = null; + this.resolved = false; + this.libraryCache.clear(); + } + + private resolve(): void { + this.resolved = true; + try { + let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { + workingDirectory = ''; + } + const dwPath = workingDirectory ? findDwJson(workingDirectory) : undefined; + const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + + this.contentLibrary = config.values.contentLibrary; + + if (!config.hasB2CInstanceConfig()) { + this.configError = 'No B2C Commerce instance configured.'; + this.instance = null; + return; + } + + this.instance = config.createB2CInstance(); + this.configError = null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.configError = message; + this.instance = null; + } + } +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts new file mode 100644 index 00000000..b5730f6f --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -0,0 +1,180 @@ +/* + * 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 type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content'; +import {siteArchiveImport} from '@salesforce/b2c-tooling-sdk'; +import JSZip from 'jszip'; +import * as xml2js from 'xml2js'; +import * as vscode from 'vscode'; +import type {ContentConfigProvider} from './content-config.js'; + +export const CONTENT_SCHEME = 'b2c-content'; + +interface ParsedContentUri { + libraryId: string; + contentId?: string; + isSiteLibrary: boolean; +} + +function parseContentUri(uri: vscode.Uri): ParsedContentUri { + const parts = uri.path.replace(/^\//, '').split('/'); + if (parts[0] === 'site') { + return { + libraryId: parts[1], + contentId: parts[2]?.replace(/\.xml$/, ''), + isSiteLibrary: true, + }; + } + return { + libraryId: parts[0], + contentId: parts[1]?.replace(/\.xml$/, ''), + isSiteLibrary: false, + }; +} + +export function contentItemUri(libraryId: string, isSiteLibrary: boolean, contentId: string): vscode.Uri { + const uriPath = isSiteLibrary ? `/site/${libraryId}/${contentId}.xml` : `/${libraryId}/${contentId}.xml`; + return vscode.Uri.parse(`${CONTENT_SCHEME}:${uriPath}`); +} + +/** + * Generate library XML for a single content item and its descendants, + * without mutating the cached Library instance. + */ +function generateContentXML(library: Library, contentId: string): string { + let target: LibraryNode | undefined; + for (const node of library.nodes({traverseHidden: true, callbackHidden: true})) { + if (node.id === contentId) { + target = node as LibraryNode; + break; + } + } + if (!target) { + throw new Error(`Content "${contentId}" not found in library`); + } + + // Collect xml objects from target and all descendants + const xmlObjects: Record[] = []; + function collect(node: LibraryNode): void { + if (node.xml) { + // Sync in-memory data back to the xml representation + if (node.data) { + const dataEl = (node.xml['data'] as Array>)?.[0]; + if (dataEl) { + dataEl['_'] = JSON.stringify(node.data, null, 2); + } + } + xmlObjects.push(node.xml); + } + for (const child of node.children) { + collect(child); + } + } + collect(target); + + // Build a minimal library XML document with just the selected content + const origLibrary = library.xml['library'] as Record; + const xmlDoc = { + library: { + $: origLibrary['$'], + content: xmlObjects, + }, + }; + + return new xml2js.Builder().buildObject(xmlDoc); +} + +export class ContentFileSystemProvider implements vscode.FileSystemProvider { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor(private configProvider: ContentConfigProvider) {} + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + async stat(uri: vscode.Uri): Promise { + const {contentId} = parseContentUri(uri); + if (!contentId) { + // Library root β€” directory + return {type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0}; + } + // Content item β€” file + return {type: vscode.FileType.File, ctime: 0, mtime: Date.now(), size: 0}; + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const {libraryId} = parseContentUri(uri); + const library = this.configProvider.getCachedLibrary(libraryId); + if (!library) { + return []; + } + return library.tree.children + .filter((node) => !node.hidden) + .map((node) => [`${node.id}.xml`, vscode.FileType.File] as [string, vscode.FileType]); + } + + async readFile(uri: vscode.Uri): Promise { + const {libraryId, contentId} = parseContentUri(uri); + if (!contentId) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const library = this.configProvider.getCachedLibrary(libraryId); + if (!library) { + throw vscode.FileSystemError.Unavailable( + `Library "${libraryId}" not loaded. Expand it in the Content tree first.`, + ); + } + + const xmlString = generateContentXML(library, contentId); + return new TextEncoder().encode(xmlString); + } + + async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + const {libraryId, isSiteLibrary} = parseContentUri(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + const xmlContent = Buffer.from(content).toString('utf-8'); + const archivePath = isSiteLibrary ? `sites/${libraryId}/library/library.xml` : `libraries/${libraryId}/library.xml`; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`}, + async () => { + const archiveName = `content-update-${Date.now()}`; + const zip = new JSZip(); + zip.file(archivePath, xmlContent); + const buffer = await zip.generateAsync({type: 'nodebuffer'}); + await siteArchiveImport(instance, buffer, {archiveName}); + }, + ); + + // Invalidate cache since the instance was updated + this.configProvider.invalidateLibrary(libraryId); + this._onDidChangeFile.fire([{type: vscode.FileChangeType.Changed, uri}]); + vscode.window.showInformationMessage(`Content imported to ${libraryId} successfully.`); + } + + createDirectory(): never { + throw vscode.FileSystemError.NoPermissions('Content structure is managed by the commerce platform'); + } + + delete(): never { + throw vscode.FileSystemError.NoPermissions('Content structure is managed by the commerce platform'); + } + + rename(): never { + throw vscode.FileSystemError.NoPermissions('Rename not supported'); + } + + fireDidChange(uri: vscode.Uri): void { + this._onDidChangeFile.fire([{type: vscode.FileChangeType.Changed, uri}]); + } +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts new file mode 100644 index 00000000..42c1a9aa --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts @@ -0,0 +1,191 @@ +/* + * 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 type {LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content'; +import {fetchContentLibrary} from '@salesforce/b2c-tooling-sdk/operations/content'; +import * as vscode from 'vscode'; +import type {ContentConfigProvider} from './content-config.js'; +import {contentItemUri} from './content-fs-provider.js'; + +type ContentNodeType = 'library' | 'page' | 'content' | 'component' | 'static'; + +export class ContentTreeItem extends vscode.TreeItem { + constructor( + readonly nodeType: ContentNodeType, + readonly libraryId: string, + readonly isSiteLibrary: boolean, + readonly contentId: string, + readonly libraryNode?: LibraryNode, + ) { + const label = + nodeType === 'library' + ? isSiteLibrary + ? `${libraryId} [site]` + : libraryId + : nodeType === 'component' && libraryNode?.typeId + ? libraryNode.typeId + : contentId; + + const collapsible = + nodeType === 'static' + ? vscode.TreeItemCollapsibleState.None + : nodeType === 'library' || nodeType === 'page' + ? vscode.TreeItemCollapsibleState.Collapsed + : (libraryNode?.children.length ?? 0) > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + + super(label, collapsible); + + this.contextValue = nodeType; + + // Show content ID as description for components (label is typeId) + if (nodeType === 'component' && libraryNode?.typeId) { + this.description = contentId; + } + + // Type suffix for content assets + if (nodeType === 'content') { + this.description = 'CONTENT ASSET'; + } + + // Icons + switch (nodeType) { + case 'library': + this.iconPath = new vscode.ThemeIcon('library'); + break; + case 'page': + this.iconPath = new vscode.ThemeIcon('file-code'); + break; + case 'content': + this.iconPath = new vscode.ThemeIcon('file-text'); + break; + case 'component': + this.iconPath = new vscode.ThemeIcon('symbol-class'); + break; + case 'static': + this.iconPath = new vscode.ThemeIcon('file-media'); + break; + } + + // Click command for openable items + if (nodeType !== 'library' && nodeType !== 'static') { + const uri = contentItemUri(libraryId, isSiteLibrary, contentId); + this.command = { + command: 'vscode.open', + title: 'Open Content', + arguments: [uri], + }; + } + } +} + +export class ContentTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private filterPattern: string | undefined; + + constructor(private configProvider: ContentConfigProvider) {} + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + setFilter(pattern: string | undefined): void { + this.filterPattern = pattern; + vscode.commands.executeCommand('setContext', 'b2cContentFilterActive', !!pattern); + this._onDidChangeTreeData.fire(); + } + + getFilter(): string | undefined { + return this.filterPattern; + } + + getTreeItem(element: ContentTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: ContentTreeItem): Promise { + if (!element) { + return this.getRootChildren(); + } + + if (element.nodeType === 'library') { + return this.getLibraryChildren(element); + } + + // PAGE, CONTENT, COMPONENT: return children from the libraryNode reference + if (element.libraryNode) { + return element.libraryNode.children.map((node) => + this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary), + ); + } + + return []; + } + + private getRootChildren(): ContentTreeItem[] { + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + + // Auto-add configured library if list is empty + const libraries = this.configProvider.getLibraries(); + if (libraries.length === 0) { + const contentLibrary = this.configProvider.getContentLibrary(); + if (contentLibrary) { + this.configProvider.addLibrary(contentLibrary, false); + } + } + + return this.configProvider + .getLibraries() + .map((lib) => new ContentTreeItem('library', lib.id, lib.isSiteLibrary, lib.id)); + } + + private async getLibraryChildren(element: ContentTreeItem): Promise { + let library = this.configProvider.getCachedLibrary(element.libraryId); + + if (!library) { + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + + try { + library = await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Fetching library ${element.libraryId}...`}, + async () => { + const result = await fetchContentLibrary(instance, element.libraryId, { + isSiteLibrary: element.isSiteLibrary, + }); + return result.library; + }, + ); + this.configProvider.setCachedLibrary(element.libraryId, library); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to fetch library ${element.libraryId}: ${message}`); + return []; + } + } + + let children = library.tree.children.filter((node) => !node.hidden); + + if (this.filterPattern) { + const lower = this.filterPattern.toLowerCase(); + children = children.filter((node) => node.id.toLowerCase().includes(lower)); + } + + return children.map((node) => this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary)); + } + + private nodeToTreeItem(node: LibraryNode, libraryId: string, isSiteLibrary: boolean): ContentTreeItem { + const nodeType = node.type.toLowerCase() as ContentNodeType; + return new ContentTreeItem(nodeType, libraryId, isSiteLibrary, node.id, node); + } +} diff --git a/packages/b2c-vs-extension/src/content-tree/index.ts b/packages/b2c-vs-extension/src/content-tree/index.ts new file mode 100644 index 00000000..b95002ba --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/index.ts @@ -0,0 +1,38 @@ +/* + * 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 * as vscode from 'vscode'; +import {ContentConfigProvider} from './content-config.js'; +import {CONTENT_SCHEME, ContentFileSystemProvider} from './content-fs-provider.js'; +import {ContentTreeDataProvider} from './content-tree-provider.js'; +import {registerContentCommands} from './content-commands.js'; + +export function registerContentTree(context: vscode.ExtensionContext): void { + const configProvider = new ContentConfigProvider(); + const fsProvider = new ContentFileSystemProvider(configProvider); + + const fsRegistration = vscode.workspace.registerFileSystemProvider(CONTENT_SCHEME, fsProvider, { + isCaseSensitive: true, + isReadonly: false, + }); + + const treeProvider = new ContentTreeDataProvider(configProvider); + + const treeView = vscode.window.createTreeView('b2cContentExplorer', { + treeDataProvider: treeProvider, + showCollapseAll: true, + }); + + // Show active filter in tree view description + treeProvider.onDidChangeTreeData(() => { + const filter = treeProvider.getFilter(); + treeView.description = filter ? `filter: ${filter}` : undefined; + }); + + const commandDisposables = registerContentCommands(context, configProvider, treeProvider, fsProvider); + + context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); +} diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 16803b98..1854799b 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -18,6 +18,7 @@ const execAsync = promisify(exec); import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import {registerContentTree} from './content-tree/index.js'; import {registerWebDavTree} from './webdav-tree/index.js'; /** @@ -1164,6 +1165,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann ); registerWebDavTree(context); + registerContentTree(context); context.subscriptions.push( disposable, From ed309b1c4b82a9f25c427a2042a8f7d8a353c738 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 20 Feb 2026 21:02:49 -0500 Subject: [PATCH 05/16] Add content library explorer to VS Code extension - Native tree view for browsing B2C Commerce content libraries (pages, content assets, components, static assets) in the sidebar - FileSystemProvider (b2c-content: scheme) for viewing/editing content XML with round-trip import via site archive jobs - Export commands: Export, Export without Assets, Export Assets Only - Import site archive from command palette or explorer context menu (B2C-DX submenu) - Filter/search within library tree with toggle in title bar - Click static assets to preview images via WebDAV filesystem - Show job log in editor on import failure - Sort content-link children by position element instead of XML document order --- .../src/operations/content/library.ts | 8 +++- .../test/operations/content/fixtures.ts | 44 +++++++++++++++++++ .../test/operations/content/library.test.ts | 12 +++++ packages/b2c-vs-extension/package.json | 16 ++++++- .../src/content-tree/content-commands.ts | 20 +++++++-- .../src/content-tree/content-fs-provider.ts | 37 +++++++++++----- .../src/content-tree/content-tree-provider.ts | 11 ++++- 7 files changed, 129 insertions(+), 19 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 31704334..9d1170b3 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/library.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/library.ts @@ -118,10 +118,14 @@ function processContent( } } - // Recurse into content-links + // Recurse into content-links (sorted by position) const contentLinks = content['content-links'] as Array> | undefined; if (contentLinks?.[0]?.['content-link']) { - const links = contentLinks[0]['content-link'] as Array>; + const links = (contentLinks[0]['content-link'] as Array>).slice().sort((a, b) => { + const posA = parseFloat((a['position'] as string[] | undefined)?.[0] ?? 'Infinity'); + const posB = parseFloat((b['position'] as string[] | undefined)?.[0] ?? 'Infinity'); + return posA - posB; + }); for (const link of links) { const linkAttrs = link['$'] as Record; const linkId = linkAttrs['content-id']; diff --git a/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts b/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts index 1acc7307..f8c6c9d7 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts @@ -102,6 +102,50 @@ export const WILDCARD_ASSET_LIBRARY_XML = ` `; +/** + * Library XML with out-of-order content-link positions. + * + * The content-links are listed in XML order: comp-b, comp-d, comp-a, comp-c, + * but their positions specify: comp-a=1, comp-b=2, comp-c=3, comp-d=4. + */ +export const POSITION_LIBRARY_XML = ` + + + page.storePage + + + + 2.0 + + + 4.0 + + + 1.0 + + + 3.0 + + + + + component.alpha + + + + component.beta + + + + component.gamma + + + + component.delta + + +`; + /** * Library XML with a missing content-link target. */ diff --git a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts index bb57f1af..634fe2c2 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts @@ -11,6 +11,7 @@ import { MINIMAL_LIBRARY_XML, WILDCARD_ASSET_LIBRARY_XML, MISSING_LINK_LIBRARY_XML, + POSITION_LIBRARY_XML, } from './fixtures.js'; describe('operations/content/library', () => { @@ -426,6 +427,17 @@ describe('operations/content/library', () => { }); }); + describe('content-link position sorting', () => { + it('should sort children by position rather than XML document order', async () => { + const library = await Library.parse(POSITION_LIBRARY_XML); + const page = library.tree.children.find((n) => n.id === 'ordered-page'); + expect(page).to.exist; + + const childIds = page!.children.map((n) => n.id); + expect(childIds).to.deep.equal(['comp-a', 'comp-b', 'comp-c', 'comp-d']); + }); + }); + describe('missing content-link', () => { it('should parse without error when content-link target is missing', async () => { const library = await Library.parse(MISSING_LINK_LIBRARY_XML); diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 35211ef0..02851570 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -31,6 +31,12 @@ ], "main": "./dist/extension.js", "contributes": { + "submenus": [ + { + "id": "b2c-dx.submenu", + "label": "B2C-DX" + } + ], "viewsContainers": { "activitybar": [ { @@ -195,7 +201,7 @@ }, { "command": "b2c-dx.content.import", - "title": "B2C-DX: Import Site Archive", + "title": "Import Site Archive", "icon": "$(cloud-upload)", "category": "B2C DX" } @@ -292,10 +298,16 @@ "group": "navigation" }, { - "command": "b2c-dx.content.import", + "submenu": "b2c-dx.submenu", "when": "explorerResourceIsFolder", "group": "7_modification@9" } + ], + "b2c-dx.submenu": [ + { + "command": "b2c-dx.content.import", + "when": "explorerResourceIsFolder" + } ] } }, diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index c68a48bd..f507ca1f 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -4,14 +4,29 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import {siteArchiveImport} from '@salesforce/b2c-tooling-sdk'; +import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk'; import {exportContent} from '@salesforce/b2c-tooling-sdk/operations/content'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import * as path from 'path'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; +async function showJobError(err: unknown, instance: B2CInstance, label: string): Promise { + if (err instanceof JobExecutionError && err.execution.is_log_file_existing) { + try { + const log = await getJobLog(instance, err.execution); + const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'}); + await vscode.window.showTextDocument(doc); + } catch { + // Fall through to generic error + } + } + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`${label}: ${message}`); +} + export function registerContentCommands( _context: vscode.ExtensionContext, configProvider: ContentConfigProvider, @@ -192,8 +207,7 @@ export function registerContentCommands( }, ); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`Import failed: ${message}`); + await showJobError(err, instance, 'Import failed'); return; } diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts index b5730f6f..4a9273e6 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -5,7 +5,7 @@ */ import type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content'; -import {siteArchiveImport} from '@salesforce/b2c-tooling-sdk'; +import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk'; import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import * as vscode from 'vscode'; @@ -145,16 +145,31 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider { const xmlContent = Buffer.from(content).toString('utf-8'); const archivePath = isSiteLibrary ? `sites/${libraryId}/library/library.xml` : `libraries/${libraryId}/library.xml`; - await vscode.window.withProgress( - {location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`}, - async () => { - const archiveName = `content-update-${Date.now()}`; - const zip = new JSZip(); - zip.file(archivePath, xmlContent); - const buffer = await zip.generateAsync({type: 'nodebuffer'}); - await siteArchiveImport(instance, buffer, {archiveName}); - }, - ); + try { + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`}, + async () => { + const archiveName = `content-update-${Date.now()}`; + const zip = new JSZip(); + zip.file(archivePath, xmlContent); + const buffer = await zip.generateAsync({type: 'nodebuffer'}); + await siteArchiveImport(instance, buffer, {archiveName}); + }, + ); + } catch (err) { + // Show job log in editor on failure + if (err instanceof JobExecutionError && err.execution.is_log_file_existing) { + try { + const log = await getJobLog(instance, err.execution); + const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'}); + await vscode.window.showTextDocument(doc); + } catch { + // Fall through to generic error + } + } + const message = err instanceof Error ? err.message : String(err); + throw vscode.FileSystemError.Unavailable(`Import failed: ${message}`); + } // Invalidate cache since the instance was updated this.configProvider.invalidateLibrary(libraryId); diff --git a/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts index 42c1a9aa..17712925 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts @@ -9,6 +9,7 @@ import {fetchContentLibrary} from '@salesforce/b2c-tooling-sdk/operations/conten import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import {contentItemUri} from './content-fs-provider.js'; +import {webdavPathToUri} from '../webdav-tree/webdav-fs-provider.js'; type ContentNodeType = 'library' | 'page' | 'content' | 'component' | 'static'; @@ -72,7 +73,15 @@ export class ContentTreeItem extends vscode.TreeItem { } // Click command for openable items - if (nodeType !== 'library' && nodeType !== 'static') { + if (nodeType === 'static') { + const cleanPath = contentId.startsWith('/') ? contentId.slice(1) : contentId; + const webdavPath = `Libraries/${libraryId}/default/${cleanPath}`; + this.command = { + command: 'vscode.open', + title: 'Open Static Asset', + arguments: [webdavPathToUri(webdavPath)], + }; + } else if (nodeType !== 'library') { const uri = contentItemUri(libraryId, isSiteLibrary, contentId); this.command = { command: 'vscode.open', From 94f508d5d17c869a2019f6096b7b9235c84926a6 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 21 Feb 2026 11:01:51 -0500 Subject: [PATCH 06/16] Add VS Code extension CI tests and VSIX release publishing - Create separate CI workflow for extension tests using xvfb-run (path-filtered to packages/b2c-vs-extension/**) - Add tsconfig.test.json and pretest script to compile test files - Fix self-referencing step condition bug in ci.yml - Add typecheck to main CI for the extension (catches SDK breakage) - Add VSIX build, git tagging, and release upload to publish workflow --- .github/workflows/ci-vs-extension.yml | 64 ++++++++++++++++++++ .github/workflows/ci.yml | 7 +-- .github/workflows/publish.yml | 54 +++++++++++++++++ packages/b2c-vs-extension/package.json | 1 + packages/b2c-vs-extension/tsconfig.test.json | 15 +++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci-vs-extension.yml create mode 100644 packages/b2c-vs-extension/tsconfig.test.json diff --git a/.github/workflows/ci-vs-extension.yml b/.github/workflows/ci-vs-extension.yml new file mode 100644 index 00000000..a54292b9 --- /dev/null +++ b/.github/workflows/ci-vs-extension.yml @@ -0,0 +1,64 @@ +name: VS Extension Tests + +on: + push: + branches: + - main + paths: + - 'packages/b2c-vs-extension/**' + pull_request: + branches: + - main + paths: + - 'packages/b2c-vs-extension/**' + +permissions: + contents: read + +env: + SFCC_DISABLE_TELEMETRY: ${{ vars.SFCC_DISABLE_TELEMETRY }} + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm -r run build + + - name: Run VS Extension tests + working-directory: packages/b2c-vs-extension + run: xvfb-run -a pnpm run test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4a68785..8acedea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,12 +75,11 @@ jobs: working-directory: packages/b2c-cli run: pnpm run pretest && pnpm run test:ci && pnpm run lint - - name: Run VS Extension lint + - name: Run VS Extension checks id: vs-extension-test - if: always() && steps.vs-extension-test.conclusion != 'cancelled' + if: always() && steps.cli-test.conclusion != 'cancelled' working-directory: packages/b2c-vs-extension - # Testing not currently supported on CI - run: pnpm run lint + run: pnpm run typecheck:agent && pnpm run lint - name: Test Report uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b1d3fb5..4c446bff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -81,6 +81,17 @@ jobs: check_package "@salesforce/b2c-cli" "packages/b2c-cli" "cli" check_package "@salesforce/b2c-dx-mcp" "packages/b2c-dx-mcp" "mcp" + # VS Code extension β€” compare against git tags (not npm) + LOCAL_VSX_VERSION=$(node -p "require('./packages/b2c-vs-extension/package.json').version") + LAST_VSX_TAG=$(git tag -l "b2c-vs-extension@*" --sort=-v:refname | head -1 | sed 's/b2c-vs-extension@//') + echo "b2c-vs-extension: local=${LOCAL_VSX_VERSION} tag=${LAST_VSX_TAG:-none}" + if [ "$LOCAL_VSX_VERSION" != "$LAST_VSX_TAG" ]; then + echo "publish_vsx=true" >> $GITHUB_OUTPUT + echo "version_vsx=${LOCAL_VSX_VERSION}" >> $GITHUB_OUTPUT + else + echo "publish_vsx=false" >> $GITHUB_OUTPUT + fi + - name: Create snapshot versions if: steps.release-type.outputs.type == 'nightly' run: | @@ -114,6 +125,11 @@ jobs: if: steps.release-type.outputs.type == 'nightly' || steps.packages.outputs.publish_mcp == 'true' run: pnpm --filter @salesforce/b2c-dx-mcp publish --provenance --no-git-checks --tag ${{ steps.release-type.outputs.tag }} + - name: Package VS Code extension + if: steps.release-type.outputs.type == 'stable' && steps.packages.outputs.publish_vsx == 'true' + working-directory: packages/b2c-vs-extension + run: pnpm run package + - name: Create git tags if: steps.release-type.outputs.type == 'stable' run: | @@ -140,6 +156,12 @@ jobs: TAGS_CREATED="$TAGS_CREATED $TAG" fi + if [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then + TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" + git tag "$TAG" + TAGS_CREATED="$TAGS_CREATED $TAG" + fi + if [ -n "$TAGS_CREATED" ]; then git push origin $TAGS_CREATED echo "Created tags:$TAGS_CREATED" @@ -180,6 +202,13 @@ jobs: extract_latest packages/b2c-tooling-sdk/CHANGELOG.md echo "" fi + + if [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then + echo "## b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" + echo "" + extract_latest packages/b2c-vs-extension/CHANGELOG.md + echo "" + fi } > /tmp/release-notes.md - name: Create GitHub Release @@ -192,6 +221,8 @@ jobs: RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}" elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}" + elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then + RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" else echo "No packages published, skipping release" exit 0 @@ -223,6 +254,8 @@ jobs: RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}" elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}" + elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then + RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" else echo "No release to upload to" exit 0 @@ -231,3 +264,24 @@ jobs: gh release upload "$RELEASE_TAG" b2c-skills.zip b2c-cli-skills.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload VS Code extension to release + if: steps.release-type.outputs.type == 'stable' && steps.packages.outputs.publish_vsx == 'true' + run: | + # Determine the release tag (same logic as Create GitHub Release) + if [[ "${{ steps.packages.outputs.publish_cli }}" == "true" ]]; then + RELEASE_TAG="@salesforce/b2c-cli@${{ steps.packages.outputs.version_cli }}" + elif [[ "${{ steps.packages.outputs.publish_sdk }}" == "true" ]]; then + RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}" + elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then + RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}" + elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then + RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}" + else + echo "No release to upload to" + exit 0 + fi + + gh release upload "$RELEASE_TAG" packages/b2c-vs-extension/*.vsix + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index a3f140ab..3b97e8a0 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -72,6 +72,7 @@ "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", + "pretest": "tsc -p tsconfig.test.json", "test": "vscode-test", "posttest": "pnpm run lint", "analyze": "ANALYZE_BUNDLE=1 node scripts/esbuild-bundle.mjs" diff --git a/packages/b2c-vs-extension/tsconfig.test.json b/packages/b2c-vs-extension/tsconfig.test.json new file mode 100644 index 00000000..8e13b855 --- /dev/null +++ b/packages/b2c-vs-extension/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out/test", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src/test", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/test"] +} From a71b4685bc547da382652e6b88bec9a79959ce89 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 21 Feb 2026 21:03:02 -0500 Subject: [PATCH 07/16] wip --- packages/b2c-tooling-sdk/package.json | 11 + packages/b2c-tooling-sdk/src/cli/config.ts | 13 - .../b2c-tooling-sdk/src/config/resolver.ts | 12 + .../b2c-tooling-sdk/src/plugins/discovery.ts | 156 ++++++++++ packages/b2c-tooling-sdk/src/plugins/index.ts | 38 +++ .../b2c-tooling-sdk/src/plugins/loader.ts | 93 ++++++ .../b2c-tooling-sdk/src/plugins/manager.ts | 196 +++++++++++++ .../test/plugins/discovery.test.ts | 272 +++++++++++++++++ .../test/plugins/loader.test.ts | 158 ++++++++++ .../test/plugins/manager.test.ts | 274 ++++++++++++++++++ packages/b2c-vs-extension/src/extension.ts | 54 ++-- packages/b2c-vs-extension/src/plugins.ts | 51 ++++ .../src/webdav-tree/webdav-config.ts | 7 +- 13 files changed, 1301 insertions(+), 34 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/plugins/discovery.ts create mode 100644 packages/b2c-tooling-sdk/src/plugins/index.ts create mode 100644 packages/b2c-tooling-sdk/src/plugins/loader.ts create mode 100644 packages/b2c-tooling-sdk/src/plugins/manager.ts create mode 100644 packages/b2c-tooling-sdk/test/plugins/discovery.test.ts create mode 100644 packages/b2c-tooling-sdk/test/plugins/loader.test.ts create mode 100644 packages/b2c-tooling-sdk/test/plugins/manager.test.ts create mode 100644 packages/b2c-vs-extension/src/plugins.ts diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 9de52cdc..ba7b3fdb 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -266,6 +266,17 @@ "default": "./dist/cjs/scaffold/index.js" } }, + "./plugins": { + "development": "./src/plugins/index.ts", + "import": { + "types": "./dist/esm/plugins/index.d.ts", + "default": "./dist/esm/plugins/index.js" + }, + "require": { + "types": "./dist/cjs/plugins/index.d.ts", + "default": "./dist/cjs/plugins/index.js" + } + }, "./test-utils": { "development": "./src/test-utils/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 6086e814..e09ab293 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -255,19 +255,6 @@ export function loadConfig( sourcesAfter: pluginSources.after, }); - // Log source summary - for (const source of resolved.sources) { - logger.trace( - { - source: source.name, - location: source.location, - fields: source.fields, - fieldsIgnored: source.fieldsIgnored, - }, - `[${source.name}] Contributed fields`, - ); - } - // Log warnings (at warn level so users can see configuration issues) for (const warning of resolved.warnings) { logger.warn({warning}, `[Config] ${warning.message}`); diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index 07f33fa5..51314da4 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -13,6 +13,7 @@ */ import type {AuthCredentials} from '../auth/types.js'; import type {B2CInstance} from '../instance/index.js'; +import {getLogger} from '../logging/logger.js'; import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js'; import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js'; import type { @@ -221,6 +222,17 @@ export class ConfigResolver { fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined, }); + const logger = getLogger(); + logger.trace( + { + source: source.name, + location, + fields, + fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined, + }, + `[${source.name}] Contributed fields`, + ); + // Enrich options with accumulated config values for subsequent sources. // Only set if not already provided via CLI options. if (!enrichedOptions.accountManagerHost && baseConfig.accountManagerHost) { diff --git a/packages/b2c-tooling-sdk/src/plugins/discovery.ts b/packages/b2c-tooling-sdk/src/plugins/discovery.ts new file mode 100644 index 00000000..94b3ec02 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/plugins/discovery.ts @@ -0,0 +1,156 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type {Logger} from '../logging/types.js'; + +/** + * Hook names that the plugin system supports. + */ +const SUPPORTED_HOOKS = ['b2c:config-sources', 'b2c:http-middleware', 'b2c:auth-middleware'] as const; + +export type SupportedHookName = (typeof SUPPORTED_HOOKS)[number]; + +/** + * A discovered plugin with its hook file paths. + */ +export interface DiscoveredPlugin { + /** Plugin package name */ + name: string; + /** Absolute path to the plugin's package directory */ + packageDir: string; + /** Map of hook name to relative file path(s) within the plugin package */ + hooks: Partial>; +} + +/** + * Options for plugin discovery. + */ +export interface PluginDiscoveryOptions { + /** Override the oclif data directory (for testing) */ + dataDir?: string; + /** Override the dirname used to resolve the data directory (default: 'b2c') */ + dirname?: string; + /** Logger for warnings */ + logger?: Logger; +} + +/** + * Resolves the oclif data directory for the CLI. + * + * Cross-platform: uses `$XDG_DATA_HOME/` or `~/.local/share/` + * on POSIX; `$LOCALAPPDATA\` on Windows. + */ +export function resolveOclifDataDir(dirname = 'b2c'): string { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'); + return path.join(localAppData, dirname); + } + + const xdgDataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'); + return path.join(xdgDataHome, dirname); +} + +/** + * Reads a JSON file, returning undefined on any error. + */ +function readJsonSafe(filePath: string): unknown { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content); + } catch { + return undefined; + } +} + +/** + * Normalizes a hook value (string or string[]) to a string array. + */ +function normalizeHookPaths(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string'); + return []; +} + +/** + * Extracts the plugin name from an oclif plugins entry. + * + * Oclif stores user-installed plugins as objects: `{name, type, url}`, + * while linked/dev plugins may appear as plain strings. + */ +function resolvePluginName(entry: unknown): string | undefined { + if (typeof entry === 'string') return entry; + if (typeof entry === 'object' && entry !== null && 'name' in entry) { + const name = (entry as {name: unknown}).name; + if (typeof name === 'string') return name; + } + return undefined; +} + +/** + * Discovers installed b2c-cli plugins by reading the oclif data directory. + * + * Reads `/package.json` -> `oclif.plugins` array -> each plugin's + * `package.json` -> `oclif.hooks` -> returns `DiscoveredPlugin[]`. + * + * Only hooks matching `b2c:config-sources`, `b2c:http-middleware`, and + * `b2c:auth-middleware` are included. + */ +export function discoverPlugins(options: PluginDiscoveryOptions = {}): DiscoveredPlugin[] { + const {logger} = options; + const dataDir = options.dataDir ?? resolveOclifDataDir(options.dirname); + const plugins: DiscoveredPlugin[] = []; + + // Read the root package.json in the data directory + const rootPkgPath = path.join(dataDir, 'package.json'); + const rootPkg = readJsonSafe(rootPkgPath) as {oclif?: {plugins?: unknown[]}} | undefined; + if (!rootPkg?.oclif?.plugins?.length) { + return plugins; + } + + const nodeModulesDir = path.join(dataDir, 'node_modules'); + + for (const pluginEntry of rootPkg.oclif.plugins) { + const pluginName = resolvePluginName(pluginEntry); + if (!pluginName) continue; + + try { + const pluginDir = path.join(nodeModulesDir, ...pluginName.split('/')); + const pluginPkgPath = path.join(pluginDir, 'package.json'); + const pluginPkg = readJsonSafe(pluginPkgPath) as {oclif?: {hooks?: Record}} | undefined; + + if (!pluginPkg?.oclif?.hooks) continue; + + const hookEntries = pluginPkg.oclif.hooks; + const discoveredHooks: Partial> = {}; + let hasHooks = false; + + for (const hookName of SUPPORTED_HOOKS) { + if (hookName in hookEntries) { + const paths = normalizeHookPaths(hookEntries[hookName]); + if (paths.length > 0) { + discoveredHooks[hookName] = paths; + hasHooks = true; + } + } + } + + if (hasHooks) { + plugins.push({ + name: pluginName, + packageDir: pluginDir, + hooks: discoveredHooks, + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger?.warn(`Failed to discover plugin ${pluginName}: ${message}`); + } + } + + return plugins; +} diff --git a/packages/b2c-tooling-sdk/src/plugins/index.ts b/packages/b2c-tooling-sdk/src/plugins/index.ts new file mode 100644 index 00000000..5f68d07f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/plugins/index.ts @@ -0,0 +1,38 @@ +/* + * 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 + */ +/** + * Plugin discovery and loading for non-oclif consumers. + * + * This module enables VS Code extensions, MCP servers, and other non-CLI consumers + * to load b2c-cli plugins installed via `b2c plugins:install`. It discovers plugins + * from the oclif data directory and invokes their hooks without requiring `@oclif/core`. + * + * ## Quick Start + * + * ```typescript + * import { B2CPluginManager } from '@salesforce/b2c-tooling-sdk/plugins'; + * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + * + * const manager = new B2CPluginManager(); + * await manager.initialize(); + * manager.applyMiddleware(); + * + * const { sourcesBefore, sourcesAfter } = manager.getConfigSources(); + * const config = resolveConfig({}, { sourcesBefore, sourcesAfter }); + * ``` + * + * @module plugins + */ + +export {B2CPluginManager, type PluginHookOptions} from './manager.js'; +export { + discoverPlugins, + resolveOclifDataDir, + type DiscoveredPlugin, + type PluginDiscoveryOptions, + type SupportedHookName, +} from './discovery.js'; +export {createHookContext, invokeHook, type HookContext, type HookContextOptions} from './loader.js'; diff --git a/packages/b2c-tooling-sdk/src/plugins/loader.ts b/packages/b2c-tooling-sdk/src/plugins/loader.ts new file mode 100644 index 00000000..406eac27 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/plugins/loader.ts @@ -0,0 +1,93 @@ +/* + * 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 type {Logger} from '../logging/types.js'; + +/** + * Minimal hook context that shims the `this` context oclif provides to hooks. + * + * Provides `debug()`, `log()`, `warn()`, `error()`, and a stub `config` object + * so that existing hook implementations work without `@oclif/core`. + */ +export interface HookContext { + debug(...args: unknown[]): void; + log(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + config: Record; +} + +export interface HookContextOptions { + /** Logger to route debug/log/warn/error through */ + logger?: Logger; + /** Extra properties to include on the stub config object */ + config?: Record; +} + +/** + * Creates a minimal hook context matching what oclif provides to hooks. + */ +export function createHookContext(options: HookContextOptions = {}): HookContext { + const {logger} = options; + + return { + debug(...args: unknown[]) { + logger?.debug(args.map(String).join(' ')); + }, + log(...args: unknown[]) { + logger?.info(args.map(String).join(' ')); + }, + warn(...args: unknown[]) { + logger?.warn(args.map(String).join(' ')); + }, + error(...args: unknown[]) { + logger?.error(args.map(String).join(' ')); + }, + config: options.config ?? {}, + }; +} + +/** + * Dynamic import that survives esbuild CJS bundling. + * + * esbuild transforms `import()` to `require()` in CJS output, which cannot + * load ESM plugins. Using `new Function` preserves the native dynamic import. + */ +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string, +) => Promise>; + +/** + * Dynamically imports a hook file and invokes its default export. + * + * @param hookFilePath - Absolute path to the hook JS file + * @param context - Hook context (`this` inside the hook) + * @param hookOptions - Options passed as the first argument to the hook function + * @param logger - Optional logger for warnings on failure + * @returns The hook function's return value, or `undefined` on error + */ +export async function invokeHook( + hookFilePath: string, + context: HookContext, + hookOptions: Record, + logger?: Logger, +): Promise { + try { + const mod = await dynamicImport(hookFilePath); + const hookFn = (mod.default ?? mod) as (...args: unknown[]) => Promise; + + if (typeof hookFn !== 'function') { + logger?.warn(`Hook file ${hookFilePath} does not export a function`); + return undefined; + } + + return await hookFn.call(context, hookOptions); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger?.warn(`Failed to invoke hook ${hookFilePath}: ${message}`); + return undefined; + } +} diff --git a/packages/b2c-tooling-sdk/src/plugins/manager.ts b/packages/b2c-tooling-sdk/src/plugins/manager.ts new file mode 100644 index 00000000..f0ec8ab4 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/plugins/manager.ts @@ -0,0 +1,196 @@ +/* + * 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 * as path from 'node:path'; +import type {ConfigSource} from '../config/types.js'; +import type {HttpMiddlewareProvider} from '../clients/middleware-registry.js'; +import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; +import type {AuthMiddlewareProvider} from '../auth/middleware.js'; +import {globalAuthMiddlewareRegistry} from '../auth/middleware.js'; +import type {Logger} from '../logging/types.js'; +import type {ConfigSourcesHookResult} from '../cli/hooks.js'; +import type {HttpMiddlewareHookResult} from '../cli/hooks.js'; +import type {AuthMiddlewareHookResult} from '../cli/hooks.js'; +import {discoverPlugins, type PluginDiscoveryOptions} from './discovery.js'; +import {createHookContext, invokeHook} from './loader.js'; + +/** + * Options passed to plugin hooks during initialization. + * Mirrors the shape of the CLI hook options but without oclif-specific fields. + */ +export interface PluginHookOptions { + /** Named instance (if known) */ + instance?: string; + /** Explicit config file path (if known) */ + configPath?: string; + /** CLI flags or equivalent options */ + flags?: Record; +} + +/** + * Orchestrates plugin discovery, loading, and hook invocation for non-oclif consumers + * (VS Code extension, MCP server, etc.). + * + * Replicates the hook collection logic from `base-command.ts:412-530` without + * depending on `@oclif/core`. + */ +export class B2CPluginManager { + private _initialized = false; + private _pluginNames: string[] = []; + private _sourcesBefore: ConfigSource[] = []; + private _sourcesAfter: ConfigSource[] = []; + private _httpMiddleware: HttpMiddlewareProvider[] = []; + private _authMiddleware: AuthMiddlewareProvider[] = []; + private readonly logger?: Logger; + private readonly discoveryOptions?: PluginDiscoveryOptions; + + constructor(options?: {logger?: Logger; discoveryOptions?: PluginDiscoveryOptions}) { + this.logger = options?.logger; + this.discoveryOptions = options?.discoveryOptions; + } + + /** + * Discovers installed plugins and invokes their hooks. + * + * Collects config sources, HTTP middleware, and auth middleware providers. + */ + async initialize(hookOptions?: PluginHookOptions): Promise { + if (this._initialized) return; + this._initialized = true; + + const plugins = discoverPlugins({ + ...this.discoveryOptions, + logger: this.logger, + }); + + if (plugins.length === 0) return; + + this._pluginNames = plugins.map((p) => p.name); + this.logger?.debug(`Discovered ${plugins.length} plugin(s): ${this._pluginNames.join(', ')}`); + + const context = createHookContext({logger: this.logger}); + + for (const plugin of plugins) { + // Config sources hook + if (plugin.hooks['b2c:config-sources']) { + for (const hookPath of plugin.hooks['b2c:config-sources']) { + const absPath = path.resolve(plugin.packageDir, hookPath); + const options = { + instance: hookOptions?.instance, + configPath: hookOptions?.configPath, + flags: hookOptions?.flags ?? {}, + resolveOptions: { + instance: hookOptions?.instance, + configPath: hookOptions?.configPath, + }, + }; + const result = await invokeHook(absPath, context, options, this.logger); + if (result?.sources?.length) { + this.collectConfigSources(result, plugin.name); + } + } + } + + // HTTP middleware hook + if (plugin.hooks['b2c:http-middleware']) { + for (const hookPath of plugin.hooks['b2c:http-middleware']) { + const absPath = path.resolve(plugin.packageDir, hookPath); + const options = {flags: hookOptions?.flags ?? {}}; + const result = await invokeHook(absPath, context, options, this.logger); + if (result?.providers?.length) { + this._httpMiddleware.push(...result.providers); + for (const provider of result.providers) { + this.logger?.debug(`Collected HTTP middleware provider: ${provider.name} (from ${plugin.name})`); + } + } + } + } + + // Auth middleware hook + if (plugin.hooks['b2c:auth-middleware']) { + for (const hookPath of plugin.hooks['b2c:auth-middleware']) { + const absPath = path.resolve(plugin.packageDir, hookPath); + const options = {flags: hookOptions?.flags ?? {}}; + const result = await invokeHook(absPath, context, options, this.logger); + if (result?.providers?.length) { + this._authMiddleware.push(...result.providers); + for (const provider of result.providers) { + this.logger?.debug(`Collected auth middleware provider: ${provider.name} (from ${plugin.name})`); + } + } + } + } + } + } + + /** + * Collects config sources from a hook result, applying priority mapping + * matching `base-command.ts:434-457`. + */ + private collectConfigSources(result: ConfigSourcesHookResult, pluginName: string): void { + // Priority mapping: 'before' -> -1, 'after' -> 10, number -> as-is, undefined -> 10 + const numericPriority = + result.priority === 'before' + ? -1 + : result.priority === 'after' + ? 10 + : typeof result.priority === 'number' + ? result.priority + : 10; // default 'after' + + // Apply priority to sources that don't already have one set + for (const source of result.sources) { + if (source.priority === undefined) { + (source as {priority?: number}).priority = numericPriority; + } + } + + // Split into before/after arrays (same as base-command.ts:453-457) + if (numericPriority < 0) { + this._sourcesBefore.push(...result.sources); + } else { + this._sourcesAfter.push(...result.sources); + } + + this.logger?.debug( + `Collected ${result.sources.length} config source(s) from ${pluginName} (priority: ${result.priority ?? 'default'})`, + ); + } + + /** + * Returns the collected config sources split by priority. + */ + getConfigSources(): {sourcesBefore: ConfigSource[]; sourcesAfter: ConfigSource[]} { + return { + sourcesBefore: this._sourcesBefore, + sourcesAfter: this._sourcesAfter, + }; + } + + /** + * Registers collected middleware providers with the global registries. + */ + applyMiddleware(): void { + for (const provider of this._httpMiddleware) { + globalMiddlewareRegistry.register(provider); + this.logger?.debug(`Registered HTTP middleware provider: ${provider.name}`); + } + + for (const provider of this._authMiddleware) { + globalAuthMiddlewareRegistry.register(provider); + this.logger?.debug(`Registered auth middleware provider: ${provider.name}`); + } + } + + /** Names of all discovered plugins */ + get pluginNames(): string[] { + return this._pluginNames; + } + + /** Whether initialize() has been called */ + get initialized(): boolean { + return this._initialized; + } +} diff --git a/packages/b2c-tooling-sdk/test/plugins/discovery.test.ts b/packages/b2c-tooling-sdk/test/plugins/discovery.test.ts new file mode 100644 index 00000000..0ee46804 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/plugins/discovery.test.ts @@ -0,0 +1,272 @@ +/* + * 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 {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {discoverPlugins, resolveOclifDataDir} from '@salesforce/b2c-tooling-sdk/plugins'; + +describe('plugins/discovery', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-plugin-discovery-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + describe('resolveOclifDataDir', () => { + it('returns a path containing the dirname', () => { + const result = resolveOclifDataDir('b2c'); + expect(result).to.be.a('string'); + expect(result).to.include('b2c'); + }); + + it('uses custom dirname', () => { + const result = resolveOclifDataDir('my-cli'); + expect(result).to.include('my-cli'); + }); + }); + + describe('discoverPlugins', () => { + it('returns empty array when data dir does not exist', () => { + const result = discoverPlugins({dataDir: path.join(tempDir, 'nonexistent')}); + expect(result).to.deep.equal([]); + }); + + it('returns empty array when package.json has no oclif.plugins', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({name: 'root'})); + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.deep.equal([]); + }); + + it('returns empty array when package.json has empty plugins array', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({name: 'root', oclif: {plugins: []}})); + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.deep.equal([]); + }); + + it('discovers a plugin with b2c:config-sources hook', () => { + // Create root package.json + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['my-plugin']}})); + + // Create plugin package + const pluginDir = path.join(tempDir, 'node_modules', 'my-plugin'); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'my-plugin', + oclif: { + hooks: { + 'b2c:config-sources': './dist/hooks/config-sources.js', + }, + }, + }), + ); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('my-plugin'); + expect(result[0].packageDir).to.equal(pluginDir); + expect(result[0].hooks['b2c:config-sources']).to.deep.equal(['./dist/hooks/config-sources.js']); + }); + + it('discovers a plugin with multiple hooks', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['multi-plugin']}})); + + const pluginDir = path.join(tempDir, 'node_modules', 'multi-plugin'); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'multi-plugin', + oclif: { + hooks: { + 'b2c:config-sources': './hooks/config.js', + 'b2c:http-middleware': './hooks/http.js', + 'b2c:auth-middleware': ['./hooks/auth1.js', './hooks/auth2.js'], + }, + }, + }), + ); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(1); + expect(result[0].hooks['b2c:config-sources']).to.deep.equal(['./hooks/config.js']); + expect(result[0].hooks['b2c:http-middleware']).to.deep.equal(['./hooks/http.js']); + expect(result[0].hooks['b2c:auth-middleware']).to.deep.equal(['./hooks/auth1.js', './hooks/auth2.js']); + }); + + it('skips plugins without supported hooks', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['unrelated-plugin']}})); + + const pluginDir = path.join(tempDir, 'node_modules', 'unrelated-plugin'); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'unrelated-plugin', + oclif: { + hooks: { + init: './hooks/init.js', + }, + }, + }), + ); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.deep.equal([]); + }); + + it('skips plugins with missing package.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['missing-plugin']}})); + // Don't create the plugin directory + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.deep.equal([]); + }); + + it('skips plugins with corrupt package.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['bad-plugin']}})); + + const pluginDir = path.join(tempDir, 'node_modules', 'bad-plugin'); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync(path.join(pluginDir, 'package.json'), 'not valid json{{{'); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.deep.equal([]); + }); + + it('handles scoped package names', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: ['@myorg/b2c-keychain']}})); + + const pluginDir = path.join(tempDir, 'node_modules', '@myorg', 'b2c-keychain'); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: '@myorg/b2c-keychain', + oclif: { + hooks: { + 'b2c:config-sources': './dist/hooks/keychain.js', + }, + }, + }), + ); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('@myorg/b2c-keychain'); + }); + + it('discovers multiple plugins', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({oclif: {plugins: ['plugin-a', 'plugin-b']}}), + ); + + for (const name of ['plugin-a', 'plugin-b']) { + const dir = path.join(tempDir, 'node_modules', name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name, + oclif: {hooks: {'b2c:config-sources': './hooks/config.js'}}, + }), + ); + } + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(2); + expect(result.map((p) => p.name)).to.deep.equal(['plugin-a', 'plugin-b']); + }); + + it('handles oclif object-format plugin entries', () => { + // oclif stores user-installed plugins as objects: {name, type, url} + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ + oclif: { + plugins: [ + {name: 'obj-plugin', type: 'user', url: 'https://example.com'}, + {name: 'obj-plugin-2', type: 'user'}, + ], + }, + }), + ); + + for (const name of ['obj-plugin', 'obj-plugin-2']) { + const dir = path.join(tempDir, 'node_modules', name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name, + oclif: {hooks: {'b2c:config-sources': './hooks/config.js'}}, + }), + ); + } + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(2); + expect(result.map((p) => p.name)).to.deep.equal(['obj-plugin', 'obj-plugin-2']); + }); + + it('handles mixed string and object plugin entries', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ + oclif: { + plugins: ['string-plugin', {name: 'object-plugin', type: 'user'}], + }, + }), + ); + + for (const name of ['string-plugin', 'object-plugin']) { + const dir = path.join(tempDir, 'node_modules', name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name, + oclif: {hooks: {'b2c:http-middleware': './hooks/http.js'}}, + }), + ); + } + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(2); + expect(result.map((p) => p.name)).to.deep.equal(['string-plugin', 'object-plugin']); + }); + + it('skips entries with no extractable name', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ + oclif: {plugins: [42, null, {type: 'user'}, 'valid-plugin']}, + }), + ); + + const dir = path.join(tempDir, 'node_modules', 'valid-plugin'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: 'valid-plugin', + oclif: {hooks: {'b2c:config-sources': './hooks/config.js'}}, + }), + ); + + const result = discoverPlugins({dataDir: tempDir}); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('valid-plugin'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/plugins/loader.test.ts b/packages/b2c-tooling-sdk/test/plugins/loader.test.ts new file mode 100644 index 00000000..b2beb99e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/plugins/loader.test.ts @@ -0,0 +1,158 @@ +/* + * 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 {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {createHookContext, invokeHook} from '@salesforce/b2c-tooling-sdk/plugins'; + +describe('plugins/loader', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-plugin-loader-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + describe('createHookContext', () => { + it('creates context with all required methods', () => { + const ctx = createHookContext(); + expect(ctx).to.have.property('debug').that.is.a('function'); + expect(ctx).to.have.property('log').that.is.a('function'); + expect(ctx).to.have.property('warn').that.is.a('function'); + expect(ctx).to.have.property('error').that.is.a('function'); + expect(ctx).to.have.property('config').that.is.an('object'); + }); + + it('methods do not throw without a logger', () => { + const ctx = createHookContext(); + expect(() => ctx.debug('test')).to.not.throw(); + expect(() => ctx.log('test')).to.not.throw(); + expect(() => ctx.warn('test')).to.not.throw(); + expect(() => ctx.error('test')).to.not.throw(); + }); + + it('routes messages through provided logger', () => { + const messages: {level: string; msg: string}[] = []; + const logger = { + debug(msg: string) { + messages.push({level: 'debug', msg}); + }, + info(msg: string) { + messages.push({level: 'info', msg}); + }, + warn(msg: string) { + messages.push({level: 'warn', msg}); + }, + error(msg: string) { + messages.push({level: 'error', msg}); + }, + trace() {}, + fatal() {}, + child() { + return this; + }, + }; + + const ctx = createHookContext({logger: logger as never}); + ctx.debug('d'); + ctx.log('i'); + ctx.warn('w'); + ctx.error('e'); + + expect(messages).to.have.length(4); + expect(messages[0]).to.deep.equal({level: 'debug', msg: 'd'}); + expect(messages[1]).to.deep.equal({level: 'info', msg: 'i'}); + expect(messages[2]).to.deep.equal({level: 'warn', msg: 'w'}); + expect(messages[3]).to.deep.equal({level: 'error', msg: 'e'}); + }); + + it('includes custom config properties', () => { + const ctx = createHookContext({config: {root: '/tmp', name: 'test'}}); + expect(ctx.config).to.deep.equal({root: '/tmp', name: 'test'}); + }); + }); + + describe('invokeHook', () => { + it('invokes a hook file and returns the result', async () => { + const hookFile = path.join(tempDir, 'hook.mjs'); + fs.writeFileSync( + hookFile, + `export default async function(options) { + return { sources: [{ name: 'test-source', load: () => undefined }], priority: 'before' }; + }`, + ); + + const ctx = createHookContext(); + const result = await invokeHook<{sources: {name: string}[]; priority: string}>(hookFile, ctx, {}); + + expect(result).to.not.be.undefined; + expect(result!.sources).to.have.length(1); + expect(result!.sources[0].name).to.equal('test-source'); + expect(result!.priority).to.equal('before'); + }); + + it('passes options to the hook function', async () => { + const hookFile = path.join(tempDir, 'echo-hook.mjs'); + fs.writeFileSync( + hookFile, + `export default async function(options) { + return { received: options.instance }; + }`, + ); + + const ctx = createHookContext(); + const result = await invokeHook<{received: string}>(hookFile, ctx, {instance: 'staging'}); + + expect(result).to.not.be.undefined; + expect(result!.received).to.equal('staging'); + }); + + it('provides this context to the hook', async () => { + const hookFile = path.join(tempDir, 'ctx-hook.mjs'); + fs.writeFileSync( + hookFile, + `export default async function(options) { + return { hasDebug: typeof this.debug === 'function', hasConfig: typeof this.config === 'object' }; + }`, + ); + + const ctx = createHookContext(); + const result = await invokeHook<{hasDebug: boolean; hasConfig: boolean}>(hookFile, ctx, {}); + + expect(result).to.not.be.undefined; + expect(result!.hasDebug).to.be.true; + expect(result!.hasConfig).to.be.true; + }); + + it('returns undefined for non-existent hook file', async () => { + const ctx = createHookContext(); + const result = await invokeHook(path.join(tempDir, 'missing.mjs'), ctx, {}); + expect(result).to.be.undefined; + }); + + it('returns undefined when hook file does not export a function', async () => { + const hookFile = path.join(tempDir, 'not-a-fn.mjs'); + fs.writeFileSync(hookFile, `export default "not a function";`); + + const ctx = createHookContext(); + const result = await invokeHook(hookFile, ctx, {}); + expect(result).to.be.undefined; + }); + + it('returns undefined when hook throws an error', async () => { + const hookFile = path.join(tempDir, 'throwing-hook.mjs'); + fs.writeFileSync(hookFile, `export default async function() { throw new Error('hook error'); }`); + + const ctx = createHookContext(); + const result = await invokeHook(hookFile, ctx, {}); + expect(result).to.be.undefined; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/plugins/manager.test.ts b/packages/b2c-tooling-sdk/test/plugins/manager.test.ts new file mode 100644 index 00000000..0f6cc0ae --- /dev/null +++ b/packages/b2c-tooling-sdk/test/plugins/manager.test.ts @@ -0,0 +1,274 @@ +/* + * 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 {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {B2CPluginManager} from '@salesforce/b2c-tooling-sdk/plugins'; +import {globalMiddlewareRegistry} from '@salesforce/b2c-tooling-sdk/clients'; +import {globalAuthMiddlewareRegistry} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('plugins/manager', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-plugin-manager-')); + globalMiddlewareRegistry.clear(); + globalAuthMiddlewareRegistry.clear(); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + globalMiddlewareRegistry.clear(); + globalAuthMiddlewareRegistry.clear(); + }); + + it('initializes with no plugins when data dir is empty', async () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({oclif: {plugins: []}})); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + + expect(manager.initialized).to.be.true; + expect(manager.pluginNames).to.deep.equal([]); + const {sourcesBefore, sourcesAfter} = manager.getConfigSources(); + expect(sourcesBefore).to.deep.equal([]); + expect(sourcesAfter).to.deep.equal([]); + }); + + it('initializes with no plugins when data dir does not exist', async () => { + const manager = new B2CPluginManager({ + discoveryOptions: {dataDir: path.join(tempDir, 'nonexistent')}, + }); + await manager.initialize(); + + expect(manager.initialized).to.be.true; + expect(manager.pluginNames).to.deep.equal([]); + }); + + it('only initializes once', async () => { + const manager = new B2CPluginManager({ + discoveryOptions: {dataDir: path.join(tempDir, 'nonexistent')}, + }); + await manager.initialize(); + await manager.initialize(); // second call is a no-op + + expect(manager.initialized).to.be.true; + }); + + it('collects config sources from a plugin with priority "before"', async () => { + setupPlugin(tempDir, 'keychain-plugin', { + 'b2c:config-sources': './hooks/config.mjs', + }); + + // Write hook that returns sources with priority 'before' + const hooksDir = path.join(tempDir, 'node_modules', 'keychain-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'config.mjs'), + `export default async function(options) { + return { + sources: [{ name: 'keychain', priority: undefined, load() { return undefined; } }], + priority: 'before', + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + + expect(manager.pluginNames).to.deep.equal(['keychain-plugin']); + const {sourcesBefore, sourcesAfter} = manager.getConfigSources(); + expect(sourcesBefore).to.have.length(1); + expect(sourcesBefore[0].name).to.equal('keychain'); + expect(sourcesBefore[0].priority).to.equal(-1); + expect(sourcesAfter).to.deep.equal([]); + }); + + it('collects config sources with priority "after" (default)', async () => { + setupPlugin(tempDir, 'fallback-plugin', { + 'b2c:config-sources': './hooks/config.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'fallback-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'config.mjs'), + `export default async function(options) { + return { + sources: [{ name: 'fallback', load() { return undefined; } }], + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + + const {sourcesBefore, sourcesAfter} = manager.getConfigSources(); + expect(sourcesBefore).to.deep.equal([]); + expect(sourcesAfter).to.have.length(1); + expect(sourcesAfter[0].name).to.equal('fallback'); + expect(sourcesAfter[0].priority).to.equal(10); + }); + + it('collects config sources with numeric priority', async () => { + setupPlugin(tempDir, 'priority-plugin', { + 'b2c:config-sources': './hooks/config.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'priority-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'config.mjs'), + `export default async function(options) { + return { + sources: [{ name: 'custom-priority', load() { return undefined; } }], + priority: -5, + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + + const {sourcesBefore} = manager.getConfigSources(); + expect(sourcesBefore).to.have.length(1); + expect(sourcesBefore[0].priority).to.equal(-5); + }); + + it('does not override source priority if already set', async () => { + setupPlugin(tempDir, 'presorted-plugin', { + 'b2c:config-sources': './hooks/config.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'presorted-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'config.mjs'), + `export default async function(options) { + return { + sources: [{ name: 'pre-prioritized', priority: 42, load() { return undefined; } }], + priority: 'before', + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + + // Source had priority 42 already set, so hook-level 'before' (-1) should not override it + // But it goes into sourcesAfter because the hook priority ('before'/-1) determines the bucket + const {sourcesBefore} = manager.getConfigSources(); + expect(sourcesBefore).to.have.length(1); + expect(sourcesBefore[0].priority).to.equal(42); + }); + + it('collects and applies HTTP middleware', async () => { + setupPlugin(tempDir, 'middleware-plugin', { + 'b2c:http-middleware': './hooks/http.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'middleware-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'http.mjs'), + `export default async function(options) { + return { + providers: [{ + name: 'test-middleware', + getMiddleware() { return undefined; }, + }], + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + manager.applyMiddleware(); + + expect(globalMiddlewareRegistry.getProviderNames()).to.include('test-middleware'); + }); + + it('collects and applies auth middleware', async () => { + setupPlugin(tempDir, 'auth-plugin', { + 'b2c:auth-middleware': './hooks/auth.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'auth-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'auth.mjs'), + `export default async function(options) { + return { + providers: [{ + name: 'test-auth-middleware', + getMiddleware() { return undefined; }, + }], + }; + }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + await manager.initialize(); + manager.applyMiddleware(); + + expect(globalAuthMiddlewareRegistry.getProviderNames()).to.include('test-auth-middleware'); + }); + + it('handles hook invocation errors gracefully', async () => { + setupPlugin(tempDir, 'broken-plugin', { + 'b2c:config-sources': './hooks/broken.mjs', + }); + + const hooksDir = path.join(tempDir, 'node_modules', 'broken-plugin', 'hooks'); + fs.mkdirSync(hooksDir, {recursive: true}); + fs.writeFileSync( + path.join(hooksDir, 'broken.mjs'), + `export default async function() { throw new Error('hook crashed'); }`, + ); + + const manager = new B2CPluginManager({discoveryOptions: {dataDir: tempDir}}); + // Should not throw + await manager.initialize(); + + expect(manager.pluginNames).to.deep.equal(['broken-plugin']); + const {sourcesBefore, sourcesAfter} = manager.getConfigSources(); + expect(sourcesBefore).to.deep.equal([]); + expect(sourcesAfter).to.deep.equal([]); + }); +}); + +/** + * Helper to set up a mock plugin in the temp data directory. + */ +function setupPlugin(dataDir: string, pluginName: string, hooks: Record): void { + // Ensure root package.json lists this plugin + const rootPkgPath = path.join(dataDir, 'package.json'); + let rootPkg: {oclif: {plugins: string[]}} = {oclif: {plugins: []}}; + try { + rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf-8')); + } catch { + // first plugin + } + if (!rootPkg.oclif?.plugins) { + rootPkg.oclif = {plugins: []}; + } + if (!rootPkg.oclif.plugins.includes(pluginName)) { + rootPkg.oclif.plugins.push(pluginName); + } + fs.writeFileSync(rootPkgPath, JSON.stringify(rootPkg)); + + // Create plugin package.json + const pluginDir = path.join(dataDir, 'node_modules', pluginName); + fs.mkdirSync(pluginDir, {recursive: true}); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: pluginName, + oclif: {hooks}, + }), + ); +} diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 16803b98..29102ef0 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -5,7 +5,12 @@ */ import {createSlasClient, getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {createOdsClient, createScapiSchemasClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk/clients'; -import {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import { + resolveConfig, + type NormalizedConfig, + type ResolveConfigOptions, + type ResolvedB2CConfig, +} from '@salesforce/b2c-tooling-sdk/config'; import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging'; import {findAndDeployCartridges, getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {getPathKeys, type OpenApiSchemaInput} from '@salesforce/b2c-tooling-sdk/schemas'; @@ -18,8 +23,24 @@ const execAsync = promisify(exec); import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import {initializePlugins, getPluginConfigSources} from './plugins.js'; import {registerWebDavTree} from './webdav-tree/index.js'; +/** + * Resolves configuration with plugin sources automatically injected. + */ +function resolveConfigWithPlugins( + overrides: Partial = {}, + options: ResolveConfigOptions = {}, +): ResolvedB2CConfig { + const {sourcesBefore, sourcesAfter} = getPluginConfigSources(); + return resolveConfig(overrides, { + ...options, + sourcesBefore: [...sourcesBefore, ...(options.sourcesBefore ?? [])], + sourcesAfter: [...(options.sourcesAfter ?? []), ...sourcesAfter], + }); +} + /** * Recursively finds all files under dir whose names end with .json (metadata files). * Returns paths relative to dir. @@ -128,7 +149,7 @@ function renderTemplate( .replace(/\$\{regions\[0\]\.id\}/g, firstRegionId); } -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { const log = vscode.window.createOutputChannel('B2C DX'); try { @@ -151,7 +172,7 @@ export function activate(context: vscode.ExtensionContext) { } try { - return activateInner(context, log); + return await activateInner(context, log); } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; @@ -174,7 +195,12 @@ export function activate(context: vscode.ExtensionContext) { } } -function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChannel) { +async function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChannel) { + // Initialize b2c-cli plugins before registering commands/views. + // This ensures plugin config sources and middleware are available + // before the first resolveConfig() call. Failures are non-fatal. + await initializePlugins(); + const disposable = vscode.commands.registerCommand('b2c-dx.openUI', () => { vscode.window.showInformationMessage('B2C DX: Opening Page Designer Assistant.'); @@ -307,8 +333,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann let prefill: {tenantId: string; channelId: string; shortCode?: string} | undefined; try { const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const dwPath = findDwJson(workingDirectory); - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + const config = resolveConfigWithPlugins({}, {workingDirectory}); const hostname = config.values.hostname; const shortCode = config.values.shortCode; const firstPart = hostname && typeof hostname === 'string' ? (hostname.split('.')[0] ?? '') : ''; @@ -340,8 +365,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann }) => { const getConfig = () => { const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const dwPath = findDwJson(workingDirectory); - return dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + return resolveConfigWithPlugins({}, {workingDirectory}); }; if (msg.type === 'scapiFetchSchemas') { @@ -725,8 +749,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann return; } const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const dwPath = findDwJson(workingDirectory); - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + const config = resolveConfigWithPlugins({}, {workingDirectory}); const shortCode = config.values.shortCode; if (!shortCode) { vscode.window.showErrorMessage( @@ -840,8 +863,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann let defaultRealm = ''; try { const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const dwPath = findDwJson(workingDirectory); - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + const config = resolveConfigWithPlugins({}, {workingDirectory}); // First part of hostname, e.g. 'zyoc' from 'zyoc-003.unified.demandware.net' const hostname = config.values.hostname; const firstSegment = (hostname && typeof hostname === 'string' ? hostname : '').split('.')[0] ?? ''; @@ -853,8 +875,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann async function getOdsConfig() { const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const dwPath = findDwJson(workingDirectory); - return dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + return resolveConfigWithPlugins({}, {workingDirectory}); } async function fetchSandboxList(): Promise<{sandboxes: unknown[]; error?: string}> { @@ -1092,10 +1113,7 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann vscode.window.showErrorMessage(message); return; } - const dwPath = findDwJson(projectDirectory); - const config = dwPath - ? resolveConfig({}, {configPath: dwPath}) - : resolveConfig({}, {workingDirectory: projectDirectory}); + const config = resolveConfigWithPlugins({}, {workingDirectory: projectDirectory}); if (!config.hasB2CInstanceConfig()) { const message = 'B2C DX: No instance config for deploy. Configure SFCC_* env vars or dw.json in the project.'; diff --git a/packages/b2c-vs-extension/src/plugins.ts b/packages/b2c-vs-extension/src/plugins.ts new file mode 100644 index 00000000..d8a09801 --- /dev/null +++ b/packages/b2c-vs-extension/src/plugins.ts @@ -0,0 +1,51 @@ +/* + * 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 {B2CPluginManager} from '@salesforce/b2c-tooling-sdk/plugins'; +import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import type {ConfigSource} from '@salesforce/b2c-tooling-sdk/config'; + +let manager: B2CPluginManager | undefined; + +/** + * Initializes the b2c-cli plugin system. + * + * Discovers plugins installed via `b2c plugins:install`, invokes their hooks, + * and registers middleware with the global registries. All failures are + * non-fatal β€” the extension continues to work without plugin support. + */ +export async function initializePlugins(): Promise { + try { + const logger = getLogger(); + manager = new B2CPluginManager({logger}); + await manager.initialize(); + manager.applyMiddleware(); + + if (manager.pluginNames.length > 0) { + logger.info(`Loaded ${manager.pluginNames.length} plugin(s): ${manager.pluginNames.join(', ')}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + try { + getLogger().warn(`Plugin initialization failed: ${message}`); + } catch { + // Logger not available β€” silently ignore + } + manager = undefined; + } +} + +/** + * Returns config sources collected from plugins, split by priority. + * + * If plugins haven't been initialized yet (or initialization failed), + * returns empty arrays β€” callers gracefully degrade to no-plugin behavior. + */ +export function getPluginConfigSources(): {sourcesBefore: ConfigSource[]; sourcesAfter: ConfigSource[]} { + if (!manager?.initialized) { + return {sourcesBefore: [], sourcesAfter: []}; + } + return manager.getConfigSources(); +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts index 436fe051..f2af6ecd 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts @@ -3,10 +3,11 @@ * 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 {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import * as fs from 'fs'; import * as vscode from 'vscode'; +import {getPluginConfigSources} from '../plugins.js'; /** * Manages B2CInstance lifecycle for the WebDAV tree view. @@ -45,8 +46,8 @@ export class WebDavConfigProvider { if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { workingDirectory = ''; } - const dwPath = workingDirectory ? findDwJson(workingDirectory) : undefined; - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + const {sourcesBefore, sourcesAfter} = getPluginConfigSources(); + const config = resolveConfig({}, {sourcesBefore, sourcesAfter, workingDirectory}); if (!config.hasB2CInstanceConfig()) { this.configError = 'No B2C Commerce instance configured.'; From 1378386339589287e2665a050c0daf0ab1bc97a7 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 21 Feb 2026 21:44:40 -0500 Subject: [PATCH 08/16] 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) --- .../b2c-vs-extension/src/config-provider.ts | 161 ++++++++++++++++++ packages/b2c-vs-extension/src/extension.ts | 83 ++++----- .../b2c-vs-extension/src/webdav-tree/index.ts | 11 +- .../src/webdav-tree/webdav-commands.ts | 6 +- .../src/webdav-tree/webdav-config.ts | 66 ------- .../src/webdav-tree/webdav-fs-provider.ts | 4 +- .../src/webdav-tree/webdav-tree-provider.ts | 9 +- 7 files changed, 213 insertions(+), 127 deletions(-) create mode 100644 packages/b2c-vs-extension/src/config-provider.ts delete mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts diff --git a/packages/b2c-vs-extension/src/config-provider.ts b/packages/b2c-vs-extension/src/config-provider.ts new file mode 100644 index 00000000..957257a9 --- /dev/null +++ b/packages/b2c-vs-extension/src/config-provider.ts @@ -0,0 +1,161 @@ +/* + * 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 { + resolveConfig, + type NormalizedConfig, + type ResolveConfigOptions, + type ResolvedB2CConfig, +} from '@salesforce/b2c-tooling-sdk/config'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import {getPluginConfigSources} from './plugins.js'; + +/** + * Resolves configuration with plugin sources automatically injected. + */ +function resolveConfigWithPlugins( + overrides: Partial = {}, + options: ResolveConfigOptions = {}, +): ResolvedB2CConfig { + const {sourcesBefore, sourcesAfter} = getPluginConfigSources(); + return resolveConfig(overrides, { + ...options, + sourcesBefore: [...sourcesBefore, ...(options.sourcesBefore ?? [])], + sourcesAfter: [...(options.sourcesAfter ?? []), ...sourcesAfter], + }); +} + +const DW_JSON = 'dw.json'; + +/** + * Centralized B2C config provider for the VS Code extension. + * + * Resolves config from dw.json / env vars once, caches the result, + * and exposes an event so all features can react to config changes. + * Watches for dw.json changes via both a FileSystemWatcher (external edits, + * creates, deletes) and onDidSaveTextDocument (in-editor saves). + */ +export class B2CExtensionConfig implements vscode.Disposable { + private config: ResolvedB2CConfig | null = null; + private instance: B2CInstance | null = null; + private configError: string | null = null; + private resolved = false; + + private readonly _onDidReset = new vscode.EventEmitter(); + readonly onDidReset = this._onDidReset.event; + + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly log: vscode.OutputChannel) { + // Watch for dw.json saves made within VS Code (most reliable for in-editor edits) + this.disposables.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + if (path.basename(doc.fileName) === DW_JSON) { + this.log.appendLine(`[Config] dw.json saved in editor: ${doc.fileName}`); + this.reset(); + } + }), + ); + + // FileSystemWatcher per workspace folder for external changes and create/delete. + // RelativePattern is more reliable than a bare glob string on macOS. + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const pattern = new vscode.RelativePattern(folder, `**/${DW_JSON}`); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + watcher.onDidChange((uri) => { + this.log.appendLine(`[Config] dw.json changed (fs watcher): ${uri.fsPath}`); + this.reset(); + }); + watcher.onDidCreate((uri) => { + this.log.appendLine(`[Config] dw.json created: ${uri.fsPath}`); + this.reset(); + }); + watcher.onDidDelete((uri) => { + this.log.appendLine(`[Config] dw.json deleted: ${uri.fsPath}`); + this.reset(); + }); + this.disposables.push(watcher); + this.log.appendLine(`[Config] File watcher registered for ${folder.uri.fsPath}/**/${DW_JSON}`); + } + } + + getConfig(): ResolvedB2CConfig | null { + if (!this.resolved) { + this.resolve(); + } + return this.config; + } + + getInstance(): B2CInstance | null { + if (!this.resolved) { + this.resolve(); + } + return this.instance; + } + + getConfigError(): string | null { + if (!this.resolved) { + this.resolve(); + } + return this.configError; + } + + reset(): void { + this.log.appendLine('[Config] Resetting cached config (will re-resolve on next access)'); + this.config = null; + this.instance = null; + this.configError = null; + this.resolved = false; + this._onDidReset.fire(); + } + + /** + * Uncached config resolution for a specific directory. + * Used by deploy-cartridge where the project directory differs from the workspace root. + */ + resolveForDirectory(workingDirectory: string, overrides: Partial = {}): ResolvedB2CConfig { + return resolveConfigWithPlugins(overrides, {workingDirectory}); + } + + dispose(): void { + this._onDidReset.dispose(); + for (const d of this.disposables) { + d.dispose(); + } + } + + private resolve(): void { + this.resolved = true; + try { + let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { + workingDirectory = ''; + } + this.log.appendLine(`[Config] Resolving config from ${workingDirectory || '(no working directory)'}`); + const config = resolveConfigWithPlugins({}, {workingDirectory}); + this.config = config; + + if (!config.hasB2CInstanceConfig()) { + this.configError = 'No B2C Commerce instance configured.'; + this.instance = null; + this.log.appendLine('[Config] No B2C Commerce instance configured'); + return; + } + + this.instance = config.createB2CInstance(); + this.configError = null; + this.log.appendLine(`[Config] Resolved instance: ${this.instance.config.hostname}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.configError = message; + this.config = null; + this.instance = null; + this.log.appendLine(`[Config] Resolution failed: ${message}`); + } + } +} diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 29102ef0..b80a656e 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -5,12 +5,6 @@ */ import {createSlasClient, getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {createOdsClient, createScapiSchemasClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk/clients'; -import { - resolveConfig, - type NormalizedConfig, - type ResolveConfigOptions, - type ResolvedB2CConfig, -} from '@salesforce/b2c-tooling-sdk/config'; import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging'; import {findAndDeployCartridges, getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {getPathKeys, type OpenApiSchemaInput} from '@salesforce/b2c-tooling-sdk/schemas'; @@ -23,24 +17,10 @@ const execAsync = promisify(exec); import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import {initializePlugins, getPluginConfigSources} from './plugins.js'; +import {B2CExtensionConfig} from './config-provider.js'; +import {initializePlugins} from './plugins.js'; import {registerWebDavTree} from './webdav-tree/index.js'; -/** - * Resolves configuration with plugin sources automatically injected. - */ -function resolveConfigWithPlugins( - overrides: Partial = {}, - options: ResolveConfigOptions = {}, -): ResolvedB2CConfig { - const {sourcesBefore, sourcesAfter} = getPluginConfigSources(); - return resolveConfig(overrides, { - ...options, - sourcesBefore: [...sourcesBefore, ...(options.sourcesBefore ?? [])], - sourcesAfter: [...(options.sourcesAfter ?? []), ...sourcesAfter], - }); -} - /** * Recursively finds all files under dir whose names end with .json (metadata files). * Returns paths relative to dir. @@ -201,6 +181,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // before the first resolveConfig() call. Failures are non-fatal. await initializePlugins(); + const configProvider = new B2CExtensionConfig(log); + context.subscriptions.push(configProvider); + const disposable = vscode.commands.registerCommand('b2c-dx.openUI', () => { vscode.window.showInformationMessage('B2C DX: Opening Page Designer Assistant.'); @@ -331,11 +314,10 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu {enableScripts: true}, ); let prefill: {tenantId: string; channelId: string; shortCode?: string} | undefined; - try { - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const config = resolveConfigWithPlugins({}, {workingDirectory}); - const hostname = config.values.hostname; - const shortCode = config.values.shortCode; + const prefillConfig = configProvider.getConfig(); + if (prefillConfig) { + const hostname = prefillConfig.values.hostname; + const shortCode = prefillConfig.values.shortCode; const firstPart = hostname && typeof hostname === 'string' ? (hostname.split('.')[0] ?? '') : ''; const tenantId = firstPart ? firstPart.replace(/-/g, '_') : ''; if (tenantId || shortCode) { @@ -345,8 +327,6 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu shortCode: typeof shortCode === 'string' ? shortCode : undefined, }; } - } catch { - // Prefill is optional; leave undefined if config fails } panel.webview.html = getScapiExplorerWebviewContent(context, prefill); panel.webview.onDidReceiveMessage( @@ -364,8 +344,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu curlText?: string; }) => { const getConfig = () => { - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - return resolveConfigWithPlugins({}, {workingDirectory}); + const config = configProvider.getConfig(); + if (!config) throw new Error('No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.'); + return config; }; if (msg.type === 'scapiFetchSchemas') { @@ -748,8 +729,13 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu vscode.window.showErrorMessage('B2C DX: Tenant Id and Channel Id are required to create a SLAS client.'); return; } - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const config = resolveConfigWithPlugins({}, {workingDirectory}); + const config = configProvider.getConfig(); + if (!config) { + vscode.window.showErrorMessage( + 'B2C DX: No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.', + ); + return; + } const shortCode = config.values.shortCode; if (!shortCode) { vscode.window.showErrorMessage( @@ -861,26 +847,23 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu {enableScripts: true}, ); let defaultRealm = ''; - try { - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - const config = resolveConfigWithPlugins({}, {workingDirectory}); - // First part of hostname, e.g. 'zyoc' from 'zyoc-003.unified.demandware.net' - const hostname = config.values.hostname; + const odsConfig = configProvider.getConfig(); + if (odsConfig) { + const hostname = odsConfig.values.hostname; const firstSegment = (hostname && typeof hostname === 'string' ? hostname : '').split('.')[0] ?? ''; defaultRealm = firstSegment.split('-')[0] ?? ''; - } catch { - // leave defaultRealm empty } panel.webview.html = getOdsManagementWebviewContent(context, {defaultRealm}); - async function getOdsConfig() { - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? context.extensionPath; - return resolveConfigWithPlugins({}, {workingDirectory}); + function getOdsConfig() { + const config = configProvider.getConfig(); + if (!config) throw new Error('No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.'); + return config; } async function fetchSandboxList(): Promise<{sandboxes: unknown[]; error?: string}> { try { - const config = await getOdsConfig(); + const config = getOdsConfig(); if (!config.hasOAuthConfig()) { return {sandboxes: [], error: 'OAuth credentials required. Set clientId and clientSecret in dw.json.'}; } @@ -914,7 +897,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu if (msg.type === 'odsGetDefaultRealm') { let defaultRealm = ''; try { - const config = await getOdsConfig(); + const config = getOdsConfig(); const hostname = config.values.hostname; const firstSegment = (hostname && typeof hostname === 'string' ? hostname : '').split('.')[0] ?? ''; defaultRealm = firstSegment.split('-')[0] ?? ''; @@ -926,7 +909,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu } if (msg.type === 'odsSandboxClick' && msg.sandboxId) { try { - const config = await getOdsConfig(); + const config = getOdsConfig(); if (!config.hasOAuthConfig()) { panel.webview.postMessage({ type: 'odsSandboxDetailsError', @@ -967,7 +950,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu } if (msg.type === 'odsDeleteClick' && msg.sandboxId) { try { - const config = await getOdsConfig(); + const config = getOdsConfig(); if (!config.hasOAuthConfig()) { vscode.window.showErrorMessage('B2C DX: OAuth credentials required for ODS. Configure dw.json.'); return; @@ -995,7 +978,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu } if (msg.type === 'odsCreateSandbox' && msg.realm !== undefined && msg.ttl !== undefined) { try { - const config = await getOdsConfig(); + const config = getOdsConfig(); if (!config.hasOAuthConfig()) { vscode.window.showErrorMessage('B2C DX: OAuth credentials required for ODS. Configure dw.json.'); return; @@ -1113,7 +1096,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu vscode.window.showErrorMessage(message); return; } - const config = resolveConfigWithPlugins({}, {workingDirectory: projectDirectory}); + const config = configProvider.resolveForDirectory(projectDirectory); if (!config.hasB2CInstanceConfig()) { const message = '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 }, ); - registerWebDavTree(context); + registerWebDavTree(context, configProvider); context.subscriptions.push( disposable, diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts index 72fde9fd..b834bec8 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/index.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -4,13 +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 * as vscode from 'vscode'; -import {WebDavConfigProvider} from './webdav-config.js'; +import type {B2CExtensionConfig} from '../config-provider.js'; import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js'; import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; import {registerWebDavCommands} from './webdav-commands.js'; -export function registerWebDavTree(context: vscode.ExtensionContext): void { - const configProvider = new WebDavConfigProvider(); +export function registerWebDavTree(context: vscode.ExtensionContext, configProvider: B2CExtensionConfig): void { const fsProvider = new WebDavFileSystemProvider(configProvider); const fsRegistration = vscode.workspace.registerFileSystemProvider(WEBDAV_SCHEME, fsProvider, { @@ -26,5 +25,11 @@ export function registerWebDavTree(context: vscode.ExtensionContext): void { const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider); + // Auto-refresh when config changes (dw.json edit, manual reset, future instance switch) + configProvider.onDidReset(() => { + fsProvider.clearCache(); + treeProvider.refresh(); + }); + context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 4630ef96..5ae14b29 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -6,20 +6,18 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import type {WebDavConfigProvider} from './webdav-config.js'; +import type {B2CExtensionConfig} from '../config-provider.js'; import {type WebDavFileSystemProvider, webdavPathToUri} from './webdav-fs-provider.js'; import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; export function registerWebDavCommands( _context: vscode.ExtensionContext, - configProvider: WebDavConfigProvider, + configProvider: B2CExtensionConfig, treeProvider: WebDavTreeDataProvider, fsProvider: WebDavFileSystemProvider, ): vscode.Disposable[] { const refresh = vscode.commands.registerCommand('b2c-dx.webdav.refresh', () => { - fsProvider.clearCache(); configProvider.reset(); - treeProvider.refresh(); }); const newFolder = vscode.commands.registerCommand('b2c-dx.webdav.newFolder', async (node: WebDavTreeItem) => { diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts deleted file mode 100644 index f2af6ecd..00000000 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; -import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; -import * as fs from 'fs'; -import * as vscode from 'vscode'; -import {getPluginConfigSources} from '../plugins.js'; - -/** - * Manages B2CInstance lifecycle for the WebDAV tree view. - * Resolves config from dw.json / env vars, caches the instance, - * and exposes error state for the welcome view. - */ -export class WebDavConfigProvider { - private instance: B2CInstance | null = null; - private configError: string | null = null; - private resolved = false; - - getInstance(): B2CInstance | null { - if (!this.resolved) { - this.resolve(); - } - return this.instance; - } - - getConfigError(): string | null { - if (!this.resolved) { - this.resolve(); - } - return this.configError; - } - - reset(): void { - this.instance = null; - this.configError = null; - this.resolved = false; - } - - private resolve(): void { - this.resolved = true; - try { - let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { - workingDirectory = ''; - } - const {sourcesBefore, sourcesAfter} = getPluginConfigSources(); - const config = resolveConfig({}, {sourcesBefore, sourcesAfter, workingDirectory}); - - if (!config.hasB2CInstanceConfig()) { - this.configError = 'No B2C Commerce instance configured.'; - this.instance = null; - return; - } - - this.instance = config.createB2CInstance(); - this.configError = null; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.configError = message; - this.instance = null; - } - } -} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts index e4abb26f..da32f2db 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts @@ -5,7 +5,7 @@ */ import * as path from 'path'; import * as vscode from 'vscode'; -import type {WebDavConfigProvider} from './webdav-config.js'; +import type {B2CExtensionConfig} from '../config-provider.js'; export const WEBDAV_SCHEME = 'b2c-webdav'; @@ -82,7 +82,7 @@ export class WebDavFileSystemProvider implements vscode.FileSystemProvider { private statCache = new Map(); private dirCache = new Map(); - constructor(private configProvider: WebDavConfigProvider) {} + constructor(private configProvider: B2CExtensionConfig) {} watch(): vscode.Disposable { // WebDAV has no push notifications β€” return no-op disposable. diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts index 3dc9b18f..cf6188fc 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts @@ -4,7 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import * as vscode from 'vscode'; -import type {WebDavConfigProvider} from './webdav-config.js'; +import type {B2CExtensionConfig} from '../config-provider.js'; import {type WebDavFileSystemProvider, WEBDAV_ROOTS, webdavPathToUri} from './webdav-fs-provider.js'; function formatFileSize(bytes: number | undefined): string { @@ -57,7 +57,7 @@ export class WebDavTreeDataProvider implements vscode.TreeDataProvider Date: Sat, 21 Feb 2026 21:47:54 -0500 Subject: [PATCH 09/16] Add changeset for SDK plugin module --- .changeset/sdk-plugin-module.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sdk-plugin-module.md diff --git a/.changeset/sdk-plugin-module.md b/.changeset/sdk-plugin-module.md new file mode 100644 index 00000000..255c6810 --- /dev/null +++ b/.changeset/sdk-plugin-module.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `@salesforce/b2c-tooling-sdk/plugins` module for discovering and loading b2c-cli plugins outside of oclif. Enables the VS Code extension and other non-CLI consumers to use installed plugins (keychain managers, config sources, middleware) without depending on `@oclif/core`. From e2a5a6821bf572985cb5f81d9814f609ebd689fd Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 19 Feb 2026 21:20:36 -0500 Subject: [PATCH 10/16] Replace WebDAV Browser webview with native TreeView Replace the 561-line HTML webview (webdav.html) with a VS Code native TreeDataProvider sidebar. This gives the extension its first Activity Bar presence and provides collapsible tree browsing, context menus, keyboard navigation, theming, and accessibility for free. - Add Activity Bar container "B2C-DX WebDAV" with sidebar tree view - Lazy-load directory contents via PROPFIND with caching - Context menu commands: New Folder, Upload, Delete, Download, Open File - Open files via temp storage with native VS Code editors (syntax highlighting, image viewer, etc.) - Welcome view when no B2C instance is configured - Replace "*" activation event with targeted onView activation - Remove webdav.html and all inline webview message handling (~240 lines) --- packages/b2c-vs-extension/.vscodeignore | 1 - packages/b2c-vs-extension/media/b2c-icon.svg | 4 + packages/b2c-vs-extension/package.json | 102 +++- packages/b2c-vs-extension/src/extension.ts | 282 +-------- .../b2c-vs-extension/src/webdav-tree/index.ts | 23 + .../src/webdav-tree/webdav-commands.ts | 201 +++++++ .../src/webdav-tree/webdav-config.ts | 65 ++ .../src/webdav-tree/webdav-tree-provider.ts | 137 +++++ packages/b2c-vs-extension/src/webdav.html | 561 ------------------ 9 files changed, 534 insertions(+), 842 deletions(-) create mode 100644 packages/b2c-vs-extension/media/b2c-icon.svg create mode 100644 packages/b2c-vs-extension/src/webdav-tree/index.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts delete mode 100644 packages/b2c-vs-extension/src/webdav.html diff --git a/packages/b2c-vs-extension/.vscodeignore b/packages/b2c-vs-extension/.vscodeignore index 70e6d349..32f022e2 100644 --- a/packages/b2c-vs-extension/.vscodeignore +++ b/packages/b2c-vs-extension/.vscodeignore @@ -2,7 +2,6 @@ .vscode-test/** src/** !src/webview.html -!src/webdav.html !src/storefront-next-cartridge.html !src/scapi-explorer.html !src/ods-management.html diff --git a/packages/b2c-vs-extension/media/b2c-icon.svg b/packages/b2c-vs-extension/media/b2c-icon.svg new file mode 100644 index 00000000..ec49c250 --- /dev/null +++ b/packages/b2c-vs-extension/media/b2c-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index a3f140ab..fe5c5a8e 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -18,8 +18,7 @@ "vscode": "^1.105.1" }, "activationEvents": [ - "*", - "onStartupFinished", + "onView:b2cWebdavExplorer", "onCommand:b2c-dx.openUI", "onCommand:b2c-dx.handleStorefrontNextCartridge", "onCommand:b2c-dx.promptAgent", @@ -29,6 +28,31 @@ ], "main": "./dist/extension.js", "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "b2c-dx", + "title": "B2C-DX WebDAV", + "icon": "media/b2c-icon.svg" + } + ] + }, + "views": { + "b2c-dx": [ + { + "id": "b2cWebdavExplorer", + "name": "Browser", + "icon": "media/b2c-icon.svg", + "contextualTitle": "B2C Commerce" + } + ] + }, + "viewsWelcome": [ + { + "view": "b2cWebdavExplorer", + "contents": "No B2C Commerce instance configured.\n\nCreate a dw.json file in your workspace or set SFCC_* environment variables.\n\n[Refresh](command:b2c-dx.webdav.refresh)" + } + ], "commands": [ { "command": "b2c-dx.openUI", @@ -59,8 +83,80 @@ "command": "b2c-dx.odsManagement", "title": "On Demand Sandbox (ods) Management", "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.refresh", + "title": "Refresh", + "icon": "$(refresh)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.newFolder", + "title": "New Folder", + "icon": "$(new-folder)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.uploadFile", + "title": "Upload File", + "icon": "$(cloud-upload)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.delete", + "title": "Delete", + "icon": "$(trash)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.download", + "title": "Download", + "icon": "$(cloud-download)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.openFile", + "title": "Open File", + "icon": "$(go-to-file)", + "category": "B2C DX" } - ] + ], + "menus": { + "view/title": [ + { + "command": "b2c-dx.webdav.refresh", + "when": "view == b2cWebdavExplorer", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "b2c-dx.webdav.newFolder", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@1" + }, + { + "command": "b2c-dx.webdav.uploadFile", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@2" + }, + { + "command": "b2c-dx.webdav.openFile", + "when": "view == b2cWebdavExplorer && viewItem == file", + "group": "1_open@1" + }, + { + "command": "b2c-dx.webdav.download", + "when": "view == b2cWebdavExplorer && viewItem == file", + "group": "1_open@2" + }, + { + "command": "b2c-dx.webdav.delete", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", + "group": "2_destructive@1" + } + ] + } }, "scripts": { "build": "node scripts/esbuild-bundle.mjs", diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 76cbed4a..16803b98 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -15,21 +15,10 @@ import {promisify} from 'util'; const execAsync = promisify(exec); -/** Standard B2C Commerce WebDAV root directories. */ -const WEBDAV_ROOTS: Record = { - IMPEX: 'Impex', - TEMP: 'Temp', - CARTRIDGES: 'Cartridges', - REALMDATA: 'Realmdata', - CATALOGS: 'Catalogs', - LIBRARIES: 'Libraries', - STATIC: 'Static', - LOGS: 'Logs', - SECURITYLOGS: 'Securitylogs', -}; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import {registerWebDavTree} from './webdav-tree/index.js'; /** * Recursively finds all files under dir whose names end with .json (metadata files). @@ -81,31 +70,6 @@ function getOdsManagementWebviewContent(context: vscode.ExtensionContext, prefil return html; } -const WEBDAV_ROOT_LABELS: Record = { - impex: 'Impex directory (default)', - temp: 'Temporary files', - cartridges: 'Code cartridges', - realmdata: 'Realm data', - catalogs: 'Product catalogs', - libraries: 'Content libraries', - static: 'Static resources', - logs: 'Log files', - securitylogs: 'Security log files', -}; - -function getWebdavWebviewContent( - context: vscode.ExtensionContext, - roots: {key: string; path: string; label: string}[], -): string { - const htmlPath = path.join(context.extensionPath, 'src', 'webdav.html'); - const raw = fs.readFileSync(htmlPath, 'utf-8'); - const rootsJson = JSON.stringify(roots); - return raw.replace( - 'const roots = window.WEBDAV_ROOTS || [];', - `window.WEBDAV_ROOTS = ${rootsJson};\n const roots = window.WEBDAV_ROOTS;`, - ); -} - /** PascalCase for use in template content (class names, types, etc.). e.g. "first page" β†’ "FirstPage" */ function pageNameToPageId(pageName: string): string { return pageName @@ -303,246 +267,8 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann } }); - type WebDavPropfindEntry = {href: string; displayName?: string; contentLength?: number; isCollection?: boolean}; - - const listWebDavDisposable = vscode.commands.registerCommand('b2c-dx.listWebDav', async () => { - let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { - workingDirectory = context.extensionPath; - } - const dwPath = findDwJson(workingDirectory); - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); - - if (!config.hasB2CInstanceConfig()) { - vscode.window.showErrorMessage( - 'B2C DX: No instance config. Configure SFCC_* env vars or dw.json in the workspace.', - ); - return; - } - - const roots = (Object.keys(WEBDAV_ROOTS) as string[]).map((key) => { - const pathVal = (WEBDAV_ROOTS as Record)[key]; - const keyLower = key.toLowerCase(); - return { - key: keyLower, - path: pathVal, - label: WEBDAV_ROOT_LABELS[keyLower] ?? pathVal, - }; - }); - - const panel = vscode.window.createWebviewPanel('b2c-dx-webdav', 'B2C WebDAV Browser', vscode.ViewColumn.One, { - enableScripts: true, - }); - panel.webview.html = getWebdavWebviewContent(context, roots); - - const instance = config.createB2CInstance() as { - webdav: { - propfind: (path: string, depth: '1') => Promise; - mkcol: (path: string) => Promise; - delete: (path: string) => Promise; - put: (path: string, content: Buffer | Blob | string, contentType?: string) => Promise; - get: (path: string) => Promise; - }; - }; - - const getDisplayName = (e: WebDavPropfindEntry): string => - e.displayName ?? e.href.split('/').filter(Boolean).at(-1) ?? e.href; - - panel.webview.onDidReceiveMessage( - async (msg: {type: string; path?: string; name?: string; isCollection?: boolean}) => { - if (msg.type === 'listPath' && msg.path !== undefined) { - const listPath = msg.path as string; - try { - const entries = await instance.webdav.propfind(listPath, '1'); - const normalizedPath = listPath.replace(/\/$/, ''); - const filtered = entries.filter((entry: WebDavPropfindEntry) => { - const entryPath = decodeURIComponent(entry.href); - return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); - }); - panel.webview.postMessage({ - type: 'listResult', - path: listPath, - entries: filtered.map((e: WebDavPropfindEntry) => ({ - name: getDisplayName(e), - isCollection: Boolean(e.isCollection), - contentLength: e.contentLength, - })), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({ - type: 'listResult', - path: listPath, - entries: [], - error: message, - }); - } - return; - } - if (msg.type === 'requestMkdir' && msg.path !== undefined) { - const parentPath = msg.path as string; - const name = await vscode.window.showInputBox({ - title: 'New folder', - prompt: parentPath ? `Create directory under ${parentPath}` : 'Create directory at root', - placeHolder: 'Folder name', - validateInput: (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return 'Enter a folder name'; - if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; - return null; - }, - }); - if (name === undefined) return; - const trimmed = name.trim(); - if (!trimmed) return; - const fullPath = parentPath ? `${parentPath}/${trimmed}` : trimmed; - try { - await instance.webdav.mkcol(fullPath); - panel.webview.postMessage({type: 'mkdirResult', success: true, path: fullPath}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'mkdirResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestDelete' && msg.path !== undefined) { - const pathToDelete = msg.path as string; - const name = msg.name ?? pathToDelete.split('/').pop() ?? pathToDelete; - const isDir = msg.isCollection === true; - const detail = isDir ? 'This directory and its contents will be deleted.' : 'This file will be deleted.'; - const choice = await vscode.window.showWarningMessage( - `Delete "${name}"? ${detail}`, - {modal: true}, - 'Delete', - 'Cancel', - ); - if (choice !== 'Delete') return; - try { - await instance.webdav.delete(pathToDelete); - panel.webview.postMessage({type: 'deleteResult', success: true}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'deleteResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestUpload' && msg.path !== undefined) { - const destPath = msg.path as string; - const uris = await vscode.window.showOpenDialog({ - title: 'Select file to upload', - canSelectFiles: true, - canSelectMany: false, - canSelectFolders: false, - }); - if (!uris?.length) return; - const uri = uris[0]; - const fileName = path.basename(uri.fsPath); - const fullPath = destPath ? `${destPath}/${fileName}` : fileName; - try { - const content = fs.readFileSync(uri.fsPath); - const ext = path.extname(fileName).toLowerCase(); - const mime: Record = { - '.json': 'application/json', - '.xml': 'application/xml', - '.zip': 'application/zip', - '.js': 'application/javascript', - '.ts': 'application/typescript', - '.html': 'text/html', - '.css': 'text/css', - '.txt': 'text/plain', - }; - const contentType = mime[ext]; - await instance.webdav.put(fullPath, content, contentType); - panel.webview.postMessage({type: 'uploadResult', success: true, path: fullPath}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({type: 'uploadResult', success: false, error: message}); - } - return; - } - if (msg.type === 'requestFileContent' && msg.path !== undefined) { - const filePath = msg.path as string; - const fileName = msg.name ?? filePath.split('/').pop() ?? filePath; - const ext = path.extname(fileName).toLowerCase(); - const imageExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg']); - const textExtensions = new Set([ - '.json', - '.js', - '.ts', - '.mjs', - '.cjs', - '.html', - '.htm', - '.css', - '.xml', - '.txt', - '.md', - '.log', - '.yml', - '.yaml', - '.env', - '.sh', - '.bat', - '.csv', - '.isml', - ]); - const isImage = imageExtensions.has(ext); - const isText = textExtensions.has(ext) || ext === ''; - try { - const buffer = await instance.webdav.get(filePath); - const arr = new Uint8Array(buffer); - if (isImage) { - const base64 = Buffer.from(arr).toString('base64'); - const mime: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.svg': 'image/svg+xml', - }; - const contentType = mime[ext] ?? 'application/octet-stream'; - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'image', - contentType, - base64, - }); - } else if (isText) { - const text = new TextDecoder('utf-8', {fatal: false}).decode(arr); - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'text', - text, - }); - } else { - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'binary', - error: 'Binary file cannot be previewed.', - }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - panel.webview.postMessage({ - type: 'fileContent', - path: filePath, - name: fileName, - kind: 'error', - error: message, - }); - } - } - }, - ); + const listWebDavDisposable = vscode.commands.registerCommand('b2c-dx.listWebDav', () => { + vscode.commands.executeCommand('b2cWebdavExplorer.focus'); }); function resolveStorefrontNextProjectDir(): string | undefined { @@ -1437,6 +1163,8 @@ function activateInner(context: vscode.ExtensionContext, log: vscode.OutputChann }, ); + registerWebDavTree(context); + context.subscriptions.push( disposable, promptAgentDisposable, diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts new file mode 100644 index 00000000..46783bd7 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -0,0 +1,23 @@ +/* + * 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 * as vscode from 'vscode'; +import {WebDavConfigProvider} from './webdav-config.js'; +import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; +import {registerWebDavCommands} from './webdav-commands.js'; + +export function registerWebDavTree(context: vscode.ExtensionContext): void { + const configProvider = new WebDavConfigProvider(); + const treeProvider = new WebDavTreeDataProvider(configProvider); + + const treeView = vscode.window.createTreeView('b2cWebdavExplorer', { + treeDataProvider: treeProvider, + showCollapseAll: true, + }); + + const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider); + + context.subscriptions.push(treeView, ...commandDisposables); +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts new file mode 100644 index 00000000..2b12adc0 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -0,0 +1,201 @@ +/* + * 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 * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; +import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; + +export function registerWebDavCommands( + context: vscode.ExtensionContext, + configProvider: WebDavConfigProvider, + treeProvider: WebDavTreeDataProvider, +): vscode.Disposable[] { + const refresh = vscode.commands.registerCommand('b2c-dx.webdav.refresh', () => { + configProvider.reset(); + treeProvider.refresh(); + }); + + const newFolder = vscode.commands.registerCommand('b2c-dx.webdav.newFolder', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const name = await vscode.window.showInputBox({ + title: 'New Folder', + prompt: `Create directory under ${node.webdavPath}`, + placeHolder: 'Folder name', + validateInput: (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return 'Enter a folder name'; + if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; + return null; + }, + }); + if (!name) return; + + const fullPath = `${node.webdavPath}/${name.trim()}`; + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Creating folder ${name.trim()}...`}, + async () => { + try { + await instance.webdav.mkcol(fullPath); + treeProvider.refreshNode(node); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to create folder: ${message}`); + } + }, + ); + }); + + const uploadFile = vscode.commands.registerCommand('b2c-dx.webdav.uploadFile', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const uris = await vscode.window.showOpenDialog({ + title: 'Select file to upload', + canSelectFiles: true, + canSelectMany: false, + canSelectFolders: false, + }); + if (!uris?.length) return; + + const uri = uris[0]; + const fileName = path.basename(uri.fsPath); + const fullPath = `${node.webdavPath}/${fileName}`; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName}...`}, + async () => { + try { + const content = fs.readFileSync(uri.fsPath); + const ext = path.extname(fileName).toLowerCase(); + const mime: Record = { + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.html': 'text/html', + '.css': 'text/css', + '.txt': 'text/plain', + }; + await instance.webdav.put(fullPath, content, mime[ext]); + treeProvider.refreshNode(node); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Upload failed: ${message}`); + } + }, + ); + }); + + const deleteItem = vscode.commands.registerCommand('b2c-dx.webdav.delete', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const detail = node.isCollection + ? 'This directory and its contents will be deleted.' + : 'This file will be deleted.'; + const choice = await vscode.window.showWarningMessage( + `Delete "${node.fileName}"? ${detail}`, + {modal: true}, + 'Delete', + 'Cancel', + ); + if (choice !== 'Delete') return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Deleting ${node.fileName}...`}, + async () => { + try { + await instance.webdav.delete(node.webdavPath); + // Refresh parent by refreshing the whole tree β€” the parent node + // is not directly available from the child. + treeProvider.refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Delete failed: ${message}`); + } + }, + ); + }); + + const download = vscode.commands.registerCommand('b2c-dx.webdav.download', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri + ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, node.fileName) + : undefined; + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + saveLabel: 'Download', + }); + if (!saveUri) return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Downloading ${node.fileName}...`}, + async () => { + try { + const buffer = await instance.webdav.get(node.webdavPath); + await vscode.workspace.fs.writeFile(saveUri, new Uint8Array(buffer)); + vscode.window.showInformationMessage(`Downloaded to ${saveUri.fsPath}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Download failed: ${message}`); + } + }, + ); + }); + + const openFile = vscode.commands.registerCommand('b2c-dx.webdav.openFile', async (node: WebDavTreeItem) => { + if (!node) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); + return; + } + + const previewDir = vscode.Uri.joinPath(context.globalStorageUri, 'webdav-preview'); + const tempFileUri = vscode.Uri.joinPath(previewDir, node.webdavPath); + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Opening ${node.fileName}...`}, + async () => { + try { + const buffer = await instance.webdav.get(node.webdavPath); + // Ensure parent directories exist + const parentDir = vscode.Uri.joinPath(tempFileUri, '..'); + await vscode.workspace.fs.createDirectory(parentDir); + await vscode.workspace.fs.writeFile(tempFileUri, new Uint8Array(buffer)); + await vscode.commands.executeCommand('vscode.open', tempFileUri); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to open file: ${message}`); + } + }, + ); + }); + + return [refresh, newFolder, uploadFile, deleteItem, download, openFile]; +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts new file mode 100644 index 00000000..436fe051 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-config.ts @@ -0,0 +1,65 @@ +/* + * 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 {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +/** + * Manages B2CInstance lifecycle for the WebDAV tree view. + * Resolves config from dw.json / env vars, caches the instance, + * and exposes error state for the welcome view. + */ +export class WebDavConfigProvider { + private instance: B2CInstance | null = null; + private configError: string | null = null; + private resolved = false; + + getInstance(): B2CInstance | null { + if (!this.resolved) { + this.resolve(); + } + return this.instance; + } + + getConfigError(): string | null { + if (!this.resolved) { + this.resolve(); + } + return this.configError; + } + + reset(): void { + this.instance = null; + this.configError = null; + this.resolved = false; + } + + private resolve(): void { + this.resolved = true; + try { + let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { + workingDirectory = ''; + } + const dwPath = workingDirectory ? findDwJson(workingDirectory) : undefined; + const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); + + if (!config.hasB2CInstanceConfig()) { + this.configError = 'No B2C Commerce instance configured.'; + this.instance = null; + return; + } + + this.instance = config.createB2CInstance(); + this.configError = null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.configError = message; + this.instance = null; + } + } +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts new file mode 100644 index 00000000..e863d9d6 --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts @@ -0,0 +1,137 @@ +/* + * 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 * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; + +/** Standard B2C Commerce WebDAV root directories. */ +const WEBDAV_ROOTS: {key: string; path: string}[] = [ + {key: 'Impex', path: 'Impex'}, + {key: 'Temp', path: 'Temp'}, + {key: 'Cartridges', path: 'Cartridges'}, + {key: 'Realmdata', path: 'Realmdata'}, + {key: 'Catalogs', path: 'Catalogs'}, + {key: 'Libraries', path: 'Libraries'}, + {key: 'Static', path: 'Static'}, + {key: 'Logs', path: 'Logs'}, + {key: 'Securitylogs', path: 'Securitylogs'}, +]; + +function formatFileSize(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return ''; + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const k = 1024; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1); + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +export class WebDavTreeItem extends vscode.TreeItem { + constructor( + readonly nodeType: 'root' | 'directory' | 'file', + readonly webdavPath: string, + readonly fileName: string, + readonly isCollection: boolean, + readonly contentLength?: number, + ) { + super( + fileName, + nodeType === 'file' ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, + ); + + this.contextValue = nodeType; + this.tooltip = webdavPath; + + if (nodeType === 'root') { + this.iconPath = new vscode.ThemeIcon('database'); + } else if (nodeType === 'directory') { + this.iconPath = vscode.ThemeIcon.Folder; + } else { + this.iconPath = vscode.ThemeIcon.File; + this.resourceUri = vscode.Uri.parse(`webdav://b2c/${webdavPath}`); + if (contentLength !== undefined) { + this.description = formatFileSize(contentLength); + } + this.command = { + command: 'b2c-dx.webdav.openFile', + title: 'Open File', + arguments: [this], + }; + } + } +} + +export class WebDavTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private childrenCache = new Map(); + + constructor(private configProvider: WebDavConfigProvider) {} + + refresh(): void { + this.childrenCache.clear(); + this._onDidChangeTreeData.fire(); + } + + refreshNode(node: WebDavTreeItem): void { + this.childrenCache.delete(node.webdavPath); + this._onDidChangeTreeData.fire(node); + } + + getTreeItem(element: WebDavTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: WebDavTreeItem): Promise { + if (!element) { + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + return WEBDAV_ROOTS.map((r) => new WebDavTreeItem('root', r.path, r.key, true)); + } + + const cached = this.childrenCache.get(element.webdavPath); + if (cached) { + return cached; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + return []; + } + + try { + const entries = await instance.webdav.propfind(element.webdavPath, '1'); + const normalizedPath = element.webdavPath.replace(/\/$/, ''); + const filtered = entries.filter((entry) => { + const entryPath = decodeURIComponent(entry.href); + return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + }); + + const children = filtered + .map((entry) => { + const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; + const childPath = `${element.webdavPath}/${displayName}`; + const nodeType = entry.isCollection ? 'directory' : 'file'; + return new WebDavTreeItem(nodeType, childPath, displayName, entry.isCollection, entry.contentLength); + }) + .sort((a, b) => { + if (a.isCollection !== b.isCollection) { + return a.isCollection ? -1 : 1; + } + return a.fileName.localeCompare(b.fileName); + }); + + this.childrenCache.set(element.webdavPath, children); + return children; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`WebDAV: Failed to list ${element.webdavPath}: ${message}`); + return []; + } + } +} diff --git a/packages/b2c-vs-extension/src/webdav.html b/packages/b2c-vs-extension/src/webdav.html deleted file mode 100644 index 4f9d39d7..00000000 --- a/packages/b2c-vs-extension/src/webdav.html +++ /dev/null @@ -1,561 +0,0 @@ - - - - - - B2C WebDAV Browser - - - -
-

B2C WebDAV Browser

- - -
- - - -
- -
-
- Preview - -
-
-
- - - - From fb868fa25f80d641dd65b396bcdf9567dff92b7b Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 19 Feb 2026 22:30:26 -0500 Subject: [PATCH 11/16] Add FileSystemProvider, New File command, and workspace folder mount - Add WebDavFileSystemProvider with caching, stat/readDirectory/readFile/ writeFile/createDirectory/delete operations against WebDAV - Root path handling returns synthetic directory listing of the 9 well-known B2C Commerce roots (avoids PROPFIND on "/") - Tree provider delegates to FS provider instead of calling WebDAV directly; root nodes use standard folder icons via resourceUri - New File command: prompts for filename, creates empty file, opens in editor - Mount/Unmount Workspace commands: add/remove b2c-webdav:/ as a VS Code workspace folder for native Explorer integration - Context key b2c-dx.webdav.mounted tracks mount state for menu visibility - Download command available in native Explorer context menu for b2c-webdav files --- packages/b2c-vs-extension/package.json | 41 +++ .../b2c-vs-extension/src/webdav-tree/index.ts | 22 +- .../src/webdav-tree/webdav-commands.ts | 110 ++++--- .../src/webdav-tree/webdav-fs-provider.ts | 287 ++++++++++++++++++ .../src/webdav-tree/webdav-tree-provider.ts | 102 +++---- 5 files changed, 444 insertions(+), 118 deletions(-) create mode 100644 packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index fe5c5a8e..acc1b4a8 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -19,6 +19,7 @@ }, "activationEvents": [ "onView:b2cWebdavExplorer", + "onFileSystem:b2c-webdav", "onCommand:b2c-dx.openUI", "onCommand:b2c-dx.handleStorefrontNextCartridge", "onCommand:b2c-dx.promptAgent", @@ -119,6 +120,24 @@ "title": "Open File", "icon": "$(go-to-file)", "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.newFile", + "title": "New File", + "icon": "$(new-file)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "title": "Open as Workspace Folder", + "icon": "$(root-folder-opened)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.webdav.unmountWorkspace", + "title": "Remove Workspace Folder", + "icon": "$(root-folder)", + "category": "B2C DX" } ], "menus": { @@ -127,9 +146,24 @@ "command": "b2c-dx.webdav.refresh", "when": "view == b2cWebdavExplorer", "group": "navigation" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "when": "view == b2cWebdavExplorer && !b2c-dx.webdav.mounted", + "group": "navigation" + }, + { + "command": "b2c-dx.webdav.unmountWorkspace", + "when": "view == b2cWebdavExplorer && b2c-dx.webdav.mounted", + "group": "navigation" } ], "view/item/context": [ + { + "command": "b2c-dx.webdav.newFile", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "1_modification@0" + }, { "command": "b2c-dx.webdav.newFolder", "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", @@ -155,6 +189,13 @@ "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", "group": "2_destructive@1" } + ], + "explorer/context": [ + { + "command": "b2c-dx.webdav.download", + "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder", + "group": "navigation" + } ] } }, diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts index 46783bd7..41fb5178 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/index.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -5,19 +5,35 @@ */ import * as vscode from 'vscode'; import {WebDavConfigProvider} from './webdav-config.js'; +import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js'; import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; import {registerWebDavCommands} from './webdav-commands.js'; +function syncMountedContext(): void { + const mounted = (vscode.workspace.workspaceFolders ?? []).some((f) => f.uri.scheme === WEBDAV_SCHEME); + vscode.commands.executeCommand('setContext', 'b2c-dx.webdav.mounted', mounted); +} + export function registerWebDavTree(context: vscode.ExtensionContext): void { const configProvider = new WebDavConfigProvider(); - const treeProvider = new WebDavTreeDataProvider(configProvider); + const fsProvider = new WebDavFileSystemProvider(configProvider); + + const fsRegistration = vscode.workspace.registerFileSystemProvider(WEBDAV_SCHEME, fsProvider, { + isCaseSensitive: true, + }); + + const treeProvider = new WebDavTreeDataProvider(configProvider, fsProvider); const treeView = vscode.window.createTreeView('b2cWebdavExplorer', { treeDataProvider: treeProvider, showCollapseAll: true, }); - const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider); + const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider); + + // Sync the mounted context key on activation and when workspace folders change + syncMountedContext(); + const folderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => syncMountedContext()); - context.subscriptions.push(treeView, ...commandDisposables); + context.subscriptions.push(fsRegistration, treeView, folderWatcher, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 2b12adc0..261b1bc5 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -7,25 +7,23 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; +import {type WebDavFileSystemProvider, WEBDAV_SCHEME, webdavPathToUri} from './webdav-fs-provider.js'; import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; export function registerWebDavCommands( - context: vscode.ExtensionContext, + _context: vscode.ExtensionContext, configProvider: WebDavConfigProvider, treeProvider: WebDavTreeDataProvider, + fsProvider: WebDavFileSystemProvider, ): vscode.Disposable[] { const refresh = vscode.commands.registerCommand('b2c-dx.webdav.refresh', () => { + fsProvider.clearCache(); configProvider.reset(); treeProvider.refresh(); }); const newFolder = vscode.commands.registerCommand('b2c-dx.webdav.newFolder', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const name = await vscode.window.showInputBox({ title: 'New Folder', @@ -45,8 +43,7 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Creating folder ${name.trim()}...`}, async () => { try { - await instance.webdav.mkcol(fullPath); - treeProvider.refreshNode(node); + await fsProvider.createDirectory(webdavPathToUri(fullPath)); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Failed to create folder: ${message}`); @@ -57,11 +54,6 @@ export function registerWebDavCommands( const uploadFile = vscode.commands.registerCommand('b2c-dx.webdav.uploadFile', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const uris = await vscode.window.showOpenDialog({ title: 'Select file to upload', @@ -80,19 +72,10 @@ export function registerWebDavCommands( async () => { try { const content = fs.readFileSync(uri.fsPath); - const ext = path.extname(fileName).toLowerCase(); - const mime: Record = { - '.json': 'application/json', - '.xml': 'application/xml', - '.zip': 'application/zip', - '.js': 'application/javascript', - '.ts': 'application/typescript', - '.html': 'text/html', - '.css': 'text/css', - '.txt': 'text/plain', - }; - await instance.webdav.put(fullPath, content, mime[ext]); - treeProvider.refreshNode(node); + await fsProvider.writeFile(webdavPathToUri(fullPath), new Uint8Array(content), { + create: true, + overwrite: true, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Upload failed: ${message}`); @@ -103,11 +86,6 @@ export function registerWebDavCommands( const deleteItem = vscode.commands.registerCommand('b2c-dx.webdav.delete', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const detail = node.isCollection ? 'This directory and its contents will be deleted.' @@ -124,10 +102,7 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Deleting ${node.fileName}...`}, async () => { try { - await instance.webdav.delete(node.webdavPath); - // Refresh parent by refreshing the whole tree β€” the parent node - // is not directly available from the child. - treeProvider.refresh(); + await fsProvider.delete(webdavPathToUri(node.webdavPath)); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`WebDAV: Delete failed: ${message}`); @@ -138,11 +113,6 @@ export function registerWebDavCommands( const download = vscode.commands.registerCommand('b2c-dx.webdav.download', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, node.fileName) @@ -157,8 +127,8 @@ export function registerWebDavCommands( {location: vscode.ProgressLocation.Notification, title: `Downloading ${node.fileName}...`}, async () => { try { - const buffer = await instance.webdav.get(node.webdavPath); - await vscode.workspace.fs.writeFile(saveUri, new Uint8Array(buffer)); + const content = await fsProvider.readFile(webdavPathToUri(node.webdavPath)); + await vscode.workspace.fs.writeFile(saveUri, content); vscode.window.showInformationMessage(`Downloaded to ${saveUri.fsPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -170,32 +140,56 @@ export function registerWebDavCommands( const openFile = vscode.commands.registerCommand('b2c-dx.webdav.openFile', async (node: WebDavTreeItem) => { if (!node) return; - const instance = configProvider.getInstance(); - if (!instance) { - vscode.window.showErrorMessage('WebDAV: No B2C instance configured.'); - return; - } + const uri = webdavPathToUri(node.webdavPath); + await vscode.commands.executeCommand('vscode.open', uri); + }); - const previewDir = vscode.Uri.joinPath(context.globalStorageUri, 'webdav-preview'); - const tempFileUri = vscode.Uri.joinPath(previewDir, node.webdavPath); + const newFile = vscode.commands.registerCommand('b2c-dx.webdav.newFile', async (node: WebDavTreeItem) => { + if (!node) return; + const name = await vscode.window.showInputBox({ + title: 'New File', + prompt: `Create file under ${node.webdavPath}`, + placeHolder: 'File name', + validateInput: (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return 'Enter a file name'; + if (/[\\/:*?"<>|]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |'; + return null; + }, + }); + if (!name) return; + + const fullPath = `${node.webdavPath}/${name.trim()}`; + const uri = webdavPathToUri(fullPath); await vscode.window.withProgress( - {location: vscode.ProgressLocation.Notification, title: `Opening ${node.fileName}...`}, + {location: vscode.ProgressLocation.Notification, title: `Creating file ${name.trim()}...`}, async () => { try { - const buffer = await instance.webdav.get(node.webdavPath); - // Ensure parent directories exist - const parentDir = vscode.Uri.joinPath(tempFileUri, '..'); - await vscode.workspace.fs.createDirectory(parentDir); - await vscode.workspace.fs.writeFile(tempFileUri, new Uint8Array(buffer)); - await vscode.commands.executeCommand('vscode.open', tempFileUri); + await fsProvider.writeFile(uri, new Uint8Array(0), {create: true, overwrite: false}); + await vscode.commands.executeCommand('vscode.open', uri); } catch (err) { const message = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`WebDAV: Failed to open file: ${message}`); + vscode.window.showErrorMessage(`WebDAV: Failed to create file: ${message}`); } }, ); }); - return [refresh, newFolder, uploadFile, deleteItem, download, openFile]; + const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', () => { + vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length ?? 0, 0, { + uri: vscode.Uri.parse(`${WEBDAV_SCHEME}:/`), + name: 'B2C Commerce WebDAV', + }); + }); + + const unmountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.unmountWorkspace', () => { + const folders = vscode.workspace.workspaceFolders ?? []; + const idx = folders.findIndex((f) => f.uri.scheme === WEBDAV_SCHEME); + if (idx >= 0) { + vscode.workspace.updateWorkspaceFolders(idx, 1); + } + }); + + return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace, unmountWorkspace]; } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts new file mode 100644 index 00000000..e4abb26f --- /dev/null +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts @@ -0,0 +1,287 @@ +/* + * 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 * as path from 'path'; +import * as vscode from 'vscode'; +import type {WebDavConfigProvider} from './webdav-config.js'; + +export const WEBDAV_SCHEME = 'b2c-webdav'; + +/** Standard B2C Commerce WebDAV root directories. */ +export const WEBDAV_ROOTS: {key: string; path: string}[] = [ + {key: 'Impex', path: 'Impex'}, + {key: 'Temp', path: 'Temp'}, + {key: 'Cartridges', path: 'Cartridges'}, + {key: 'Realmdata', path: 'Realmdata'}, + {key: 'Catalogs', path: 'Catalogs'}, + {key: 'Libraries', path: 'Libraries'}, + {key: 'Static', path: 'Static'}, + {key: 'Logs', path: 'Logs'}, + {key: 'Securitylogs', path: 'Securitylogs'}, +]; + +const CACHE_TTL_MS = 30_000; + +const MIME_BY_EXT: Record = { + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.html': 'text/html', + '.css': 'text/css', + '.txt': 'text/plain', +}; + +interface CachedStat { + stat: vscode.FileStat; + timestamp: number; +} + +interface CachedDir { + entries: [string, vscode.FileType][]; + timestamp: number; +} + +/** Convert a b2c-webdav URI to a WebDAV path (strip leading slash). */ +function uriToWebdavPath(uri: vscode.Uri): string { + return uri.path.replace(/^\//, ''); +} + +/** Build a b2c-webdav URI from a WebDAV path. */ +export function webdavPathToUri(webdavPath: string): vscode.Uri { + return vscode.Uri.parse(`${WEBDAV_SCHEME}:/${webdavPath}`); +} + +function isStale(timestamp: number): boolean { + return Date.now() - timestamp > CACHE_TTL_MS; +} + +function mapHttpError(err: unknown, uri: vscode.Uri): vscode.FileSystemError { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('404') || message.includes('Not Found')) { + return vscode.FileSystemError.FileNotFound(uri); + } + if ( + message.includes('401') || + message.includes('403') || + message.includes('Unauthorized') || + message.includes('Forbidden') + ) { + return vscode.FileSystemError.NoPermissions(uri); + } + return vscode.FileSystemError.Unavailable(message); +} + +export class WebDavFileSystemProvider implements vscode.FileSystemProvider { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private statCache = new Map(); + private dirCache = new Map(); + + constructor(private configProvider: WebDavConfigProvider) {} + + watch(): vscode.Disposable { + // WebDAV has no push notifications β€” return no-op disposable. + return new vscode.Disposable(() => {}); + } + + async stat(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + + // Synthetic root directory β€” avoids PROPFIND on "/" + if (!webdavPath) { + return {type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0}; + } + + const cached = this.statCache.get(webdavPath); + if (cached && !isStale(cached.timestamp)) { + return cached.stat; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const entries = await instance.webdav.propfind(webdavPath, '0'); + if (!entries.length) { + throw vscode.FileSystemError.FileNotFound(uri); + } + const entry = entries[0]; + const mtime = entry.lastModified ? entry.lastModified.getTime() : 0; + const fileStat: vscode.FileStat = { + type: entry.isCollection ? vscode.FileType.Directory : vscode.FileType.File, + ctime: mtime, + mtime, + size: entry.contentLength ?? 0, + }; + this.statCache.set(webdavPath, {stat: fileStat, timestamp: Date.now()}); + return fileStat; + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const webdavPath = uriToWebdavPath(uri); + + // Synthetic root listing β€” return the well-known roots + if (!webdavPath) { + return WEBDAV_ROOTS.map((r) => [r.key, vscode.FileType.Directory] as [string, vscode.FileType]); + } + + const cached = this.dirCache.get(webdavPath); + if (cached && !isStale(cached.timestamp)) { + return cached.entries; + } + + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const allEntries = await instance.webdav.propfind(webdavPath, '1'); + + // Filter out the self-entry + const normalizedPath = webdavPath.replace(/\/$/, ''); + const children = allEntries.filter((entry) => { + const entryPath = decodeURIComponent(entry.href); + return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + }); + + const now = Date.now(); + const result: [string, vscode.FileType][] = []; + + for (const entry of children) { + const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; + const childPath = `${webdavPath}/${displayName}`; + const fileType = entry.isCollection ? vscode.FileType.Directory : vscode.FileType.File; + const mtime = entry.lastModified ? entry.lastModified.getTime() : 0; + + // Populate stat cache for each child + this.statCache.set(childPath, { + stat: { + type: fileType, + ctime: mtime, + mtime, + size: entry.contentLength ?? 0, + }, + timestamp: now, + }); + + result.push([displayName, fileType]); + } + + this.dirCache.set(webdavPath, {entries: result, timestamp: now}); + return result; + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async readFile(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const buffer = await instance.webdav.get(webdavPath); + return new Uint8Array(buffer); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: {create: boolean; overwrite: boolean}, + ): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + const ext = path.extname(webdavPath).toLowerCase(); + const contentType = MIME_BY_EXT[ext]; + await instance.webdav.put(webdavPath, Buffer.from(content), contentType); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Changed, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async createDirectory(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + await instance.webdav.mkcol(webdavPath); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Created, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + async delete(uri: vscode.Uri): Promise { + const webdavPath = uriToWebdavPath(uri); + const instance = this.configProvider.getInstance(); + if (!instance) { + throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); + } + + try { + await instance.webdav.delete(webdavPath); + this.clearCache(webdavPath); + this.fireDid(vscode.FileChangeType.Deleted, uri); + } catch (err) { + if (err instanceof vscode.FileSystemError) throw err; + throw mapHttpError(err, uri); + } + } + + rename(): never { + throw vscode.FileSystemError.NoPermissions('Rename not supported'); + } + + /** Clear cached data for a path and its parent directory. If no path, clear everything. */ + clearCache(webdavPath?: string): void { + if (!webdavPath) { + this.statCache.clear(); + this.dirCache.clear(); + return; + } + this.statCache.delete(webdavPath); + this.dirCache.delete(webdavPath); + // Also invalidate parent + const parentPath = webdavPath.includes('/') ? webdavPath.substring(0, webdavPath.lastIndexOf('/')) : ''; + if (parentPath) { + this.statCache.delete(parentPath); + this.dirCache.delete(parentPath); + } + } + + private fireDid(type: vscode.FileChangeType, uri: vscode.Uri): void { + this._onDidChangeFile.fire([{type, uri}]); + } +} diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts index e863d9d6..3dc9b18f 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts @@ -5,19 +5,7 @@ */ import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; - -/** Standard B2C Commerce WebDAV root directories. */ -const WEBDAV_ROOTS: {key: string; path: string}[] = [ - {key: 'Impex', path: 'Impex'}, - {key: 'Temp', path: 'Temp'}, - {key: 'Cartridges', path: 'Cartridges'}, - {key: 'Realmdata', path: 'Realmdata'}, - {key: 'Catalogs', path: 'Catalogs'}, - {key: 'Libraries', path: 'Libraries'}, - {key: 'Static', path: 'Static'}, - {key: 'Logs', path: 'Logs'}, - {key: 'Securitylogs', path: 'Securitylogs'}, -]; +import {type WebDavFileSystemProvider, WEBDAV_ROOTS, webdavPathToUri} from './webdav-fs-provider.js'; function formatFileSize(bytes: number | undefined): string { if (bytes === undefined || bytes === null) return ''; @@ -44,20 +32,21 @@ export class WebDavTreeItem extends vscode.TreeItem { this.contextValue = nodeType; this.tooltip = webdavPath; + const resourceUri = webdavPathToUri(webdavPath); + if (nodeType === 'root') { - this.iconPath = new vscode.ThemeIcon('database'); + this.resourceUri = resourceUri; } else if (nodeType === 'directory') { - this.iconPath = vscode.ThemeIcon.Folder; + this.resourceUri = resourceUri; } else { - this.iconPath = vscode.ThemeIcon.File; - this.resourceUri = vscode.Uri.parse(`webdav://b2c/${webdavPath}`); + this.resourceUri = resourceUri; if (contentLength !== undefined) { this.description = formatFileSize(contentLength); } this.command = { - command: 'b2c-dx.webdav.openFile', + command: 'vscode.open', title: 'Open File', - arguments: [this], + arguments: [resourceUri], }; } } @@ -67,20 +56,21 @@ export class WebDavTreeDataProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private childrenCache = new Map(); - - constructor(private configProvider: WebDavConfigProvider) {} + constructor( + private configProvider: WebDavConfigProvider, + private fsProvider: WebDavFileSystemProvider, + ) { + // Auto-refresh the tree when the FS provider fires change events + this.fsProvider.onDidChangeFile(() => { + this._onDidChangeTreeData.fire(); + }); + } refresh(): void { - this.childrenCache.clear(); + this.fsProvider.clearCache(); this._onDidChangeTreeData.fire(); } - refreshNode(node: WebDavTreeItem): void { - this.childrenCache.delete(node.webdavPath); - this._onDidChangeTreeData.fire(node); - } - getTreeItem(element: WebDavTreeItem): vscode.TreeItem { return element; } @@ -94,39 +84,37 @@ export class WebDavTreeDataProvider implements vscode.TreeDataProvider new WebDavTreeItem('root', r.path, r.key, true)); } - const cached = this.childrenCache.get(element.webdavPath); - if (cached) { - return cached; - } + try { + const uri = webdavPathToUri(element.webdavPath); + const entries = await this.fsProvider.readDirectory(uri); + + const children: WebDavTreeItem[] = []; + for (const [name, fileType] of entries) { + const childPath = `${element.webdavPath}/${name}`; + const isCollection = fileType === vscode.FileType.Directory; + const nodeType = isCollection ? 'directory' : 'file'; + + let contentLength: number | undefined; + if (!isCollection) { + try { + const childStat = await this.fsProvider.stat(webdavPathToUri(childPath)); + contentLength = childStat.size; + } catch { + // Stat may fail β€” show item without size + } + } - const instance = this.configProvider.getInstance(); - if (!instance) { - return []; - } + children.push(new WebDavTreeItem(nodeType, childPath, name, isCollection, contentLength)); + } - try { - const entries = await instance.webdav.propfind(element.webdavPath, '1'); - const normalizedPath = element.webdavPath.replace(/\/$/, ''); - const filtered = entries.filter((entry) => { - const entryPath = decodeURIComponent(entry.href); - return !entryPath.endsWith(`/${normalizedPath}`) && !entryPath.endsWith(`/${normalizedPath}/`); + // Sort: directories first, then alphabetical + children.sort((a, b) => { + if (a.isCollection !== b.isCollection) { + return a.isCollection ? -1 : 1; + } + return a.fileName.localeCompare(b.fileName); }); - const children = filtered - .map((entry) => { - const displayName = entry.displayName ?? entry.href.split('/').filter(Boolean).at(-1) ?? entry.href; - const childPath = `${element.webdavPath}/${displayName}`; - const nodeType = entry.isCollection ? 'directory' : 'file'; - return new WebDavTreeItem(nodeType, childPath, displayName, entry.isCollection, entry.contentLength); - }) - .sort((a, b) => { - if (a.isCollection !== b.isCollection) { - return a.isCollection ? -1 : 1; - } - return a.fileName.localeCompare(b.fileName); - }); - - this.childrenCache.set(element.webdavPath, children); return children; } catch (err) { const message = err instanceof Error ? err.message : String(err); From 26087556319e68540e92c6f1b52dd2cc60c634fe Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 20 Feb 2026 18:11:21 -0500 Subject: [PATCH 12/16] Mount individual folders instead of entire WebDAV root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change "Open as Workspace Folder" to operate on the right-clicked node (root or directory) rather than mounting b2c-webdav:/ globally. The workspace folder is named "WebDAV: " for clarity. Move mount command from view title bar to context menu. Remove unmountWorkspace command and mounted context key tracking β€” VS Code's native "Remove Folder from Workspace" handles unmounting. --- packages/b2c-vs-extension/package.json | 21 +++++-------------- .../b2c-vs-extension/src/webdav-tree/index.ts | 11 +--------- .../src/webdav-tree/webdav-commands.ts | 20 +++++++----------- 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index acc1b4a8..8aad31f5 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -132,12 +132,6 @@ "title": "Open as Workspace Folder", "icon": "$(root-folder-opened)", "category": "B2C DX" - }, - { - "command": "b2c-dx.webdav.unmountWorkspace", - "title": "Remove Workspace Folder", - "icon": "$(root-folder)", - "category": "B2C DX" } ], "menus": { @@ -146,16 +140,6 @@ "command": "b2c-dx.webdav.refresh", "when": "view == b2cWebdavExplorer", "group": "navigation" - }, - { - "command": "b2c-dx.webdav.mountWorkspace", - "when": "view == b2cWebdavExplorer && !b2c-dx.webdav.mounted", - "group": "navigation" - }, - { - "command": "b2c-dx.webdav.unmountWorkspace", - "when": "view == b2cWebdavExplorer && b2c-dx.webdav.mounted", - "group": "navigation" } ], "view/item/context": [ @@ -188,6 +172,11 @@ "command": "b2c-dx.webdav.delete", "when": "view == b2cWebdavExplorer && viewItem =~ /^(directory|file)$/", "group": "2_destructive@1" + }, + { + "command": "b2c-dx.webdav.mountWorkspace", + "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/", + "group": "3_workspace@1" } ], "explorer/context": [ diff --git a/packages/b2c-vs-extension/src/webdav-tree/index.ts b/packages/b2c-vs-extension/src/webdav-tree/index.ts index 41fb5178..72fde9fd 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/index.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts @@ -9,11 +9,6 @@ import {WEBDAV_SCHEME, WebDavFileSystemProvider} from './webdav-fs-provider.js'; import {WebDavTreeDataProvider} from './webdav-tree-provider.js'; import {registerWebDavCommands} from './webdav-commands.js'; -function syncMountedContext(): void { - const mounted = (vscode.workspace.workspaceFolders ?? []).some((f) => f.uri.scheme === WEBDAV_SCHEME); - vscode.commands.executeCommand('setContext', 'b2c-dx.webdav.mounted', mounted); -} - export function registerWebDavTree(context: vscode.ExtensionContext): void { const configProvider = new WebDavConfigProvider(); const fsProvider = new WebDavFileSystemProvider(configProvider); @@ -31,9 +26,5 @@ export function registerWebDavTree(context: vscode.ExtensionContext): void { const commandDisposables = registerWebDavCommands(context, configProvider, treeProvider, fsProvider); - // Sync the mounted context key on activation and when workspace folders change - syncMountedContext(); - const folderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => syncMountedContext()); - - context.subscriptions.push(fsRegistration, treeView, folderWatcher, ...commandDisposables); + context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 261b1bc5..4630ef96 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import type {WebDavConfigProvider} from './webdav-config.js'; -import {type WebDavFileSystemProvider, WEBDAV_SCHEME, webdavPathToUri} from './webdav-fs-provider.js'; +import {type WebDavFileSystemProvider, webdavPathToUri} from './webdav-fs-provider.js'; import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js'; export function registerWebDavCommands( @@ -176,20 +176,14 @@ export function registerWebDavCommands( ); }); - const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', () => { + const mountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.mountWorkspace', (node: WebDavTreeItem) => { + if (!node) return; + const uri = webdavPathToUri(node.webdavPath); vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length ?? 0, 0, { - uri: vscode.Uri.parse(`${WEBDAV_SCHEME}:/`), - name: 'B2C Commerce WebDAV', + uri, + name: `WebDAV: ${node.webdavPath}`, }); }); - const unmountWorkspace = vscode.commands.registerCommand('b2c-dx.webdav.unmountWorkspace', () => { - const folders = vscode.workspace.workspaceFolders ?? []; - const idx = folders.findIndex((f) => f.uri.scheme === WEBDAV_SCHEME); - if (idx >= 0) { - vscode.workspace.updateWorkspaceFolders(idx, 1); - } - }); - - return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace, unmountWorkspace]; + return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace]; } From 996d73c902f0e4072e09a59f34043bf8ddc0289c Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 21 Feb 2026 22:13:54 -0500 Subject: [PATCH 13/16] fix config resolution for content tree --- .../src/content-tree/content-config.ts | 59 ++++--------------- .../src/content-tree/index.ts | 11 ++-- packages/b2c-vs-extension/src/extension.ts | 2 +- 3 files changed, 17 insertions(+), 55 deletions(-) diff --git a/packages/b2c-vs-extension/src/content-tree/content-config.ts b/packages/b2c-vs-extension/src/content-tree/content-config.ts index d7fb66cd..367c7ac4 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-config.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-config.ts @@ -4,11 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import {findDwJson, resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import type {Library} from '@salesforce/b2c-tooling-sdk/operations/content'; -import * as fs from 'fs'; -import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; export interface BrowsedLibrary { id: string; @@ -16,32 +14,25 @@ export interface BrowsedLibrary { } export class ContentConfigProvider { - private instance: B2CInstance | null = null; - private configError: string | null = null; - private resolved = false; private libraries: BrowsedLibrary[] = []; private libraryCache = new Map(); - private contentLibrary: string | undefined; + + constructor(private readonly configProvider: B2CExtensionConfig) { + configProvider.onDidReset(() => { + this.libraryCache.clear(); + }); + } getInstance(): B2CInstance | null { - if (!this.resolved) { - this.resolve(); - } - return this.instance; + return this.configProvider.getInstance(); } getConfigError(): string | null { - if (!this.resolved) { - this.resolve(); - } - return this.configError; + return this.configProvider.getConfigError(); } getContentLibrary(): string | undefined { - if (!this.resolved) { - this.resolve(); - } - return this.contentLibrary; + return this.configProvider.getConfig()?.values.contentLibrary; } getLibraries(): BrowsedLibrary[] { @@ -76,36 +67,6 @@ export class ContentConfigProvider { } reset(): void { - this.instance = null; - this.configError = null; - this.resolved = false; this.libraryCache.clear(); } - - private resolve(): void { - this.resolved = true; - try { - let workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - if (!workingDirectory || workingDirectory === '/' || !fs.existsSync(workingDirectory)) { - workingDirectory = ''; - } - const dwPath = workingDirectory ? findDwJson(workingDirectory) : undefined; - const config = dwPath ? resolveConfig({}, {configPath: dwPath}) : resolveConfig({}, {workingDirectory}); - - this.contentLibrary = config.values.contentLibrary; - - if (!config.hasB2CInstanceConfig()) { - this.configError = 'No B2C Commerce instance configured.'; - this.instance = null; - return; - } - - this.instance = config.createB2CInstance(); - this.configError = null; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.configError = message; - this.instance = null; - } - } } diff --git a/packages/b2c-vs-extension/src/content-tree/index.ts b/packages/b2c-vs-extension/src/content-tree/index.ts index b95002ba..5d04b11d 100644 --- a/packages/b2c-vs-extension/src/content-tree/index.ts +++ b/packages/b2c-vs-extension/src/content-tree/index.ts @@ -5,21 +5,22 @@ */ import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; import {ContentConfigProvider} from './content-config.js'; import {CONTENT_SCHEME, ContentFileSystemProvider} from './content-fs-provider.js'; import {ContentTreeDataProvider} from './content-tree-provider.js'; import {registerContentCommands} from './content-commands.js'; -export function registerContentTree(context: vscode.ExtensionContext): void { - const configProvider = new ContentConfigProvider(); - const fsProvider = new ContentFileSystemProvider(configProvider); +export function registerContentTree(context: vscode.ExtensionContext, configProvider: B2CExtensionConfig): void { + const contentConfig = new ContentConfigProvider(configProvider); + const fsProvider = new ContentFileSystemProvider(contentConfig); const fsRegistration = vscode.workspace.registerFileSystemProvider(CONTENT_SCHEME, fsProvider, { isCaseSensitive: true, isReadonly: false, }); - const treeProvider = new ContentTreeDataProvider(configProvider); + const treeProvider = new ContentTreeDataProvider(contentConfig); const treeView = vscode.window.createTreeView('b2cContentExplorer', { treeDataProvider: treeProvider, @@ -32,7 +33,7 @@ export function registerContentTree(context: vscode.ExtensionContext): void { treeView.description = filter ? `filter: ${filter}` : undefined; }); - const commandDisposables = registerContentCommands(context, configProvider, treeProvider, fsProvider); + const commandDisposables = registerContentCommands(context, contentConfig, treeProvider, fsProvider); context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 61a4ff91..92090ae6 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -1166,7 +1166,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu ); registerWebDavTree(context, configProvider); - registerContentTree(context); + registerContentTree(context, configProvider); context.subscriptions.push( disposable, From 46f3b42ad79d2edcf8bbc43a0e4153cad2986b8a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 21 Feb 2026 23:06:28 -0500 Subject: [PATCH 14/16] fix review findings: config reset, writeFile flags, archive stripping, plugin opts - Refresh content tree on config reset (matching webdav-tree pattern) - Honour create/overwrite flags in WebDAV writeFile per VS Code contract - Strip single existing top-level root in ensureArchiveStructure to avoid double-nesting when re-wrapping site archives - Add accountManagerHost to PluginHookOptions and resolveOptions - Use Uri.from instead of Uri.parse for correct special-char handling - Guard applyMiddleware against duplicate registration --- .../src/operations/jobs/site-archive.ts | 8 ++- .../b2c-tooling-sdk/src/plugins/manager.ts | 7 +++ .../test/operations/jobs/site-archive.test.ts | 57 +++++++++++++++++++ .../src/content-tree/content-fs-provider.ts | 2 +- .../src/content-tree/index.ts | 5 ++ .../src/webdav-tree/webdav-fs-provider.ts | 23 ++++++-- 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index 28a6899f..ddfcd2c7 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -276,13 +276,19 @@ async function ensureArchiveStructure( `Re-wrapping archive contents under ${archiveDirName}/`, ); + // When a single top-level directory exists with a different name, strip it + // to avoid nesting (e.g. newRoot/oldRoot/...). + const stripPrefix = topLevelEntries.size === 1 ? [...topLevelEntries][0] + '/' : undefined; + const newZip = new JSZip(); const rootFolder = newZip.folder(archiveDirName)!; for (const [filePath, entry] of Object.entries(zip.files)) { if (!entry.dir) { const content = await entry.async('nodebuffer'); - rootFolder.file(filePath, content); + const adjustedPath = + stripPrefix && filePath.startsWith(stripPrefix) ? filePath.slice(stripPrefix.length) : filePath; + rootFolder.file(adjustedPath, content); } } diff --git a/packages/b2c-tooling-sdk/src/plugins/manager.ts b/packages/b2c-tooling-sdk/src/plugins/manager.ts index f0ec8ab4..404a58b6 100644 --- a/packages/b2c-tooling-sdk/src/plugins/manager.ts +++ b/packages/b2c-tooling-sdk/src/plugins/manager.ts @@ -25,6 +25,8 @@ export interface PluginHookOptions { instance?: string; /** Explicit config file path (if known) */ configPath?: string; + /** Account Manager host override */ + accountManagerHost?: string; /** CLI flags or equivalent options */ flags?: Record; } @@ -38,6 +40,7 @@ export interface PluginHookOptions { */ export class B2CPluginManager { private _initialized = false; + private _middlewareApplied = false; private _pluginNames: string[] = []; private _sourcesBefore: ConfigSource[] = []; private _sourcesAfter: ConfigSource[] = []; @@ -84,6 +87,7 @@ export class B2CPluginManager { resolveOptions: { instance: hookOptions?.instance, configPath: hookOptions?.configPath, + accountManagerHost: hookOptions?.accountManagerHost, }, }; const result = await invokeHook(absPath, context, options, this.logger); @@ -173,6 +177,9 @@ export class B2CPluginManager { * Registers collected middleware providers with the global registries. */ applyMiddleware(): void { + if (this._middlewareApplied) return; + this._middlewareApplied = true; + for (const provider of this._httpMiddleware) { globalMiddlewareRegistry.register(provider); this.logger?.debug(`Registered HTTP middleware provider: ${provider.name}`); diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts index 1d0942ff..e04dd7bf 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts @@ -14,6 +14,7 @@ import * as path from 'node:path'; import {WebDavClient} from '../../../src/clients/webdav.js'; import {createOcapiClient} from '../../../src/clients/ocapi.js'; import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import JSZip from 'jszip'; import { siteArchiveImport, siteArchiveExport, @@ -268,6 +269,62 @@ describe('operations/jobs/site-archive', () => { expect(deleteRequested).to.be.false; }); + it('should strip single existing top-level root when re-wrapping archive', async () => { + // Create a zip with a different top-level directory name + const srcZip = new JSZip(); + srcZip.file('oldRoot/meta/system-objecttype-extensions.xml', ''); + srcZip.file('oldRoot/sites/RefArch/site.xml', ''); + const zipBuffer = await srcZip.generateAsync({type: 'nodebuffer'}); + + let uploadedZip: Buffer | null = null; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + if (request.method === 'DELETE') { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-rewrap', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-rewrap`, () => { + return HttpResponse.json({ + id: 'exec-rewrap', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + ); + + await siteArchiveImport(mockInstance, zipBuffer, { + archiveName: 'my-import', + waitOptions: FAST_WAIT_OPTIONS, + }); + + expect(uploadedZip).to.not.be.null; + + // Verify the uploaded archive has the correct structure: + // my-import/meta/... and my-import/sites/... (not my-import/oldRoot/...) + const resultZip = await JSZip.loadAsync(uploadedZip!); + const paths = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir); + expect(paths).to.include('my-import/meta/system-objecttype-extensions.xml'); + expect(paths).to.include('my-import/sites/RefArch/site.xml'); + // Ensure the old root was stripped + const hasOldRoot = paths.some((p) => p.includes('oldRoot')); + expect(hasOldRoot).to.be.false; + }); + it('should throw error when archiveName is missing for Buffer', async () => { const zipBuffer = Buffer.from('PK\x03\x04test-data'); diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts index 4a9273e6..0abe4b89 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -37,7 +37,7 @@ function parseContentUri(uri: vscode.Uri): ParsedContentUri { export function contentItemUri(libraryId: string, isSiteLibrary: boolean, contentId: string): vscode.Uri { const uriPath = isSiteLibrary ? `/site/${libraryId}/${contentId}.xml` : `/${libraryId}/${contentId}.xml`; - return vscode.Uri.parse(`${CONTENT_SCHEME}:${uriPath}`); + return vscode.Uri.from({scheme: CONTENT_SCHEME, path: uriPath}); } /** diff --git a/packages/b2c-vs-extension/src/content-tree/index.ts b/packages/b2c-vs-extension/src/content-tree/index.ts index 5d04b11d..289fd0e1 100644 --- a/packages/b2c-vs-extension/src/content-tree/index.ts +++ b/packages/b2c-vs-extension/src/content-tree/index.ts @@ -35,5 +35,10 @@ export function registerContentTree(context: vscode.ExtensionContext, configProv const commandDisposables = registerContentCommands(context, contentConfig, treeProvider, fsProvider); + configProvider.onDidReset(() => { + contentConfig.clearCache(); + treeProvider.refresh(); + }); + context.subscriptions.push(fsRegistration, treeView, ...commandDisposables); } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts index da32f2db..12260a97 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts @@ -52,7 +52,7 @@ function uriToWebdavPath(uri: vscode.Uri): string { /** Build a b2c-webdav URI from a WebDAV path. */ export function webdavPathToUri(webdavPath: string): vscode.Uri { - return vscode.Uri.parse(`${WEBDAV_SCHEME}:/${webdavPath}`); + return vscode.Uri.from({scheme: WEBDAV_SCHEME, path: `/${webdavPath}`}); } function isStale(timestamp: number): boolean { @@ -203,17 +203,28 @@ export class WebDavFileSystemProvider implements vscode.FileSystemProvider { } } - async writeFile( - uri: vscode.Uri, - content: Uint8Array, - _options: {create: boolean; overwrite: boolean}, - ): Promise { + async writeFile(uri: vscode.Uri, content: Uint8Array, options: {create: boolean; overwrite: boolean}): Promise { const webdavPath = uriToWebdavPath(uri); const instance = this.configProvider.getInstance(); if (!instance) { throw vscode.FileSystemError.Unavailable('No B2C Commerce instance configured'); } + // Honour create/overwrite flags per VS Code FileSystemProvider contract + let exists = false; + try { + await this.stat(uri); + exists = true; + } catch { + // stat throws FileNotFound when file doesn't exist + } + if (exists && !options.overwrite) { + throw vscode.FileSystemError.FileExists(uri); + } + if (!exists && !options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + try { const ext = path.extname(webdavPath).toLowerCase(); const contentType = MIME_BY_EXT[ext]; From 2f5822add35ea42faa139c0128d0c4e6517ed20e Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 23 Feb 2026 13:03:17 -0500 Subject: [PATCH 15/16] bug fixing import zip conventions and adding multi-select --- .../src/operations/jobs/site-archive.ts | 86 +++++++++---------- .../test/operations/jobs/site-archive.test.ts | 67 ++++++++------- .../src/content-tree/content-commands.ts | 52 +++++++---- .../src/content-tree/content-fs-provider.ts | 12 ++- .../src/content-tree/index.ts | 1 + 5 files changed, 122 insertions(+), 96 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index ddfcd2c7..e3720bad 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -50,6 +50,14 @@ export interface SiteArchiveImportResult { * - A Buffer containing zip data * - A filename already on the instance (in Impex/src/instance/) * + * **Buffer handling:** When passing a Buffer, the `archiveName` option controls + * the contract: + * - **Without `archiveName`:** The buffer should contain archive entries without + * a root directory (e.g. `libraries/mylib/library.xml`). The SDK generates + * an archive name and wraps the contents under it. + * - **With `archiveName`:** The buffer must already be correctly structured with + * `archiveName/` as the top-level directory. It is uploaded as-is. + * * @param instance - B2C instance to import to * @param target - Source to import (directory path, zip file path, Buffer, or remote filename) * @param options - Import options @@ -64,9 +72,17 @@ export interface SiteArchiveImportResult { * // Import from a zip file * const result = await siteArchiveImport(instance, './export.zip'); * - * // Import from a buffer - * const zipBuffer = await fs.promises.readFile('./export.zip'); - * const result = await siteArchiveImport(instance, zipBuffer, { + * // Import from a buffer (SDK wraps contents automatically) + * const zip = new JSZip(); + * zip.file('libraries/mylib/library.xml', xmlContent); + * const buffer = await zip.generateAsync({type: 'nodebuffer'}); + * const result = await siteArchiveImport(instance, buffer); + * + * // Import from a buffer with explicit archive name (caller owns structure) + * const zip = new JSZip(); + * zip.file('my-import/libraries/mylib/library.xml', xmlContent); + * const buffer = await zip.generateAsync({type: 'nodebuffer'}); + * const result = await siteArchiveImport(instance, buffer, { * archiveName: 'my-import' * }); * @@ -94,13 +110,20 @@ export async function siteArchiveImport( zipFilename = target.remoteFilename; needsUpload = false; } else if (Buffer.isBuffer(target)) { - // Buffer - use provided archive name - if (!archiveName) { - throw new Error('archiveName is required when importing from a Buffer'); + if (archiveName) { + // Caller provides name β€” buffer must already contain the correct + // top-level directory structure (archiveName/...). + const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName; + zipFilename = `${baseName}.zip`; + archiveContent = target; + } else { + // No name β€” SDK generates one and wraps the buffer contents under it. + // The buffer should contain archive entries without a root directory + // (e.g. libraries/mylib/library.xml, sites/RefArch/site.xml). + const archiveDirName = `import-${Date.now()}`; + zipFilename = `${archiveDirName}.zip`; + archiveContent = await wrapArchiveContents(target, archiveDirName, logger); } - const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName; - zipFilename = `${baseName}.zip`; - archiveContent = await ensureArchiveStructure(target, baseName, logger); } else { // File path - check if directory or zip file const targetPath = target as string; @@ -238,47 +261,20 @@ async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise, ): Promise { - let zip: JSZip; - try { - zip = await JSZip.loadAsync(buffer); - } catch { - // If we can't parse the zip, pass it through as-is - logger.debug('Could not parse zip buffer for structure check; passing through as-is'); - return buffer; - } - - // Determine the unique top-level directory names - const topLevelEntries = new Set(); - for (const filePath of Object.keys(zip.files)) { - const topLevel = filePath.split('/')[0]; - topLevelEntries.add(topLevel); - } - - if (topLevelEntries.size === 1 && topLevelEntries.has(archiveDirName)) { - return buffer; // Already correctly structured - } - - // Re-wrap all entries under archiveDirName/ - logger.debug( - {archiveDirName, topLevelEntries: [...topLevelEntries]}, - `Re-wrapping archive contents under ${archiveDirName}/`, - ); + const zip = await JSZip.loadAsync(buffer); - // When a single top-level directory exists with a different name, strip it - // to avoid nesting (e.g. newRoot/oldRoot/...). - const stripPrefix = topLevelEntries.size === 1 ? [...topLevelEntries][0] + '/' : undefined; + logger.debug({archiveDirName}, `Wrapping archive contents under ${archiveDirName}/`); const newZip = new JSZip(); const rootFolder = newZip.folder(archiveDirName)!; @@ -286,9 +282,7 @@ async function ensureArchiveStructure( for (const [filePath, entry] of Object.entries(zip.files)) { if (!entry.dir) { const content = await entry.async('nodebuffer'); - const adjustedPath = - stripPrefix && filePath.startsWith(stripPrefix) ? filePath.slice(stripPrefix.length) : filePath; - rootFolder.file(adjustedPath, content); + rootFolder.file(filePath, content); } } diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts index e04dd7bf..ed2811d3 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts @@ -166,12 +166,25 @@ describe('operations/jobs/site-archive', () => { expect(uploadedZip).to.not.be.null; }); - it('should import from a Buffer', async () => { - const zipBuffer = Buffer.from('PK\x03\x04test-data'); + it('should import from a Buffer with archiveName (caller owns structure)', async () => { + // When archiveName is provided, the buffer is used as-is + const srcZip = new JSZip(); + srcZip.file('buffer-import/libraries/mylib/library.xml', ''); + const zipBuffer = await srcZip.generateAsync({type: 'nodebuffer'}); + + let uploadedZip: Buffer | null = null; server.use( - http.all(`${WEBDAV_BASE}/*`, async () => { - return new HttpResponse(null, {status: 201}); + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + if (request.method === 'DELETE') { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); }), http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { return HttpResponse.json({ @@ -196,7 +209,12 @@ describe('operations/jobs/site-archive', () => { }); expect(result.execution.id).to.equal('exec-3'); - expect(result.archiveFilename).to.include('buffer-import'); + expect(result.archiveFilename).to.equal('buffer-import.zip'); + + // Buffer should be passed through as-is (no re-wrapping) + const resultZip = await JSZip.loadAsync(uploadedZip!); + const paths = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir); + expect(paths).to.include('buffer-import/libraries/mylib/library.xml'); }); it('should import from remote filename', async () => { @@ -269,11 +287,10 @@ describe('operations/jobs/site-archive', () => { expect(deleteRequested).to.be.false; }); - it('should strip single existing top-level root when re-wrapping archive', async () => { - // Create a zip with a different top-level directory name + it('should auto-wrap buffer contents when archiveName is omitted', async () => { + // Create a zip without a root directory (like the content FS provider does) const srcZip = new JSZip(); - srcZip.file('oldRoot/meta/system-objecttype-extensions.xml', ''); - srcZip.file('oldRoot/sites/RefArch/site.xml', ''); + srcZip.file('libraries/mylib/library.xml', ''); const zipBuffer = await srcZip.generateAsync({type: 'nodebuffer'}); let uploadedZip: Buffer | null = null; @@ -292,14 +309,14 @@ describe('operations/jobs/site-archive', () => { }), http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { return HttpResponse.json({ - id: 'exec-rewrap', + id: 'exec-wrap', execution_status: 'finished', exit_status: {code: 'OK'}, }); }), - http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-rewrap`, () => { + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-wrap`, () => { return HttpResponse.json({ - id: 'exec-rewrap', + id: 'exec-wrap', execution_status: 'finished', exit_status: {code: 'OK'}, is_log_file_existing: false, @@ -307,33 +324,19 @@ describe('operations/jobs/site-archive', () => { }), ); - await siteArchiveImport(mockInstance, zipBuffer, { - archiveName: 'my-import', + const result = await siteArchiveImport(mockInstance, zipBuffer, { waitOptions: FAST_WAIT_OPTIONS, }); + // SDK should auto-generate an import-{timestamp} archive name + expect(result.archiveFilename).to.match(/^import-\d+\.zip$/); expect(uploadedZip).to.not.be.null; - // Verify the uploaded archive has the correct structure: - // my-import/meta/... and my-import/sites/... (not my-import/oldRoot/...) + // Contents must be wrapped under the generated root directory const resultZip = await JSZip.loadAsync(uploadedZip!); const paths = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir); - expect(paths).to.include('my-import/meta/system-objecttype-extensions.xml'); - expect(paths).to.include('my-import/sites/RefArch/site.xml'); - // Ensure the old root was stripped - const hasOldRoot = paths.some((p) => p.includes('oldRoot')); - expect(hasOldRoot).to.be.false; - }); - - it('should throw error when archiveName is missing for Buffer', async () => { - const zipBuffer = Buffer.from('PK\x03\x04test-data'); - - try { - await siteArchiveImport(mockInstance, zipBuffer); - expect.fail('Should have thrown error'); - } catch (error: any) { - expect(error.message).to.include('archiveName is required'); - } + const archiveRoot = result.archiveFilename.replace(/\.zip$/, ''); + expect(paths).to.include(`${archiveRoot}/libraries/mylib/library.xml`); }); it('should throw JobExecutionError when import fails', async () => { diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index f507ca1f..ee9a3ec6 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -69,7 +69,7 @@ export function registerContentCommands( }); async function runExport( - node: ContentTreeItem, + nodes: ContentTreeItem[], {offline, assetsOnly}: {offline: boolean; assetsOnly: boolean}, ): Promise { const instance = configProvider.getInstance(); @@ -78,6 +78,16 @@ export function registerContentCommands( return; } + // All selected nodes must be from the same library + const libraryId = nodes[0].libraryId; + const isSiteLibrary = nodes[0].isSiteLibrary; + if (nodes.some((n) => n.libraryId !== libraryId)) { + vscode.window.showErrorMessage('Cannot export content from different libraries at the same time.'); + return; + } + + const contentIds = nodes.map((n) => n.contentId); + const dialogTitle = assetsOnly ? 'Select directory for static assets' : 'Select export directory'; const folders = await vscode.window.showOpenDialog({ title: dialogTitle, @@ -90,15 +100,16 @@ export function registerContentCommands( const outputPath = folders[0].fsPath; const label = assetsOnly ? 'static assets for' : offline ? '(without assets)' : ''; - const progressTitle = `Exporting ${label ? `${label} ` : ''}${node.contentId}...`; + const itemLabel = contentIds.length === 1 ? contentIds[0] : `${contentIds.length} items`; + const progressTitle = `Exporting ${label ? `${label} ` : ''}${itemLabel}...`; let result; try { result = await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false}, async (progress) => { - return exportContent(instance, [node.contentId], node.libraryId, outputPath, { - isSiteLibrary: node.isSiteLibrary, + return exportContent(instance, contentIds, libraryId, outputPath, { + isSiteLibrary, offline, onAssetProgress: (_asset, index, total) => { progress.report({ @@ -118,7 +129,7 @@ export function registerContentCommands( let msg: string; if (assetsOnly) { if (result.downloadedAssets.length === 0) { - vscode.window.showInformationMessage('No static assets found for this content item.'); + vscode.window.showInformationMessage('No static assets found for the selected content.'); return; } msg = `Downloaded ${result.downloadedAssets.length} static asset(s)`; @@ -143,23 +154,32 @@ export function registerContentCommands( } } - const exportCmd = vscode.commands.registerCommand('b2c-dx.content.export', async (node: ContentTreeItem) => { - if (!node) return; - await runExport(node, {offline: false, assetsOnly: false}); - }); + const exportCmd = vscode.commands.registerCommand( + 'b2c-dx.content.export', + async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => { + const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : []; + if (!nodes.length) return; + await runExport(nodes, {offline: false, assetsOnly: false}); + }, + ); const exportNoAssets = vscode.commands.registerCommand( 'b2c-dx.content.exportNoAssets', - async (node: ContentTreeItem) => { - if (!node) return; - await runExport(node, {offline: true, assetsOnly: false}); + async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => { + const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : []; + if (!nodes.length) return; + await runExport(nodes, {offline: true, assetsOnly: false}); }, ); - const exportAssets = vscode.commands.registerCommand('b2c-dx.content.exportAssets', async (node: ContentTreeItem) => { - if (!node) return; - await runExport(node, {offline: false, assetsOnly: true}); - }); + const exportAssets = vscode.commands.registerCommand( + 'b2c-dx.content.exportAssets', + async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => { + const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : []; + if (!nodes.length) return; + await runExport(nodes, {offline: false, assetsOnly: true}); + }, + ); const filter = vscode.commands.registerCommand('b2c-dx.content.filter', async () => { const current = treeProvider.getFilter(); diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts index 0abe4b89..16f9e1dd 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -4,6 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content'; import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk'; import JSZip from 'jszip'; @@ -149,11 +152,16 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`}, async () => { - const archiveName = `content-update-${Date.now()}`; const zip = new JSZip(); zip.file(archivePath, xmlContent); const buffer = await zip.generateAsync({type: 'nodebuffer'}); - await siteArchiveImport(instance, buffer, {archiveName}); + + // DEBUG: write archive to temp dir for inspection + const debugPath = path.join(os.tmpdir(), `content-update-${Date.now()}.zip`); + await fs.promises.writeFile(debugPath, buffer); + console.log(`[content-fs] Debug archive written to: ${debugPath}`); + + await siteArchiveImport(instance, buffer); }, ); } catch (err) { diff --git a/packages/b2c-vs-extension/src/content-tree/index.ts b/packages/b2c-vs-extension/src/content-tree/index.ts index 289fd0e1..d451a86b 100644 --- a/packages/b2c-vs-extension/src/content-tree/index.ts +++ b/packages/b2c-vs-extension/src/content-tree/index.ts @@ -25,6 +25,7 @@ export function registerContentTree(context: vscode.ExtensionContext, configProv const treeView = vscode.window.createTreeView('b2cContentExplorer', { treeDataProvider: treeProvider, showCollapseAll: true, + canSelectMany: true, }); // Show active filter in tree view description From 7f8289a74e2066fe72e528144e51f014a7bd3e98 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 24 Feb 2026 13:30:17 -0500 Subject: [PATCH 16/16] changeset: downgrade sdk-plugin-module to patch --- .changeset/sdk-plugin-module.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/sdk-plugin-module.md b/.changeset/sdk-plugin-module.md index 255c6810..c32b6b9b 100644 --- a/.changeset/sdk-plugin-module.md +++ b/.changeset/sdk-plugin-module.md @@ -1,5 +1,5 @@ --- -'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-tooling-sdk': patch --- Add `@salesforce/b2c-tooling-sdk/plugins` module for discovering and loading b2c-cli plugins outside of oclif. Enables the VS Code extension and other non-CLI consumers to use installed plugins (keychain managers, config sources, middleware) without depending on `@oclif/core`.