diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 317043349..9d1170b35 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/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index e4254d1b1..28a6899f8 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-tooling-sdk/test/operations/content/fixtures.ts b/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts index 1acc73078..f8c6c9d72 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 bb57f1af8..634fe2c21 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 8aad31f57..02851570d 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", @@ -29,11 +31,17 @@ ], "main": "./dist/extension.js", "contributes": { + "submenus": [ + { + "id": "b2c-dx.submenu", + "label": "B2C-DX" + } + ], "viewsContainers": { "activitybar": [ { "id": "b2c-dx", - "title": "B2C-DX WebDAV", + "title": "B2C-DX", "icon": "media/b2c-icon.svg" } ] @@ -42,9 +50,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 +66,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 +150,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": "Import Site Archive", + "icon": "$(cloud-upload)", + "category": "B2C DX" } ], "menus": { @@ -140,6 +212,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 +269,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 +296,17 @@ "command": "b2c-dx.webdav.download", "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder", "group": "navigation" + }, + { + "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 new file mode 100644 index 000000000..f507ca1f2 --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -0,0 +1,220 @@ +/* + * 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, 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, + 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) { + await showJobError(err, instance, 'Import failed'); + 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 000000000..d7fb66cd4 --- /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 000000000..4a9273e6d --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -0,0 +1,195 @@ +/* + * 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, getJobLog, JobExecutionError} 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`; + + 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); + 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 000000000..17712925b --- /dev/null +++ b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts @@ -0,0 +1,200 @@ +/* + * 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'; +import {webdavPathToUri} from '../webdav-tree/webdav-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 === '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', + 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 000000000..b95002ba3 --- /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 16803b988..1854799b8 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,