From bbf3f89af86218c54c993d33471a39f1c0d159bd Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 24 Apr 2026 22:49:52 -0400 Subject: [PATCH 1/2] feat: add Code Sync feature to VS Code extension with cartridge management Add file watcher with automatic upload, deploy command, cartridge tree view, and code version management to the VS Code extension. Extract reusable uploadFiles and downloadSingleCartridge functions in the SDK for efficient per-file and per-cartridge operations. SDK changes: - Extract batch upload pipeline into uploadFiles() from watchCartridges() - Add downloadSingleCartridge() for per-cartridge download (ZIPs only the target cartridge instead of entire code version) - downloadCartridges() now uses per-cartridge download when include filter set - Add autoUpload config field (dw.json: "autoUpload" / "auto-upload") VS Code extension: - CodeSyncManager: file watcher using VS Code FileSystemWatcher with debounced batch upload, status bar toggle, per-instance state persistence - Deploy command with cartridge selection and activate/reload options - Cartridge tree view with discovered cartridges and context menu actions: upload, download from instance, add/remove site cartridge path - Code version management: list, create, activate, reload, delete - Auto-start based on workspaceState or dw.json autoUpload setting - Auto-detect new cartridges via .project file watcher - Move API Browser to separate SCAPI sidebar --- .changeset/code-upload-sdk.md | 5 + .changeset/code-upload-vscode.md | 5 + .../b2c-tooling-sdk/src/config/dw-json.ts | 2 + .../b2c-tooling-sdk/src/config/mapping.ts | 5 + packages/b2c-tooling-sdk/src/config/types.ts | 4 + .../src/operations/code/download.ts | 325 ++++++++---- .../src/operations/code/index.ts | 6 +- .../src/operations/code/upload-files.ts | 169 +++++++ .../src/operations/code/watch.ts | 133 +---- .../test/operations/code/download.test.ts | 30 +- packages/b2c-vs-extension/package.json | 196 +++++++- .../src/code-sync/cartridge-commands.ts | 464 ++++++++++++++++++ .../src/code-sync/cartridge-tree-provider.ts | 62 +++ .../src/code-sync/code-sync-manager.ts | 393 +++++++++++++++ .../src/code-sync/deploy-command.ts | 188 +++++++ .../b2c-vs-extension/src/code-sync/index.ts | 171 +++++++ packages/b2c-vs-extension/src/extension.ts | 4 + .../b2c-vs-extension/src/logs/logs-tail.ts | 2 +- 18 files changed, 1940 insertions(+), 224 deletions(-) create mode 100644 .changeset/code-upload-sdk.md create mode 100644 .changeset/code-upload-vscode.md create mode 100644 packages/b2c-tooling-sdk/src/operations/code/upload-files.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/cartridge-tree-provider.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/deploy-command.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/index.ts diff --git a/.changeset/code-upload-sdk.md b/.changeset/code-upload-sdk.md new file mode 100644 index 00000000..f0c99f6c --- /dev/null +++ b/.changeset/code-upload-sdk.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `uploadFiles` and `downloadSingleCartridge` functions for efficient per-file and per-cartridge operations. Extract batch upload pipeline from `watchCartridges` into reusable `uploadFiles` function. `downloadCartridges` now downloads individual cartridges when `include` filter is specified instead of zipping the entire code version. Add `autoUpload` config field for IDE auto-sync. diff --git a/.changeset/code-upload-vscode.md b/.changeset/code-upload-vscode.md new file mode 100644 index 00000000..68a8d0db --- /dev/null +++ b/.changeset/code-upload-vscode.md @@ -0,0 +1,5 @@ +--- +'b2c-vs-extension': minor +--- + +Add Code Sync feature: file watcher with automatic upload to instance, deploy command, cartridge tree view with download/upload/site path management, and code version management. Includes status bar toggle, per-instance state persistence, and `autoUpload` dw.json support. Move API Browser to separate SCAPI sidebar. diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 78a9f92f..56261a7a 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -75,6 +75,8 @@ export interface DwJsonConfig { sandboxApiHost?: string; /** Default ODS realm for sandbox operations */ realm?: string; + /** Whether to auto-start code upload/sync in IDE extensions */ + autoUpload?: boolean; /** Cartridge names to include in deploy/watch (string with colon/comma separators, or array) */ cartridges?: string | string[]; /** Default content library ID for content export/list commands */ diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index ad020581..5e0ed06e 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -156,6 +156,7 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi tenantId: json.tenantId, sandboxApiHost: json.sandboxApiHost, realm: json.realm, + autoUpload: json.autoUpload, cartridges: parseCartridges(json.cartridges), contentLibrary: json.contentLibrary, catalogs: json.catalogs, @@ -274,6 +275,9 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.accountManagerHost !== undefined) { result.accountManagerHost = config.accountManagerHost; } + if (config.autoUpload !== undefined) { + result.autoUpload = config.autoUpload; + } if (config.cartridges !== undefined) { result.cartridges = config.cartridges; } @@ -420,6 +424,7 @@ export function mergeConfigsWithProtection( accountManagerHost: overrides.accountManagerHost ?? base.accountManagerHost, shortCode: overrides.shortCode ?? base.shortCode, tenantId: overrides.tenantId ?? base.tenantId, + autoUpload: overrides.autoUpload ?? base.autoUpload, cartridges: overrides.cartridges ?? base.cartridges, contentLibrary: overrides.contentLibrary ?? base.contentLibrary, catalogs: overrides.catalogs ?? base.catalogs, diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 25d9a7c0..b2841120 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -89,6 +89,10 @@ export interface NormalizedConfig { /** MRT API origin URL override */ mrtOrigin?: string; + // Code upload + /** Whether to auto-start code upload/sync in IDE extensions */ + autoUpload?: boolean; + // Cartridges /** Cartridge names to include in deploy/watch operations */ cartridges?: string[]; diff --git a/packages/b2c-tooling-sdk/src/operations/code/download.ts b/packages/b2c-tooling-sdk/src/operations/code/download.ts index 0b6a45e0..899a6b2d 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/download.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/download.ts @@ -49,14 +49,207 @@ export interface DownloadResult { outputDirectory: string; } +// Progress helper: fires immediately (0s) then every 5s until stopped +const PROGRESS_INTERVAL_MS = 5_000; +function startProgress( + phase: DownloadProgressInfo['phase'], + onProgress?: (info: DownloadProgressInfo) => void, +): () => void { + const start = Date.now(); + onProgress?.({phase, elapsedSeconds: 0}); + if (!onProgress) return () => {}; + const interval = setInterval(() => { + onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); + }, PROGRESS_INTERVAL_MS); + return () => clearInterval(interval); +} + +/** + * Resolves code version from instance config or OCAPI auto-discovery. + */ +async function resolveCodeVersion(instance: B2CInstance): Promise { + const logger = getLogger(); + let codeVersion = instance.config.codeVersion; + + if (!codeVersion) { + logger.debug('No code version configured, attempting to discover active version...'); + try { + const activeVersion = await getActiveCodeVersion(instance); + if (activeVersion?.id) { + codeVersion = activeVersion.id; + instance.config.codeVersion = codeVersion; + } + } catch (error) { + logger.debug({error}, 'Failed to discover active code version'); + } + if (!codeVersion) { + throw new Error( + 'Code version required for download. Configure --code-version or ensure OAuth credentials are available for auto-discovery.', + ); + } + } + + return codeVersion; +} + +/** + * Extracts files from a ZIP archive to disk. + * + * @param zip - Loaded JSZip instance + * @param options - Extraction options + * @param options.stripPrefix - Number of path segments to strip from ZIP entry paths (e.g. 1 to remove codeVersion, 2 to remove codeVersion + cartridgeName) + * @param options.outputDirectory - Base output directory + * @param options.mirror - Map of cartridge names to local paths for mirror extraction + * @param options.include - Cartridge names to include + * @param options.exclude - Cartridge names to exclude + * @returns Set of extracted cartridge names + */ +async function extractZip( + zip: JSZip, + options: { + stripPrefix: number; + outputDirectory: string; + cartridgeName?: string; + mirror?: Map; + include?: string[]; + exclude?: string[]; + }, +): Promise> { + const extractedCartridges = new Set(); + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + + for (const entry of entries) { + const parts = entry.name.split('/'); + if (parts.length < options.stripPrefix + 1) { + continue; + } + + // Strip the prefix segments + for (let i = 0; i < options.stripPrefix; i++) { + parts.shift(); + } + + // Determine cartridge name: either from the next segment or from the option + const cartridgeName = options.cartridgeName ?? parts.shift()!; + const relativePath = options.cartridgeName ? parts.join('/') : parts.join('/'); + + // Apply filters + if (options.include?.length && !options.include.includes(cartridgeName)) { + continue; + } + if (options.exclude?.length && options.exclude.includes(cartridgeName)) { + continue; + } + + let targetPath: string; + if (options.mirror?.has(cartridgeName)) { + targetPath = path.join(options.mirror.get(cartridgeName)!, relativePath); + } else { + targetPath = path.join(options.outputDirectory, cartridgeName, relativePath); + } + + // Preserve existing file permissions + let existingMode: number | null = null; + try { + const stat = await fs.promises.stat(targetPath); + existingMode = stat.mode; + } catch { + // File doesn't exist yet + } + + await fs.promises.mkdir(path.dirname(targetPath), {recursive: true}); + const content = await entry.async('nodebuffer'); + await fs.promises.writeFile(targetPath, content); + + if (existingMode !== null) { + await fs.promises.chmod(targetPath, existingMode); + } + + extractedCartridges.add(cartridgeName); + } + + return extractedCartridges; +} + +/** + * Downloads a single cartridge from an instance via WebDAV. + * + * This is more efficient than downloading the entire code version when only + * one cartridge is needed, as it ZIPs only the cartridge subdirectory on the server. + * + * @param instance - B2C instance to download from + * @param codeVersion - Code version containing the cartridge + * @param cartridgeName - Name of the cartridge to download + * @param outputPath - Local path to extract the cartridge into + * @param onProgress - Optional progress callback + */ +export async function downloadSingleCartridge( + instance: B2CInstance, + codeVersion: string, + cartridgeName: string, + outputPath: string, + onProgress?: (info: DownloadProgressInfo) => void, +): Promise { + const logger = getLogger(); + const webdav = instance.webdav; + const cartridgePath = `Cartridges/${codeVersion}/${cartridgeName}`; + const zipPath = `${cartridgePath}.zip`; + + let stopProgress = startProgress('zipping', onProgress); + logger.debug({cartridgeName, codeVersion}, 'Requesting server-side zip for single cartridge...'); + const zipResponse = await webdav.request(cartridgePath, { + method: 'POST', + body: ZIP_BODY, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), + }); + stopProgress(); + + if (!zipResponse.ok) { + const text = await zipResponse.text(); + throw new Error(`Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText} - ${text}`); + } + + stopProgress = startProgress('downloading', onProgress); + const dlResponse = await webdav.request(zipPath, { + method: 'GET', + signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), + }); + if (!dlResponse.ok) { + stopProgress(); + throw new Error(`Failed to download zip: ${dlResponse.status} ${dlResponse.statusText}`); + } + const buffer = await dlResponse.arrayBuffer(); + stopProgress(); + logger.debug({size: buffer.byteLength}, `Archive downloaded: ${buffer.byteLength} bytes`); + + // Cleanup server-side zip (best effort) + onProgress?.({phase: 'cleanup', elapsedSeconds: 0}); + try { + await webdav.delete(zipPath); + } catch (error) { + logger.warn({error, zipPath}, 'Failed to clean up server-side zip (non-fatal)'); + } + + // Extract + onProgress?.({phase: 'extracting', elapsedSeconds: 0}); + const zip = await JSZip.loadAsync(buffer); + // Single cartridge ZIP contains: cartridgeName/relative/path... + await extractZip(zip, { + stripPrefix: 1, + outputDirectory: path.dirname(outputPath), + cartridgeName, + }); + + logger.debug({cartridgeName, codeVersion}, `Downloaded cartridge ${cartridgeName}`); +} + /** * Downloads cartridges from an instance via WebDAV. * - * This function: - * 1. Triggers server-side zipping of the code version - * 2. Downloads the zip archive - * 3. Cleans up the server-side zip (best-effort) - * 4. Extracts cartridges locally with optional filtering + * When `include` specifies cartridges, each is downloaded individually using + * per-cartridge server-side zipping for efficiency. When downloading all + * cartridges (no include filter), the entire code version is zipped at once. * * If `instance.config.codeVersion` is not set, attempts to discover the active * code version via OCAPI. If that also fails, throws an error. @@ -72,7 +265,7 @@ export interface DownloadResult { * // Download all cartridges * const result = await downloadCartridges(instance, './output'); * - * // Download specific cartridges + * // Download specific cartridges (efficient per-cartridge download) * const result = await downloadCartridges(instance, './output', { * include: ['app_storefront_base'], * }); @@ -88,45 +281,34 @@ export async function downloadCartridges( options: DownloadOptions = {}, ): Promise { const logger = getLogger(); - let codeVersion = instance.config.codeVersion; + const codeVersion = await resolveCodeVersion(instance); + const resolvedOutput = path.resolve(outputDirectory); + const {include, exclude, mirror, onProgress} = options; - if (!codeVersion) { - logger.debug('No code version configured, attempting to discover active version...'); - try { - const activeVersion = await getActiveCodeVersion(instance); - if (activeVersion?.id) { - codeVersion = activeVersion.id; - instance.config.codeVersion = codeVersion; - } - } catch (error) { - logger.debug({error}, 'Failed to discover active code version'); - } - if (!codeVersion) { - throw new Error( - 'Code version required for download. Configure --code-version or ensure OAuth credentials are available for auto-discovery.', - ); + // When specific cartridges are requested, download each individually + if (include?.length) { + const allExtracted = new Set(); + + for (const cartridgeName of include) { + if (exclude?.length && exclude.includes(cartridgeName)) continue; + + const outputPath = mirror?.has(cartridgeName) + ? mirror.get(cartridgeName)! + : path.join(resolvedOutput, cartridgeName); + + await downloadSingleCartridge(instance, codeVersion, cartridgeName, outputPath, onProgress); + allExtracted.add(cartridgeName); } + + const cartridgeList = [...allExtracted].sort(); + return {cartridges: cartridgeList, codeVersion, outputDirectory: resolvedOutput}; } + // Full code version download const webdav = instance.webdav; const zipPath = `Cartridges/${codeVersion}.zip`; - const resolvedOutput = path.resolve(outputDirectory); - const {onProgress} = options; - - // Progress helper: fires immediately (0s) then every 5s until stopped - const PROGRESS_INTERVAL_MS = 5_000; - function startProgress(phase: DownloadProgressInfo['phase']): () => void { - const start = Date.now(); - onProgress?.({phase, elapsedSeconds: 0}); - if (!onProgress) return () => {}; - const interval = setInterval(() => { - onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); - }, PROGRESS_INTERVAL_MS); - return () => clearInterval(interval); - } - // Step 1: Trigger server-side zip (can take several minutes for large code versions) - let stopProgress = startProgress('zipping'); + let stopProgress = startProgress('zipping', onProgress); logger.debug({codeVersion}, 'Requesting server-side zip...'); const zipResponse = await webdav.request(`Cartridges/${codeVersion}`, { method: 'POST', @@ -144,8 +326,7 @@ export async function downloadCartridges( } logger.debug('Server-side zip created'); - // Step 2: Download zip archive (can be large) - stopProgress = startProgress('downloading'); + stopProgress = startProgress('downloading', onProgress); logger.debug({zipPath}, 'Downloading zip archive...'); const downloadResponse = await webdav.request(zipPath, { method: 'GET', @@ -161,7 +342,7 @@ export async function downloadCartridges( stopProgress(); logger.debug({size: buffer.byteLength}, `Archive downloaded: ${buffer.byteLength} bytes`); - // Step 3: Cleanup server-side zip (best-effort) + // Cleanup server-side zip (best-effort) onProgress?.({phase: 'cleanup', elapsedSeconds: 0}); try { await webdav.delete(zipPath); @@ -170,64 +351,18 @@ export async function downloadCartridges( logger.warn({error, zipPath}, 'Failed to clean up server-side zip (non-fatal)'); } - // Step 4: Extract locally + // Extract onProgress?.({phase: 'extracting', elapsedSeconds: 0}); logger.debug('Extracting archive...'); const zip = await JSZip.loadAsync(buffer); - const extractedCartridges = new Set(); - const {include, exclude, mirror} = options; - - const entries = Object.values(zip.files).filter((entry) => !entry.dir); - - for (const entry of entries) { - const parts = entry.name.split('/'); - if (parts.length < 3) { - continue; - } - - // Format: {codeVersion}/{cartridgeName}/{relativePath...} - parts.shift(); // remove codeVersion - const cartridgeName = parts.shift()!; - const relativePath = parts.join('/'); - - // Apply filters - if (include?.length && !include.includes(cartridgeName)) { - continue; - } - if (exclude?.length && exclude.includes(cartridgeName)) { - continue; - } - - let targetPath: string; - if (mirror?.has(cartridgeName)) { - targetPath = path.join(mirror.get(cartridgeName)!, relativePath); - } else { - targetPath = path.join(resolvedOutput, cartridgeName, relativePath); - } - // Preserve existing file permissions - let existingMode: number | null = null; - try { - const stat = await fs.promises.stat(targetPath); - existingMode = stat.mode; - } catch { - // File doesn't exist yet - } - - // Ensure parent directory exists - await fs.promises.mkdir(path.dirname(targetPath), {recursive: true}); - - // Write file - const content = await entry.async('nodebuffer'); - await fs.promises.writeFile(targetPath, content); - - // Restore permissions if file existed - if (existingMode !== null) { - await fs.promises.chmod(targetPath, existingMode); - } - - extractedCartridges.add(cartridgeName); - } + // Full code version ZIP: {codeVersion}/{cartridgeName}/{relativePath...} + const extractedCartridges = await extractZip(zip, { + stripPrefix: 1, + outputDirectory: resolvedOutput, + mirror, + exclude, + }); const cartridgeList = [...extractedCartridges].sort(); logger.debug( diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index 5701fbe4..329a63c3 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -86,9 +86,13 @@ export {findAndDeployCartridges, uploadCartridges, deleteCartridges} from './dep export type {DeployOptions, DeployResult, UploadOptions, UploadProgressInfo} from './deploy.js'; // Download -export {downloadCartridges} from './download.js'; +export {downloadCartridges, downloadSingleCartridge} from './download.js'; export type {DownloadOptions, DownloadProgressInfo, DownloadResult} from './download.js'; +// File upload pipeline +export {uploadFiles, fileToCartridgePath} from './upload-files.js'; +export type {FileChange, UploadFilesOptions} from './upload-files.js'; + // Watch export {watchCartridges} from './watch.js'; export type {WatchOptions, WatchResult} from './watch.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts b/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts new file mode 100644 index 00000000..0dcd23df --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts @@ -0,0 +1,169 @@ +/* + * 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 path from 'node:path'; +import fs from 'node:fs'; +import JSZip from 'jszip'; +import type {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import type {CartridgeMapping} from './cartridges.js'; + +const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); + +/** + * Represents a file to upload or delete, with source and destination paths. + */ +export interface FileChange { + /** Absolute path to the file on disk */ + src: string; + /** Cartridge-relative destination path (e.g. "cartridgeName/path/to/file.js") */ + dest: string; +} + +/** + * Callbacks for file upload/delete operations. + */ +export interface UploadFilesOptions { + /** Called after files are successfully uploaded */ + onUpload?: (files: string[]) => void; + /** Called after files are successfully deleted */ + onDelete?: (files: string[]) => void; + /** Called when an error occurs */ + onError?: (error: Error) => void; +} + +/** + * Maps an absolute file path to its cartridge-relative destination. + * + * @param absolutePath - The absolute path to a file + * @param cartridges - The list of discovered cartridge mappings + * @returns The file change with src and dest, or undefined if the path is not inside any cartridge + */ +export function fileToCartridgePath( + absolutePath: string, + cartridges: CartridgeMapping[], +): FileChange | undefined { + const cartridge = cartridges.find((c) => absolutePath.startsWith(c.src)); + + if (!cartridge) { + return undefined; + } + + const relativePath = absolutePath.substring(cartridge.src.length); + const destPath = path.join(cartridge.dest, relativePath); + + return { + src: absolutePath, + dest: destPath, + }; +} + +/** + * Uploads and deletes files on an instance via WebDAV. + * + * This is the core batch-upload pipeline used by both `watchCartridges` and + * the VS Code extension. It: + * 1. Filters out non-existent upload files + * 2. Creates a ZIP archive of upload files + * 3. Uploads via WebDAV PUT and unzips on server + * 4. Deletes files (skipping any that were also uploaded in the same batch) + * + * @param instance - B2C instance to sync to + * @param codeVersion - Code version to deploy to + * @param uploads - Files to upload + * @param deletes - Files to delete + * @param options - Callbacks for upload/delete/error events + */ +export async function uploadFiles( + instance: B2CInstance, + codeVersion: string, + uploads: FileChange[], + deletes: FileChange[], + options?: UploadFilesOptions, +): Promise { + const logger = getLogger(); + const webdav = instance.webdav; + const webdavLocation = `Cartridges/${codeVersion}`; + + const validUploadFiles = uploads.filter((f) => { + if (!fs.existsSync(f.src)) { + logger.debug({file: f.src}, 'Skipping missing file'); + return false; + } + return true; + }); + + if (validUploadFiles.length > 0) { + const uploadPath = `${webdavLocation}/_upload-${Date.now()}.zip`; + + try { + const zip = new JSZip(); + + for (const f of validUploadFiles) { + try { + const content = await fs.promises.readFile(f.src); + zip.file(f.dest, content); + } catch (error) { + logger.warn({file: f.src, error}, 'Failed to add file to archive'); + } + } + + const buffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: {level: 5}, + }); + + await webdav.put(uploadPath, buffer, 'application/zip'); + logger.debug({uploadPath}, 'Archive uploaded'); + + const response = await webdav.request(uploadPath, { + method: 'POST', + body: UNZIP_BODY, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (!response.ok) { + throw new Error(`Unzip failed: ${response.status}`); + } + + await webdav.delete(uploadPath); + + logger.debug( + {fileCount: validUploadFiles.length, server: instance.config.hostname}, + `Uploaded ${validUploadFiles.length} file(s)`, + ); + + options?.onUpload?.(validUploadFiles.map((f) => f.dest)); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({error: err}, `Upload error: ${err.message}`); + options?.onError?.(err); + throw err; + } + } + + // Skip deletes for any file that was also uploaded in this batch (disk state wins) + const uploadedPaths = new Set(validUploadFiles.map((f) => f.dest)); + const filesToDeleteFiltered = deletes.filter((f) => !uploadedPaths.has(f.dest)); + + if (filesToDeleteFiltered.length > 0) { + logger.debug({fileCount: filesToDeleteFiltered.length}, `Deleting ${filesToDeleteFiltered.length} file(s)`); + + for (const f of filesToDeleteFiltered) { + const deletePath = `${webdavLocation}/${f.dest}`; + try { + await webdav.delete(deletePath); + logger.info({path: deletePath}, `Deleted: ${deletePath}`); + } catch (error) { + logger.debug({path: deletePath, error}, `Failed to delete ${deletePath}`); + } + } + + options?.onDelete?.(filesToDeleteFiltered.map((f) => f.dest)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/watch.ts b/packages/b2c-tooling-sdk/src/operations/code/watch.ts index 45efd771..63498bcc 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/watch.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/watch.ts @@ -4,16 +4,13 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import path from 'node:path'; -import fs from 'node:fs'; import {watch, type FSWatcher} from 'chokidar'; -import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from './cartridges.js'; +import {fileToCartridgePath, uploadFiles} from './upload-files.js'; import {getActiveCodeVersion} from './versions.js'; -const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); - /** Default debounce time in ms for batching file uploads */ const DEFAULT_DEBOUNCE_TIME = parseInt(process.env.SFCC_UPLOAD_DEBOUNCE_TIME ?? '100', 10); @@ -45,28 +42,6 @@ export interface WatchResult { stop: () => Promise; } -/** - * Maps an absolute file path to its cartridge-relative destination. - */ -function fileToCartridgePath( - absolutePath: string, - cartridges: CartridgeMapping[], -): {src: string; dest: string} | undefined { - const cartridge = cartridges.find((c) => absolutePath.startsWith(c.src)); - - if (!cartridge) { - return undefined; - } - - const relativePath = absolutePath.substring(cartridge.src.length); - const destPath = path.join(cartridge.dest, relativePath); - - return { - src: absolutePath, - dest: destPath, - }; -} - /** * Creates a debounced function that batches calls. */ @@ -148,8 +123,8 @@ export async function watchCartridges( logger.info({cartridgeName: c.name, path: c.src}, ` ${c.name}`); } - const webdav = instance.webdav; - const webdavLocation = `Cartridges/${codeVersion}`; + // Re-bind as const so TypeScript knows it's a string inside closures + const resolvedCodeVersion = codeVersion; const cwd = process.cwd(); // Sets for batching file changes @@ -177,102 +152,30 @@ export async function watchCartridges( await new Promise((resolve) => setTimeout(resolve, waitTime)); } - const uploadFiles = Array.from(filesToUpload) + const uploadChanges = Array.from(filesToUpload) .map((f) => fileToCartridgePath(f, cartridges)) .filter((f): f is NonNullable => f !== undefined); - const deleteFiles = Array.from(filesToDelete) + const deleteChanges = Array.from(filesToDelete) .map((f) => fileToCartridgePath(f, cartridges)) .filter((f): f is NonNullable => f !== undefined); filesToUpload.clear(); filesToDelete.clear(); - // Filter out files that no longer exist - const validUploadFiles = uploadFiles.filter((f) => { - if (!fs.existsSync(f.src)) { - logger.debug({file: f.src}, 'Skipping missing file'); - return false; - } - return true; - }); - - // Upload files - if (validUploadFiles.length > 0) { - const uploadPath = `${webdavLocation}/_upload-${Date.now()}.zip`; - - try { - const zip = new JSZip(); - - for (const f of validUploadFiles) { - try { - const content = await fs.promises.readFile(f.src); - zip.file(f.dest, content); - } catch (error) { - logger.warn({file: f.src, error}, 'Failed to add file to archive'); - } - } - - const buffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE', - compressionOptions: {level: 5}, - }); - - await webdav.put(uploadPath, buffer, 'application/zip'); - logger.debug({uploadPath}, 'Archive uploaded'); - - const response = await webdav.request(uploadPath, { - method: 'POST', - body: UNZIP_BODY, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - if (!response.ok) { - throw new Error(`Unzip failed: ${response.status}`); - } - - await webdav.delete(uploadPath); - - logger.debug( - {fileCount: validUploadFiles.length, server: instance.config.hostname}, - `Uploaded ${validUploadFiles.length} file(s)`, - ); - - options.onUpload?.(validUploadFiles.map((f) => f.dest)); - } catch (error) { - lastErrorTime = Date.now(); - // Re-queue so the while loop retries after rate-limit wait - for (const f of validUploadFiles) { - filesToUpload.add(f.src); - } - const err = error instanceof Error ? error : new Error(String(error)); - logger.error({error: err}, `Upload error: ${err.message}`); - options.onError?.(err); + try { + await uploadFiles(instance, resolvedCodeVersion, uploadChanges, deleteChanges, { + onUpload: options.onUpload, + onDelete: options.onDelete, + onError: options.onError, + }); + } catch { + lastErrorTime = Date.now(); + // Re-queue so the while loop retries after rate-limit wait + for (const f of uploadChanges) { + filesToUpload.add(f.src); } } - - // Skip deletes for any file that was also uploaded in this batch (disk state wins) - const uploadedPaths = new Set(validUploadFiles.map((f) => f.dest)); - const filesToDeleteFiltered = deleteFiles.filter((f) => !uploadedPaths.has(f.dest)); - - if (filesToDeleteFiltered.length > 0) { - logger.debug({fileCount: filesToDeleteFiltered.length}, `Deleting ${filesToDeleteFiltered.length} file(s)`); - - for (const f of filesToDeleteFiltered) { - const deletePath = `${webdavLocation}/${f.dest}`; - try { - await webdav.delete(deletePath); - logger.info({path: deletePath}, `Deleted: ${deletePath}`); - } catch (error) { - logger.debug({path: deletePath, error}, `Failed to delete ${deletePath}`); - } - } - - options.onDelete?.(filesToDeleteFiltered.map((f) => f.dest)); - } } } finally { isProcessing = false; @@ -313,12 +216,12 @@ export async function watchCartridges( options.onError?.(error); }); - logger.debug({server: instance.config.hostname, codeVersion}, 'Watching for changes...'); + logger.debug({server: instance.config.hostname, codeVersion: resolvedCodeVersion}, 'Watching for changes...'); return { watcher, cartridges, - codeVersion, + codeVersion: resolvedCodeVersion, stop: async () => { await watcher.close(); logger.debug('Watcher stopped'); diff --git a/packages/b2c-tooling-sdk/test/operations/code/download.test.ts b/packages/b2c-tooling-sdk/test/operations/code/download.test.ts index 7c349b58..55ce7f24 100644 --- a/packages/b2c-tooling-sdk/test/operations/code/download.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/code/download.test.ts @@ -31,6 +31,14 @@ async function createTestZip(codeVersion: string, cartridges: Record): Promise { + const zip = new JSZip(); + for (const [filePath, content] of Object.entries(files)) { + zip.file(`${cartridgeName}/${filePath}`, content); + } + return zip.generateAsync({type: 'nodebuffer'}); +} + describe('operations/code/download', () => { const server = setupServer(); let mockInstance: any; @@ -106,17 +114,21 @@ describe('operations/code/download', () => { expect(coreJs).to.equal('module.exports = {};'); }); - it('should apply include filter', async () => { - const zipBuffer = await createTestZip('v1', { - app_storefront: {'main.js': 'storefront'}, - app_core: {'core.js': 'core'}, - }); + it('should apply include filter with per-cartridge download', async () => { + const cartridgeZip = await createCartridgeZip('app_storefront', {'main.js': 'storefront'}); server.use( http.all(`${WEBDAV_BASE}/*`, ({request}) => { - if (request.method === 'POST') return new HttpResponse(null, {status: 204}); - if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); - if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + const url = new URL(request.url); + if (request.method === 'POST' && url.pathname.includes('/Cartridges/v1/app_storefront')) { + return new HttpResponse(null, {status: 204}); + } + if (request.method === 'GET' && url.pathname.endsWith('/Cartridges/v1/app_storefront.zip')) { + return new HttpResponse(cartridgeZip, {status: 200}); + } + if (request.method === 'DELETE' && url.pathname.endsWith('/Cartridges/v1/app_storefront.zip')) { + return new HttpResponse(null, {status: 204}); + } return new HttpResponse(null, {status: 404}); }), ); @@ -125,7 +137,7 @@ describe('operations/code/download', () => { expect(result.cartridges).to.deep.equal(['app_storefront']); expect(fs.existsSync(path.join(tempDir, 'app_storefront/main.js'))).to.be.true; - expect(fs.existsSync(path.join(tempDir, 'app_core/core.js'))).to.be.false; + expect(fs.existsSync(path.join(tempDir, 'app_core'))).to.be.false; }); it('should apply exclude filter', async () => { diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index ba4332e2..6df22f27 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -25,6 +25,7 @@ "onFileSystem:b2c-content", "onView:b2cApiBrowser", "onView:b2cSandboxExplorer", + "onView:b2cCartridgeExplorer", "onDebugResolve:b2c-script", "workspaceContains:**/commerce-app.json" ], @@ -87,6 +88,11 @@ "minimum": 2, "maximum": 300, "description": "Seconds between polls during sandbox state transitions." + }, + "b2c-dx.features.codeSync": { + "type": "boolean", + "default": true, + "description": "Enable Code Sync (watch and deploy cartridges)." } } }, @@ -101,7 +107,12 @@ { "id": "b2c-dx", "title": "B2C-DX", - "icon": "media/b2c-icon.svg" + "icon": "$(remote-explorer)" + }, + { + "id": "b2c-dx-scapi", + "title": "B2C-DX: SCAPI", + "icon": "$(symbol-interface)" }, { "id": "b2c-dx-sandboxes", @@ -125,12 +136,20 @@ "contextualTitle": "B2C-DX" }, { - "id": "b2cApiBrowser", - "name": "API Browser", + "id": "b2cCartridgeExplorer", + "name": "Cartridges", "icon": "media/b2c-icon.svg", "contextualTitle": "B2C-DX" } ], + "b2c-dx-scapi": [ + { + "id": "b2cApiBrowser", + "name": "API Browser", + "icon": "$(symbol-interface)", + "contextualTitle": "B2C-DX: SCAPI" + } + ], "b2c-dx-sandboxes": [ { "id": "b2cSandboxExplorer", @@ -155,6 +174,10 @@ { "view": "b2cSandboxExplorer", "contents": "No sandbox realms configured.\n\nSet \"realm\" in dw.json or add a realm manually.\n\n[Add Realm](command:b2c-dx.sandbox.addRealm)" + }, + { + "view": "b2cCartridgeExplorer", + "contents": "No cartridges found.\n\nCartridges are identified by .project files in the workspace.\n\n[Refresh](command:b2c-dx.codeSync.refreshCartridges)" } ], "debuggers": [ @@ -462,6 +485,90 @@ "title": "Install Commerce App (CAP)", "icon": "$(cloud-upload)", "category": "B2C DX" + }, + { + "command": "b2c-dx.codeSync.toggle", + "title": "Toggle Code Sync", + "icon": "$(sync)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.start", + "title": "Start Code Sync", + "icon": "$(sync)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.stop", + "title": "Stop Code Sync", + "icon": "$(debug-stop)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.deploy", + "title": "Deploy Cartridges", + "icon": "$(cloud-upload)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.refreshCartridges", + "title": "Refresh Cartridges", + "icon": "$(refresh)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.uploadCartridge", + "title": "Upload Cartridge", + "icon": "$(cloud-upload)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.uploadToInstance", + "title": "Upload to Instance", + "icon": "$(cloud-upload)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.downloadCartridge", + "title": "Download from Instance", + "icon": "$(cloud-download)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.diffCartridge", + "title": "Compare with Instance", + "icon": "$(diff)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.addToSitePath", + "title": "Add to Site Cartridge Path", + "icon": "$(add)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.removeFromSitePath", + "title": "Remove from Site Cartridge Path", + "icon": "$(remove)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.listCodeVersions", + "title": "Code Versions", + "icon": "$(list-unordered)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.createCodeVersion", + "title": "Create Code Version", + "icon": "$(add)", + "category": "B2C DX - Code Sync" + }, + { + "command": "b2c-dx.codeSync.activateCodeVersion", + "title": "Activate Code Version", + "icon": "$(check)", + "category": "B2C DX - Code Sync" } ], "menus": { @@ -505,6 +612,31 @@ "command": "b2c-dx.sandbox.addRealm", "when": "view == b2cSandboxExplorer", "group": "navigation" + }, + { + "command": "b2c-dx.codeSync.refreshCartridges", + "when": "view == b2cCartridgeExplorer", + "group": "navigation" + }, + { + "command": "b2c-dx.codeSync.deploy", + "when": "view == b2cCartridgeExplorer", + "group": "navigation" + }, + { + "command": "b2c-dx.codeSync.listCodeVersions", + "when": "view == b2cCartridgeExplorer", + "group": "navigation" + }, + { + "command": "b2c-dx.codeSync.createCodeVersion", + "when": "view == b2cCartridgeExplorer", + "group": "1_versions" + }, + { + "command": "b2c-dx.codeSync.activateCodeVersion", + "when": "view == b2cCartridgeExplorer", + "group": "1_versions" } ], "view/item/context": [ @@ -632,6 +764,26 @@ "command": "b2c-dx.sandbox.delete", "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/", "group": "3_destructive@1" + }, + { + "command": "b2c-dx.codeSync.uploadCartridge", + "when": "view == b2cCartridgeExplorer && viewItem == cartridge", + "group": "1_upload@1" + }, + { + "command": "b2c-dx.codeSync.downloadCartridge", + "when": "view == b2cCartridgeExplorer && viewItem == cartridge", + "group": "2_instance@1" + }, + { + "command": "b2c-dx.codeSync.addToSitePath", + "when": "view == b2cCartridgeExplorer && viewItem == cartridge", + "group": "3_sitepath@1" + }, + { + "command": "b2c-dx.codeSync.removeFromSitePath", + "when": "view == b2cCartridgeExplorer && viewItem == cartridge", + "group": "3_sitepath@2" } ], "file/newFile": [ @@ -647,6 +799,11 @@ "when": "resourceScheme == b2c-webdav && !explorerResourceIsFolder", "group": "navigation" }, + { + "command": "b2c-dx.codeSync.uploadToInstance", + "when": "b2c-dx.codeSyncAvailable && resourceScheme == file", + "group": "7_modification@8" + }, { "submenu": "b2c-dx.submenu", "when": "explorerResourceIsFolder", @@ -659,6 +816,11 @@ } ], "b2c-dx.submenu": [ + { + "command": "b2c-dx.codeSync.deploy", + "when": "explorerResourceIsFolder && b2c-dx.codeSyncAvailable", + "group": "0_code" + }, { "command": "b2c-dx.scaffold.generate", "when": "explorerResourceIsFolder", @@ -779,6 +941,34 @@ { "command": "b2c-dx.cap.install", "when": "false" + }, + { + "command": "b2c-dx.codeSync.uploadCartridge", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.uploadToInstance", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.refreshCartridges", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.downloadCartridge", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.diffCartridge", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.addToSitePath", + "when": "false" + }, + { + "command": "b2c-dx.codeSync.removeFromSitePath", + "when": "false" } ] } diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts new file mode 100644 index 00000000..1267c8f5 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -0,0 +1,464 @@ +/* + * 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 { + downloadSingleCartridge, + listCodeVersions, + getActiveCodeVersion, + activateCodeVersion, + createCodeVersion, + reloadCodeVersion, + deleteCodeVersion, +} from '@salesforce/b2c-tooling-sdk/operations/code'; +import { + addCartridge, + removeCartridge, + getCartridgePath, + type CartridgePosition, +} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; +import {CartridgeItem, type CartridgeTreeItem, type CartridgeTreeProvider} from './cartridge-tree-provider.js'; + +function getInstance(configProvider: B2CExtensionConfig): B2CInstance | undefined { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + } + return instance ?? undefined; +} + +function showError(err: unknown, outputChannel: vscode.OutputChannel): void { + const message = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error && err.cause ? `\n Cause: ${String(err.cause)}` : ''; + outputChannel.appendLine(`[Error] ${message}${cause}`); + void vscode.window.showErrorMessage(`B2C DX: ${message.split('\n')[0]}`, 'Show Details').then((action) => { + if (action === 'Show Details') outputChannel.show(); + }); +} + +// --------------------------------------------------------------------------- +// Download from Instance +// --------------------------------------------------------------------------- + +function createDownloadCartridgeCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, +): (item: CartridgeItem) => Promise { + return async (item) => { + const instance = getInstance(configProvider); + if (!instance) return; + + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) codeVersion = active.id; + } catch { + // fall through + } + } + if (!codeVersion) { + vscode.window.showErrorMessage('B2C DX: No code version configured.'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `This will overwrite local files in '${item.cartridge.name}'. Continue?`, + {modal: true}, + 'Download', + ); + if (confirm !== 'Download') return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Downloading ${item.cartridge.name}...`}, + async (progress) => { + try { + const phaseLabels: Record = { + zipping: 'Creating server-side archive...', + downloading: 'Downloading...', + cleanup: 'Cleaning up...', + extracting: 'Extracting files...', + }; + await downloadSingleCartridge(instance, codeVersion, item.cartridge.name, item.cartridge.src, (info) => { + progress.report({message: phaseLabels[info.phase] ?? info.phase}); + }); + + outputChannel.appendLine(`[Download] ${item.cartridge.name} downloaded from instance`); + vscode.window.showInformationMessage(`B2C DX: Downloaded '${item.cartridge.name}' from instance.`); + } catch (err) { + showError(err, outputChannel); + } + }, + ); + }; +} + +// --------------------------------------------------------------------------- +// Diff with Instance (TODO: disabled — needs optimization for large cartridges) +// --------------------------------------------------------------------------- + +function createDiffCartridgeCommand( + _configProvider: B2CExtensionConfig, + _outputChannel: vscode.OutputChannel, + _tempDirs: string[], +): (item: CartridgeItem) => Promise { + return async () => { + vscode.window.showInformationMessage('B2C DX: Compare with Instance is not yet available.'); + }; +} + +// --------------------------------------------------------------------------- +// Site Cartridge Path +// --------------------------------------------------------------------------- + +async function pickSite(instance: B2CInstance): Promise { + let siteItems: {label: string; siteId: string}[] = []; + + try { + const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, + }); + if (!error && data) { + const sites = (data as {data?: {id?: string}[]}).data ?? []; + siteItems = sites + .filter((s): s is {id: string} => typeof s.id === 'string') + .map((s) => ({label: s.id, siteId: s.id})); + } + } catch { + // OAuth not available — fall through to manual input + } + + siteItems.push({label: 'Business Manager (Sites-Site)', siteId: 'Sites-Site'}); + + if (siteItems.length > 1) { + const picked = await vscode.window.showQuickPick(siteItems, { + title: 'Select a site', + placeHolder: 'Choose a site', + }); + return picked?.siteId; + } + + return vscode.window.showInputBox({ + title: 'Site ID', + placeHolder: 'Enter site ID (e.g. RefArch, Sites-Site)', + validateInput: (v) => (v.trim() ? null : 'Site ID is required'), + }); +} + +function createAddToSitePathCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, +): (item: CartridgeItem) => Promise { + return async (item) => { + const instance = getInstance(configProvider); + if (!instance) return; + + const siteId = await pickSite(instance); + if (!siteId) return; + + const positionPick = await vscode.window.showQuickPick( + [ + {label: 'First', position: 'first' as CartridgePosition}, + {label: 'Last', position: 'last' as CartridgePosition}, + {label: 'Before...', position: 'before' as CartridgePosition}, + {label: 'After...', position: 'after' as CartridgePosition}, + ], + {title: 'Position in cartridge path'}, + ); + if (!positionPick) return; + + let target: string | undefined; + if (positionPick.position === 'before' || positionPick.position === 'after') { + try { + const pathResult = await getCartridgePath(instance, siteId); + if (pathResult.cartridgeList.length === 0) { + vscode.window.showWarningMessage('B2C DX: Site has no cartridges yet. Adding as first.'); + } + const targetPick = await vscode.window.showQuickPick( + pathResult.cartridgeList.map((c) => ({label: c})), + {title: `Add ${positionPick.position} which cartridge?`}, + ); + if (!targetPick) return; + target = targetPick.label; + } catch (err) { + showError(err, outputChannel); + return; + } + } + + try { + const result = await addCartridge(instance, siteId, { + name: item.cartridge.name, + position: positionPick.position, + target, + }); + outputChannel.appendLine(`[Site Path] Added '${item.cartridge.name}' to ${siteId}: ${result.cartridges}`); + vscode.window.showInformationMessage(`B2C DX: Added '${item.cartridge.name}' to ${siteId} cartridge path.`); + } catch (err) { + showError(err, outputChannel); + } + }; +} + +function createRemoveFromSitePathCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, +): (item: CartridgeItem) => Promise { + return async (item) => { + const instance = getInstance(configProvider); + if (!instance) return; + + const siteId = await pickSite(instance); + if (!siteId) return; + + const confirm = await vscode.window.showWarningMessage( + `Remove '${item.cartridge.name}' from ${siteId} cartridge path?`, + {modal: true}, + 'Remove', + ); + if (confirm !== 'Remove') return; + + try { + const result = await removeCartridge(instance, siteId, item.cartridge.name); + outputChannel.appendLine(`[Site Path] Removed '${item.cartridge.name}' from ${siteId}: ${result.cartridges}`); + vscode.window.showInformationMessage(`B2C DX: Removed '${item.cartridge.name}' from ${siteId} cartridge path.`); + } catch (err) { + showError(err, outputChannel); + } + }; +} + +// --------------------------------------------------------------------------- +// Code Version Management +// --------------------------------------------------------------------------- + +function createListCodeVersionsCommand( + configProvider: B2CExtensionConfig, + treeView: vscode.TreeView, + outputChannel: vscode.OutputChannel, +): () => Promise { + return async () => { + const instance = getInstance(configProvider); + if (!instance) return; + + try { + const versions = await listCodeVersions(instance); + const items = versions.map((v) => ({ + label: `${v.active ? '$(star-full) ' : ''}${v.id ?? 'unknown'}`, + description: v.active ? 'Active' : '', + version: v, + })); + + const picked = await vscode.window.showQuickPick(items, { + title: 'Code Versions', + placeHolder: 'Select a code version for actions', + }); + if (!picked || !picked.version.id) return; + + const actions: {label: string; action: string}[] = []; + if (!picked.version.active) { + actions.push({label: '$(check) Activate', action: 'activate'}); + } + actions.push({label: '$(debug-restart) Reload', action: 'reload'}); + if (!picked.version.active) { + actions.push({label: '$(trash) Delete', action: 'delete'}); + } + + const actionPick = await vscode.window.showQuickPick(actions, { + title: `Actions for "${picked.version.id}"`, + }); + if (!actionPick) return; + + const versionId = picked.version.id; + + if (actionPick.action === 'activate') { + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Activating "${versionId}"...`}, + async () => { + await activateCodeVersion(instance, versionId); + treeView.description = `v: ${versionId}`; + }, + ); + vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" activated.`); + } else if (actionPick.action === 'reload') { + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Reloading "${versionId}"...`}, + () => reloadCodeVersion(instance, versionId), + ); + vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" reloaded.`); + } else if (actionPick.action === 'delete') { + const confirm = await vscode.window.showWarningMessage( + `Delete code version "${versionId}"? This cannot be undone.`, + {modal: true}, + 'Delete', + ); + if (confirm === 'Delete') { + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Deleting "${versionId}"...`}, + () => deleteCodeVersion(instance, versionId), + ); + vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" deleted.`); + } + } + } catch (err) { + showError(err, outputChannel); + } + }; +} + +function createCreateCodeVersionCommand( + configProvider: B2CExtensionConfig, + treeProvider: CartridgeTreeProvider, + outputChannel: vscode.OutputChannel, +): () => Promise { + return async () => { + const instance = getInstance(configProvider); + if (!instance) return; + + const name = await vscode.window.showInputBox({ + title: 'Create Code Version', + placeHolder: 'Enter code version name', + validateInput: (v) => (v.trim() ? null : 'Name is required'), + }); + if (!name) return; + + try { + await createCodeVersion(instance, name.trim()); + outputChannel.appendLine(`[Code Version] Created "${name.trim()}"`); + vscode.window.showInformationMessage(`B2C DX: Code version "${name.trim()}" created.`); + treeProvider.refresh(); + } catch (err) { + showError(err, outputChannel); + } + }; +} + +function createActivateCodeVersionCommand( + configProvider: B2CExtensionConfig, + treeView: vscode.TreeView, + outputChannel: vscode.OutputChannel, +): () => Promise { + return async () => { + const instance = getInstance(configProvider); + if (!instance) return; + + try { + const versions = await listCodeVersions(instance); + const items = versions.map((v) => ({ + label: v.id ?? 'unknown', + description: v.active ? '$(star-full) Active' : '', + version: v, + })); + + const picked = await vscode.window.showQuickPick(items, { + title: 'Activate Code Version', + placeHolder: 'Select a code version to activate', + }); + if (!picked || !picked.version.id) return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Activating "${picked.version.id}"...`}, + async () => { + await activateCodeVersion(instance, picked.version.id!); + treeView.description = `v: ${picked.version.id}`; + }, + ); + outputChannel.appendLine(`[Code Version] Activated "${picked.version.id}"`); + vscode.window.showInformationMessage(`B2C DX: Code version "${picked.version.id}" activated.`); + } catch (err) { + showError(err, outputChannel); + } + }; +} + +// --------------------------------------------------------------------------- +// Code Version Display +// --------------------------------------------------------------------------- + +export async function updateCodeVersionDisplay( + configProvider: B2CExtensionConfig, + treeView: vscode.TreeView, +): Promise { + const config = configProvider.getConfig(); + const instance = configProvider.getInstance(); + + // Prefer the locally configured code version (no OCAPI needed) + const configuredVersion = config?.values.codeVersion; + if (configuredVersion) { + treeView.description = `v: ${configuredVersion}`; + return; + } + + // Fall back to OCAPI discovery if available + if (!instance) { + treeView.description = ''; + return; + } + try { + const active = await getActiveCodeVersion(instance); + treeView.description = active?.id ? `v: ${active.id}` : ''; + } catch { + treeView.description = ''; + } +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerCartridgeCommands( + configProvider: B2CExtensionConfig, + treeProvider: CartridgeTreeProvider, + treeView: vscode.TreeView, + outputChannel: vscode.OutputChannel, +): vscode.Disposable[] { + const tempDirs: string[] = []; + + const disposables = [ + vscode.commands.registerCommand( + 'b2c-dx.codeSync.downloadCartridge', + createDownloadCartridgeCommand(configProvider, outputChannel), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.diffCartridge', + createDiffCartridgeCommand(configProvider, outputChannel, tempDirs), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.addToSitePath', + createAddToSitePathCommand(configProvider, outputChannel), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.removeFromSitePath', + createRemoveFromSitePathCommand(configProvider, outputChannel), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.listCodeVersions', + createListCodeVersionsCommand(configProvider, treeView, outputChannel), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.createCodeVersion', + createCreateCodeVersionCommand(configProvider, treeProvider, outputChannel), + ), + vscode.commands.registerCommand( + 'b2c-dx.codeSync.activateCodeVersion', + createActivateCodeVersionCommand(configProvider, treeView, outputChannel), + ), + // Cleanup temp dirs on dispose + new vscode.Disposable(() => { + for (const dir of tempDirs) { + try { + fs.rmSync(dir, {recursive: true, force: true}); + } catch { + // best effort + } + } + }), + ]; + + return disposables; +} diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-tree-provider.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-tree-provider.ts new file mode 100644 index 00000000..c5c6e10c --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-tree-provider.ts @@ -0,0 +1,62 @@ +/* + * 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 {findCartridges, type CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; + +export class CartridgeItem extends vscode.TreeItem { + constructor(public readonly cartridge: CartridgeMapping) { + super(cartridge.name, vscode.TreeItemCollapsibleState.None); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const workspaceRoot = workspaceFolders[0].uri.fsPath; + this.description = path.relative(workspaceRoot, cartridge.src); + } + + this.iconPath = new vscode.ThemeIcon('package'); + this.contextValue = 'cartridge'; + this.tooltip = cartridge.src; + } +} + +export type CartridgeTreeItem = CartridgeItem; + +export class CartridgeTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private cartridges: CartridgeMapping[] = []; + + constructor(private readonly configProvider: B2CExtensionConfig) {} + + refresh(): void { + this.cartridges = []; + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: CartridgeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: CartridgeItem): CartridgeItem[] { + if (element) return []; + + if (this.cartridges.length === 0) { + const workingDirectory = this.configProvider.getWorkingDirectory(); + if (workingDirectory) { + this.cartridges = findCartridges(workingDirectory); + } + } + + return this.cartridges.map((c) => new CartridgeItem(c)); + } + + dispose(): void { + this._onDidChangeTreeData.dispose(); + } +} diff --git a/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts new file mode 100644 index 00000000..b79838a1 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts @@ -0,0 +1,393 @@ +/* + * 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 { + findCartridges, + uploadFiles, + fileToCartridgePath, + uploadCartridges, + getActiveCodeVersion, + type CartridgeMapping, + type FileChange, +} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const DEBOUNCE_MS = 150; +const ERROR_RATE_LIMIT_MS = 5000; +const STATE_KEY_PREFIX = 'b2c-dx.codeSync.state.'; + +export class CodeSyncManager implements vscode.Disposable { + readonly outputChannel: vscode.OutputChannel; + private statusBar: vscode.StatusBarItem; + private fileWatchers: vscode.Disposable[] = []; + private cartridges: CartridgeMapping[] = []; + private codeVersion: string | undefined; + private instance: B2CInstance | undefined; + private watching = false; + + // Debounce state + private pendingUploads = new Map(); + private pendingDeletes = new Map(); + private debounceTimer: ReturnType | undefined; + private isProcessing = false; + private lastErrorTime = 0; + + constructor(private readonly workspaceState: vscode.Memento) { + this.outputChannel = vscode.window.createOutputChannel('B2C Code Upload'); + this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + this.updateStatusBar(); + } + + get isWatching(): boolean { + return this.watching; + } + + get discoveredCartridges(): CartridgeMapping[] { + return this.cartridges; + } + + async startWatch(instance: B2CInstance, directory: string): Promise { + if (this.watching) { + vscode.window.showWarningMessage('B2C DX: Code Sync is already active. Stop it first.'); + return; + } + + this.instance = instance; + + // Discover cartridges + const cartridges = findCartridges(directory); + if (cartridges.length === 0) { + vscode.window.showWarningMessage('B2C DX: No cartridges found (no .project files in workspace).'); + return; + } + this.cartridges = cartridges; + + this.codeVersion = instance.config.codeVersion; + if (!this.codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + this.codeVersion = active.id; + instance.config.codeVersion = this.codeVersion; + } + } catch { + // OCAPI not available — soft failure + } + } + if (!this.codeVersion) { + this.log( + '[Warning] No code version configured. Set "codeVersion" in dw.json or configure OAuth credentials. Code upload is disabled.', + ); + this.updateStatusBar('warning'); + return; + } + + // Set up VS Code file watchers + for (const c of cartridges) { + const pattern = new vscode.RelativePattern(c.src, '**/*'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + watcher.onDidChange((uri) => this.onFileChange(uri)); + watcher.onDidCreate((uri) => this.onFileChange(uri)); + watcher.onDidDelete((uri) => this.onFileDelete(uri)); + + this.fileWatchers.push(watcher); + } + + this.watching = true; + + this.outputChannel.clear(); + this.outputChannel.show(true); + const hostname = instance.config.hostname ?? 'unknown'; + this.log(`--- Code Sync started ---`); + this.log(`Instance: ${hostname}`); + if (this.codeVersion) { + this.log(`Code Version: ${this.codeVersion}`); + } + this.log(`Watching ${cartridges.length} cartridge(s):`); + for (const c of cartridges) { + this.log(` ${c.name} (${c.src})`); + } + + this.updateStatusBar(); + } + + refreshCartridges(directory: string): void { + if (!this.watching) return; + + const cartridges = findCartridges(directory); + const existingNames = new Set(this.cartridges.map((c) => c.name)); + const newCartridges = cartridges.filter((c) => !existingNames.has(c.name)); + + if (newCartridges.length === 0) return; + + for (const c of newCartridges) { + const pattern = new vscode.RelativePattern(c.src, '**/*'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + watcher.onDidChange((uri) => this.onFileChange(uri)); + watcher.onDidCreate((uri) => this.onFileChange(uri)); + watcher.onDidDelete((uri) => this.onFileDelete(uri)); + this.fileWatchers.push(watcher); + this.log(`[Watch] Added cartridge: ${c.name} (${c.src})`); + } + + this.cartridges = cartridges; + this.updateStatusBar(); + } + + async stopWatch(): Promise { + if (!this.watching) return; + + // Clear debounce timer + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + + // Dispose all file watchers + for (const w of this.fileWatchers) { + w.dispose(); + } + this.fileWatchers = []; + + // Clear pending state + this.pendingUploads.clear(); + this.pendingDeletes.clear(); + this.isProcessing = false; + + this.watching = false; + this.instance = undefined; + + this.log(`--- Code Sync stopped ---`); + this.updateStatusBar(); + } + + async toggle(instance: B2CInstance, directory: string, hostname: string): Promise { + if (this.watching) { + await this.stopWatch(); + await this.setPersistedState(hostname, false); + } else { + await this.startWatch(instance, directory); + await this.setPersistedState(hostname, true); + } + } + + async uploadSingleCartridge(instance: B2CInstance, cartridge: CartridgeMapping): Promise { + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; + } + } catch { + // fall through to error + } + } + if (!codeVersion) { + vscode.window.showErrorMessage( + 'B2C DX: No code version configured. Set code-version in dw.json or activate a code version.', + ); + return; + } + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Uploading ${cartridge.name}...`}, + async () => { + await uploadCartridges(instance, [cartridge]); + this.log(`[Upload] Cartridge "${cartridge.name}" uploaded successfully`); + vscode.window.showInformationMessage(`B2C DX: Cartridge "${cartridge.name}" uploaded.`); + }, + ); + } + + async uploadFileOrFolder(instance: B2CInstance, uri: vscode.Uri, directory: string): Promise { + const cartridges = this.cartridges.length > 0 ? this.cartridges : findCartridges(directory); + const filePath = uri.fsPath; + + const cartridge = cartridges.find((c) => filePath.startsWith(c.src)); + if (!cartridge) { + vscode.window.showWarningMessage('B2C DX: This file is not inside a discovered cartridge.'); + return; + } + + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; + } + } catch { + // fall through + } + } + if (!codeVersion) { + vscode.window.showErrorMessage('B2C DX: No code version configured.'); + return; + } + + const stat = await vscode.workspace.fs.stat(uri); + if (stat.type === vscode.FileType.Directory) { + // Upload entire cartridge containing this folder + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Uploading ${cartridge.name}...`}, + async () => { + await uploadCartridges(instance, [cartridge]); + this.log(`[Upload] Cartridge "${cartridge.name}" uploaded successfully`); + vscode.window.showInformationMessage(`B2C DX: Cartridge "${cartridge.name}" uploaded.`); + }, + ); + } else { + // Upload single file + const change = fileToCartridgePath(filePath, cartridges); + if (!change) return; + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Uploading ${path.basename(filePath)}...`}, + async () => { + await uploadFiles(instance, codeVersion!, [change], []); + this.log(`[Upload] ${change.dest}`); + vscode.window.showInformationMessage(`B2C DX: File uploaded.`); + }, + ); + } + } + + getPersistedState(hostname: string): boolean | undefined { + return this.workspaceState.get(`${STATE_KEY_PREFIX}${hostname}`); + } + + async setPersistedState(hostname: string, enabled: boolean): Promise { + await this.workspaceState.update(`${STATE_KEY_PREFIX}${hostname}`, enabled); + } + + dispose(): void { + if (this.watching) { + this.stopWatch().catch(() => {}); + } + this.statusBar.dispose(); + this.outputChannel.dispose(); + } + + private onFileChange(uri: vscode.Uri): void { + const filePath = uri.fsPath; + const change = fileToCartridgePath(filePath, this.cartridges); + if (!change) return; + + this.pendingUploads.set(filePath, change); + this.pendingDeletes.delete(filePath); + this.scheduleProcessing(); + } + + private onFileDelete(uri: vscode.Uri): void { + const filePath = uri.fsPath; + const change = fileToCartridgePath(filePath, this.cartridges); + if (!change) return; + + this.pendingDeletes.set(filePath, change); + this.pendingUploads.delete(filePath); + this.scheduleProcessing(); + } + + private scheduleProcessing(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + void this.processChanges(); + }, DEBOUNCE_MS); + } + + private async processChanges(): Promise { + if (this.isProcessing) return; + if (!this.instance || !this.codeVersion) return; + + this.isProcessing = true; + + try { + while (this.pendingUploads.size > 0 || this.pendingDeletes.size > 0) { + // Rate limit after errors + const timeSinceError = Date.now() - this.lastErrorTime; + if (timeSinceError < ERROR_RATE_LIMIT_MS) { + await new Promise((resolve) => setTimeout(resolve, ERROR_RATE_LIMIT_MS - timeSinceError)); + } + + const uploads = Array.from(this.pendingUploads.values()); + const deletes = Array.from(this.pendingDeletes.values()); + this.pendingUploads.clear(); + this.pendingDeletes.clear(); + + try { + await uploadFiles(this.instance, this.codeVersion, uploads, deletes, { + onUpload: (files) => { + const ts = new Date().toLocaleTimeString(); + for (const f of files) { + this.log(`${ts} [Upload] ${f}`); + } + }, + onDelete: (files) => { + const ts = new Date().toLocaleTimeString(); + for (const f of files) { + this.log(`${ts} [Delete] ${f}`); + } + }, + onError: (error) => { + this.log(`[Error] ${error.message}`); + }, + }); + } catch { + this.lastErrorTime = Date.now(); + // Re-queue for retry + for (const f of uploads) { + this.pendingUploads.set(f.src, f); + } + } + } + } finally { + this.isProcessing = false; + } + } + + private log(message: string): void { + this.outputChannel.appendLine(message); + } + + private updateStatusBar(state?: 'warning'): void { + this.statusBar.command = 'b2c-dx.codeSync.toggle'; + if (state === 'warning') { + this.statusBar.text = '$(warning)'; + this.statusBar.tooltip = 'Code Sync: No code version configured\nClick to toggle'; + this.statusBar.show(); + } else if (this.watching) { + const hostname = this.instance?.config.hostname ?? ''; + const lines = ['Code Sync: Active']; + if (hostname) lines.push(`Instance: ${hostname}`); + if (this.codeVersion) lines.push(`Code Version: ${this.codeVersion}`); + lines.push(`Cartridges: ${this.cartridges.length}`); + lines.push('Click to stop'); + this.statusBar.text = '$(cloud-upload)'; + this.statusBar.tooltip = lines.join('\n'); + this.statusBar.show(); + } else { + this.statusBar.text = '$(sync-ignored)'; + this.statusBar.tooltip = 'Code Sync: Inactive\nClick to start'; + this.statusBar.show(); + } + } + + hideStatusBar(): void { + this.statusBar.hide(); + } + + showStatusBar(): void { + this.updateStatusBar(); + } +} diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts new file mode 100644 index 00000000..f3be4c95 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -0,0 +1,188 @@ +/* + * 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 { + findCartridges, + uploadCartridges, + deleteCartridges, + getActiveCodeVersion, + activateCodeVersion, + reloadCodeVersion, +} from '@salesforce/b2c-tooling-sdk/operations/code'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; + +export function createDeployCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, +): () => Promise { + return async () => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + + // Resolve code version + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; + } + } catch { + // fall through + } + } + if (!codeVersion) { + vscode.window.showErrorMessage( + 'B2C DX: No code version configured. Set code-version in dw.json or activate a code version.', + ); + return; + } + + // Discover cartridges + const directory = configProvider.getWorkingDirectory(); + const cartridges = findCartridges(directory); + if (cartridges.length === 0) { + vscode.window.showWarningMessage('B2C DX: No cartridges found (no .project files in workspace).'); + return; + } + + // Let user select cartridges if more than one + let selectedCartridges = cartridges; + if (cartridges.length > 1) { + const picks = await vscode.window.showQuickPick( + cartridges.map((c) => ({label: c.name, description: c.src, picked: true, cartridge: c})), + {title: 'Select cartridges to deploy', canPickMany: true}, + ); + if (!picks || picks.length === 0) return; + selectedCartridges = picks.map((p) => p.cartridge); + } + + // Choose post-deploy action + const actionPick = await vscode.window.showQuickPick( + [ + {label: 'Deploy only', action: 'none' as const}, + {label: 'Deploy & Activate', action: 'activate' as const}, + {label: 'Deploy & Reload', description: 'Toggle activation to force reload', action: 'reload' as const}, + ], + {title: 'Post-deploy action'}, + ); + if (!actionPick) return; + + const hostname = instance.config.hostname ?? 'unknown'; + outputChannel.appendLine(`--- Deploy started ---`); + outputChannel.appendLine(`Instance: ${hostname}`); + outputChannel.appendLine(`Code Version: ${codeVersion}`); + outputChannel.appendLine(`Cartridges: ${selectedCartridges.map((c) => c.name).join(', ')}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Deploying cartridges...', + cancellable: false, + }, + async (progress) => { + try { + progress.report({message: 'Uploading cartridges...'}); + await uploadCartridges(instance, selectedCartridges); + + if (actionPick.action === 'activate') { + progress.report({message: 'Activating code version...'}); + await activateCodeVersion(instance, codeVersion); + outputChannel.appendLine(`Code version "${codeVersion}" activated`); + } else if (actionPick.action === 'reload') { + progress.report({message: 'Reloading code version...'}); + await reloadCodeVersion(instance, codeVersion); + outputChannel.appendLine(`Code version "${codeVersion}" reloaded`); + } + + outputChannel.appendLine( + `Deployed ${selectedCartridges.length} cartridge(s) to "${codeVersion}" successfully`, + ); + outputChannel.appendLine(`--- Deploy complete ---`); + vscode.window.showInformationMessage( + `B2C DX: Deployed ${selectedCartridges.length} cartridge(s) to "${codeVersion}".`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[Error] Deploy failed: ${message}`); + outputChannel.appendLine(`--- Deploy failed ---`); + vscode.window.showErrorMessage(`B2C DX: Deploy failed: ${message}`); + } + }, + ); + }; +} + +export function createDeleteAndDeployCommand( + configProvider: B2CExtensionConfig, + outputChannel: vscode.OutputChannel, +): () => Promise { + return async () => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + + let codeVersion = instance.config.codeVersion; + if (!codeVersion) { + try { + const active = await getActiveCodeVersion(instance); + if (active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; + } + } catch { + // fall through + } + } + if (!codeVersion) { + vscode.window.showErrorMessage('B2C DX: No code version configured.'); + return; + } + + const directory = configProvider.getWorkingDirectory(); + const cartridges = findCartridges(directory); + if (cartridges.length === 0) { + vscode.window.showWarningMessage('B2C DX: No cartridges found.'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `This will delete existing cartridges on "${codeVersion}" before deploying. Continue?`, + {modal: true}, + 'Delete & Deploy', + ); + if (confirm !== 'Delete & Deploy') return; + + outputChannel.appendLine(`--- Clean Deploy started ---`); + + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: 'Clean deploy...', cancellable: false}, + async (progress) => { + try { + progress.report({message: 'Deleting existing cartridges...'}); + await deleteCartridges(instance, cartridges); + + progress.report({message: 'Uploading cartridges...'}); + await uploadCartridges(instance, cartridges); + + outputChannel.appendLine(`Clean deployed ${cartridges.length} cartridge(s) to "${codeVersion}"`); + outputChannel.appendLine(`--- Clean Deploy complete ---`); + vscode.window.showInformationMessage(`B2C DX: Clean deploy complete.`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[Error] Clean deploy failed: ${message}`); + vscode.window.showErrorMessage(`B2C DX: Clean deploy failed: ${message}`); + } + }, + ); + }; +} diff --git a/packages/b2c-vs-extension/src/code-sync/index.ts b/packages/b2c-vs-extension/src/code-sync/index.ts new file mode 100644 index 00000000..1e48d936 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/index.ts @@ -0,0 +1,171 @@ +/* + * 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 {B2CExtensionConfig} from '../config-provider.js'; +import {CodeSyncManager} from './code-sync-manager.js'; +import {CartridgeTreeProvider, CartridgeItem} from './cartridge-tree-provider.js'; +import {createDeployCommand} from './deploy-command.js'; +import {registerCartridgeCommands, updateCodeVersionDisplay} from './cartridge-commands.js'; + +export function registerCodeSync( + context: vscode.ExtensionContext, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, +): void { + const manager = new CodeSyncManager(context.workspaceState); + const treeProvider = new CartridgeTreeProvider(configProvider); + const treeView = vscode.window.createTreeView('b2cCartridgeExplorer', {treeDataProvider: treeProvider}); + + // --- Core sync commands --- + + const toggleCmd = vscode.commands.registerCommand('b2c-dx.codeSync.toggle', async () => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + const hostname = configProvider.getConfig()?.values.hostname ?? ''; + await manager.toggle(instance, configProvider.getWorkingDirectory(), hostname); + treeProvider.refresh(); + }); + + const startCmd = vscode.commands.registerCommand('b2c-dx.codeSync.start', async () => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + const hostname = configProvider.getConfig()?.values.hostname ?? ''; + await manager.startWatch(instance, configProvider.getWorkingDirectory()); + await manager.setPersistedState(hostname, true); + treeProvider.refresh(); + }); + + const stopCmd = vscode.commands.registerCommand('b2c-dx.codeSync.stop', async () => { + const hostname = configProvider.getConfig()?.values.hostname ?? ''; + await manager.stopWatch(); + await manager.setPersistedState(hostname, false); + }); + + const deployCmd = vscode.commands.registerCommand( + 'b2c-dx.codeSync.deploy', + createDeployCommand(configProvider, manager.outputChannel), + ); + + const refreshCmd = vscode.commands.registerCommand('b2c-dx.codeSync.refreshCartridges', () => { + treeProvider.refresh(); + manager.refreshCartridges(configProvider.getWorkingDirectory()); + }); + + // Watch for new .project files (new cartridges added via scaffolding, etc.) + const projectFileWatcher = vscode.workspace.createFileSystemWatcher('**/.project'); + projectFileWatcher.onDidCreate(() => { + treeProvider.refresh(); + manager.refreshCartridges(configProvider.getWorkingDirectory()); + }); + projectFileWatcher.onDidDelete(() => { + treeProvider.refresh(); + }); + + const uploadCartridgeCmd = vscode.commands.registerCommand( + 'b2c-dx.codeSync.uploadCartridge', + async (item: CartridgeItem) => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + await manager.uploadSingleCartridge(instance, item.cartridge); + }, + ); + + const uploadToInstanceCmd = vscode.commands.registerCommand( + 'b2c-dx.codeSync.uploadToInstance', + async (uri?: vscode.Uri) => { + if (!uri) return; + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured.'); + return; + } + await manager.uploadFileOrFolder(instance, uri, configProvider.getWorkingDirectory()); + }, + ); + + // --- Cartridge commands (download, diff, site path, code versions) --- + const cartridgeCmdDisposables = registerCartridgeCommands( + configProvider, + treeProvider, + treeView, + manager.outputChannel, + ); + + // --- Context key for explorer menu visibility --- + function updateContextKey(): void { + const hasInstance = configProvider.getInstance() !== null; + void vscode.commands.executeCommand('setContext', 'b2c-dx.codeSyncAvailable', hasInstance); + } + updateContextKey(); + + // --- Auto-start logic --- + async function evaluateAutoStart(): Promise { + const instance = configProvider.getInstance(); + if (!instance) { + manager.hideStatusBar(); + return; + } + manager.showStatusBar(); + + const hostname = configProvider.getConfig()?.values.hostname ?? ''; + if (!hostname) return; + + // State resolution: workspaceState → dw.json autoUpload → off + let shouldStart = manager.getPersistedState(hostname); + if (shouldStart === undefined) { + shouldStart = configProvider.getConfig()?.values.autoUpload === true; + } + + if (shouldStart && !manager.isWatching) { + try { + await manager.startWatch(instance, configProvider.getWorkingDirectory()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.appendLine(`[CodeSync] Auto-start failed: ${message}`); + } + } + + // Update code version display in tree view + await updateCodeVersionDisplay(configProvider, treeView); + } + + // Wire config resets + configProvider.onDidReset(async () => { + if (manager.isWatching) { + await manager.stopWatch(); + } + treeProvider.refresh(); + updateContextKey(); + await evaluateAutoStart(); + }); + + // Initial auto-start + void evaluateAutoStart(); + + context.subscriptions.push( + manager, + treeProvider, + treeView, + toggleCmd, + startCmd, + stopCmd, + deployCmd, + refreshCmd, + uploadCartridgeCmd, + uploadToInstanceCmd, + projectFileWatcher, + ...cartridgeCmdDisposables, + ); +} diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 01b18292..d190dabf 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -19,6 +19,7 @@ import {registerSandboxTree} from './sandbox-tree/index.js'; import {registerScaffold} from './scaffold/index.js'; import {registerApiBrowser} from './api-browser/index.js'; import {registerDebugger} from './debugger/index.js'; +import {registerCodeSync} from './code-sync/index.js'; import {registerWebDavTree} from './webdav-tree/index.js'; function getWebviewContent(context: vscode.ExtensionContext): string { @@ -403,6 +404,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu if (settings.get('features.cap', true)) { registerCap(context, configProvider, log); } + if (settings.get('features.codeSync', true)) { + registerCodeSync(context, configProvider, log); + } registerDebugger(context, configProvider); diff --git a/packages/b2c-vs-extension/src/logs/logs-tail.ts b/packages/b2c-vs-extension/src/logs/logs-tail.ts index ff7a1d70..0dc1c851 100644 --- a/packages/b2c-vs-extension/src/logs/logs-tail.ts +++ b/packages/b2c-vs-extension/src/logs/logs-tail.ts @@ -38,7 +38,7 @@ export class LogTailManager implements vscode.Disposable { constructor() { this.outputChannel = vscode.window.createOutputChannel('B2C Logs'); - this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 49); + this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 48); this.statusBar.command = 'b2c-dx.logs.stopTail'; this.updateStatusBar(); } From e6b2013c4d0310eebc7b32b9967e0094d1865f51 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 24 Apr 2026 22:57:37 -0400 Subject: [PATCH 2/2] fix: prettier formatting in upload-files.ts --- packages/b2c-tooling-sdk/src/operations/code/upload-files.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts b/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts index 0dcd23df..2bc45bc0 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts @@ -41,10 +41,7 @@ export interface UploadFilesOptions { * @param cartridges - The list of discovered cartridge mappings * @returns The file change with src and dest, or undefined if the path is not inside any cartridge */ -export function fileToCartridgePath( - absolutePath: string, - cartridges: CartridgeMapping[], -): FileChange | undefined { +export function fileToCartridgePath(absolutePath: string, cartridges: CartridgeMapping[]): FileChange | undefined { const cartridge = cartridges.find((c) => absolutePath.startsWith(c.src)); if (!cartridge) {