diff --git a/packages/b2c-vs-extension/.vscodeignore b/packages/b2c-vs-extension/.vscodeignore
index 70e6d3491..32f022e29 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 000000000..ec49c2507
--- /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 a3f140ab7..8aad31f57 100644
--- a/packages/b2c-vs-extension/package.json
+++ b/packages/b2c-vs-extension/package.json
@@ -18,8 +18,8 @@
"vscode": "^1.105.1"
},
"activationEvents": [
- "*",
- "onStartupFinished",
+ "onView:b2cWebdavExplorer",
+ "onFileSystem:b2c-webdav",
"onCommand:b2c-dx.openUI",
"onCommand:b2c-dx.handleStorefrontNextCartridge",
"onCommand:b2c-dx.promptAgent",
@@ -29,6 +29,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 +84,109 @@
"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"
+ },
+ {
+ "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"
}
- ]
+ ],
+ "menus": {
+ "view/title": [
+ {
+ "command": "b2c-dx.webdav.refresh",
+ "when": "view == b2cWebdavExplorer",
+ "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)$/",
+ "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"
+ },
+ {
+ "command": "b2c-dx.webdav.mountWorkspace",
+ "when": "view == b2cWebdavExplorer && viewItem =~ /^(root|directory)$/",
+ "group": "3_workspace@1"
+ }
+ ],
+ "explorer/context": [
+ {
+ "command": "b2c-dx.webdav.download",
+ "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder",
+ "group": "navigation"
+ }
+ ]
+ }
},
"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 76cbed4a7..16803b988 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 000000000..72fde9fd4
--- /dev/null
+++ b/packages/b2c-vs-extension/src/webdav-tree/index.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 {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();
+ 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, fsProvider);
+
+ 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
new file mode 100644
index 000000000..4630ef964
--- /dev/null
+++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts
@@ -0,0 +1,189 @@
+/*
+ * 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 WebDavFileSystemProvider, webdavPathToUri} from './webdav-fs-provider.js';
+import type {WebDavTreeDataProvider, WebDavTreeItem} from './webdav-tree-provider.js';
+
+export function registerWebDavCommands(
+ _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 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 fsProvider.createDirectory(webdavPathToUri(fullPath));
+ } 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 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);
+ 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}`);
+ }
+ },
+ );
+ });
+
+ const deleteItem = vscode.commands.registerCommand('b2c-dx.webdav.delete', async (node: WebDavTreeItem) => {
+ if (!node) 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 fsProvider.delete(webdavPathToUri(node.webdavPath));
+ } 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 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 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);
+ vscode.window.showErrorMessage(`WebDAV: Download failed: ${message}`);
+ }
+ },
+ );
+ });
+
+ const openFile = vscode.commands.registerCommand('b2c-dx.webdav.openFile', async (node: WebDavTreeItem) => {
+ if (!node) return;
+ const uri = webdavPathToUri(node.webdavPath);
+ await vscode.commands.executeCommand('vscode.open', uri);
+ });
+
+ 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: `Creating file ${name.trim()}...`},
+ async () => {
+ try {
+ 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 create file: ${message}`);
+ }
+ },
+ );
+ });
+
+ 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,
+ name: `WebDAV: ${node.webdavPath}`,
+ });
+ });
+
+ return [refresh, newFolder, newFile, uploadFile, deleteItem, download, openFile, mountWorkspace];
+}
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 000000000..436fe0512
--- /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-fs-provider.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-fs-provider.ts
new file mode 100644
index 000000000..e4abb26fb
--- /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
new file mode 100644
index 000000000..3dc9b18fd
--- /dev/null
+++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-tree-provider.ts
@@ -0,0 +1,125 @@
+/*
+ * 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';
+import {type WebDavFileSystemProvider, WEBDAV_ROOTS, webdavPathToUri} from './webdav-fs-provider.js';
+
+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;
+
+ const resourceUri = webdavPathToUri(webdavPath);
+
+ if (nodeType === 'root') {
+ this.resourceUri = resourceUri;
+ } else if (nodeType === 'directory') {
+ this.resourceUri = resourceUri;
+ } else {
+ this.resourceUri = resourceUri;
+ if (contentLength !== undefined) {
+ this.description = formatFileSize(contentLength);
+ }
+ this.command = {
+ command: 'vscode.open',
+ title: 'Open File',
+ arguments: [resourceUri],
+ };
+ }
+ }
+}
+
+export class WebDavTreeDataProvider implements vscode.TreeDataProvider {
+ private _onDidChangeTreeData = new vscode.EventEmitter();
+ readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
+
+ 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.fsProvider.clearCache();
+ this._onDidChangeTreeData.fire();
+ }
+
+ 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));
+ }
+
+ 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
+ }
+ }
+
+ children.push(new WebDavTreeItem(nodeType, childPath, name, isCollection, contentLength));
+ }
+
+ // 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);
+ });
+
+ 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 4f9d39d79..000000000
--- a/packages/b2c-vs-extension/src/webdav.html
+++ /dev/null
@@ -1,561 +0,0 @@
-
-
-
-
-
- B2C WebDAV Browser
-
-
-
-
-
B2C WebDAV Browser
-
-
← Back
-
-
-
-
-
-
-
-
-
Loading…
-
-
-
-
-
-
-
-