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..2bc45bc0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/upload-files.ts @@ -0,0 +1,166 @@ +/* + * 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(); }