diff --git a/packages/vscode-extension/src/commands/metadata/deleteMetadataCommand.ts b/packages/vscode-extension/src/commands/metadata/deleteMetadataCommand.ts index 08e3834e..3fdf56f1 100644 --- a/packages/vscode-extension/src/commands/metadata/deleteMetadataCommand.ts +++ b/packages/vscode-extension/src/commands/metadata/deleteMetadataCommand.ts @@ -40,6 +40,7 @@ export default class DeleteMetadataCommand extends MetadataCommand { if (!result.success) { if (result.details?.componentFailures) { await this.logDeployResult(sfPackage, result); + this.outputDeployResult(sfPackage.components(), result); const distinctProblems = [...new Set(result.details.componentFailures.map(failure => failure.problem))]; if (distinctProblems.length == 1) { throw new Error(distinctProblems[0]); diff --git a/packages/vscode-extension/src/commands/metadata/deployMetadataCommand.ts b/packages/vscode-extension/src/commands/metadata/deployMetadataCommand.ts index 9f1fb29f..67e629cc 100644 --- a/packages/vscode-extension/src/commands/metadata/deployMetadataCommand.ts +++ b/packages/vscode-extension/src/commands/metadata/deployMetadataCommand.ts @@ -77,18 +77,18 @@ export default class DeployMetadataCommand extends MetadataCommand { // build package const packageBuilder = container.create(SalesforcePackageBuilder, SalesforcePackageType.deploy, this.vlocode.getApiVersion()); await packageBuilder.addFiles(selectedFiles); - const sfPackage = await (options?.delta - ? packageBuilder.getDeltaPackage(RetrieveDeltaStrategy) - : packageBuilder.getPackage() - ); + const packageComponents = packageBuilder.getPackageComponents(); + if (packageComponents.length === 0) { + return void vscode.window.showWarningMessage('None of specified files are deployable Salesforce Metadata'); + } - if (sfPackage.isEmpty) { - if (options?.delta) { - void vscode.window.showInformationMessage('Selected files are already up-to-date'); - } else { - void vscode.window.showWarningMessage('Selected files are not deployable Salesforce Metadata'); - } - return; + if (options?.delta) { + await packageBuilder.removeUnchanged(RetrieveDeltaStrategy); + } + const sfPackage = packageBuilder.getPackage(); + + if (sfPackage.isEmpty && options?.delta) { + return void vscode.window.showWarningMessage('Selected files are not deployable Salesforce Metadata'); } if (sfPackage.hasDestructiveChanges) { @@ -179,6 +179,7 @@ export default class DeployMetadataCommand extends MetadataCommand { return; } + this.outputDeployResult(sfPackage.components(), result); return this.onDeploymentComplete(deployment, result); }; } diff --git a/packages/vscode-extension/src/commands/metadata/metadataCommand.ts b/packages/vscode-extension/src/commands/metadata/metadataCommand.ts index e358e33a..6860f3a4 100644 --- a/packages/vscode-extension/src/commands/metadata/metadataCommand.ts +++ b/packages/vscode-extension/src/commands/metadata/metadataCommand.ts @@ -1,14 +1,16 @@ import * as vscode from 'vscode'; -import { getDocumentBodyAsString } from '@vlocode/util'; +import { getDocumentBodyAsString, groupBy, mapBy } from '@vlocode/util'; import { CommandBase } from '../../lib/commandBase'; -import { DeployResult, SalesforcePackage, SalesforceService } from '@vlocode/salesforce'; +import { DeployResult, SalesforcePackage, SalesforcePackageComponent, SalesforceService } from '@vlocode/salesforce'; /** * Salesforce metadata base command */ export default abstract class MetadataCommand extends CommandBase { + protected outputChannelName = 'Salesforce Metadata'; + /** * Problem matcher functions */ @@ -32,6 +34,44 @@ export default abstract class MetadataCommand extends CommandBase { } } + protected outputDeployResult(components: SalesforcePackageComponent[], result: DeployResult) { + const deployMessages = mapBy(result.details?.allComponentMessages || [], message => `${message.componentType}/${message.fullName}`); + const deployComponentStatus = components.map( + component => { + const message = deployMessages.get(`${component.componentType}/${component.componentName}`); + const deployStatus = message?.deleted + ? 'Deleted' : message?.success === false + ? 'Failed' : (message?.changed ? 'Changed' : 'Unchanged'); + return { + id: message?.id || undefined, + type: component.componentType, + component: component.componentName, + status: deployStatus + }; + } + ); + const deployErrors = (result.details?.componentFailures || []).map( + component => ({ + component: component.fullName, + error: component.problem + }) + ); + + if (!result.success) { + this.outputTable(deployErrors, { appendEmptyLine: true, focus: true }); + } else if (deployComponentStatus.length) { + this.outputTable(deployComponentStatus, { appendEmptyLine: true, focus: true }); + if (deployErrors.length) { + this.outputTable(deployErrors, { appendEmptyLine: true, focus: true, maxCellWidth: { error: 60 } }); + } + } + + this.output( + `Deployment ${result?.id} -- ${result.status} (${result.numberComponentsDeployed}/${result.numberComponentsTotal})`, + { appendEmptyLine: true, focus: true } + ); + } + /** * Displays any error in the diagnostics tab of VSCode * @param manifest The deployment or destructive changes manifest @@ -52,6 +92,12 @@ export default abstract class MetadataCommand extends CommandBase { // these are not useful to display so we instead filter these out const filterFailures = result.details.componentFailures.filter(failure => !failure.problem.startsWith('An unexpected error occurred.')); + // Log all failures to the console even those that have no file info + filterFailures.filter(failure => failure).forEach((failure, i) => { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + this.logger.warn(` ${i + 1}. ${failure.fullName} -- ${failure.problemType} -- ${failure.problem}`); + }); + for (const failure of filterFailures.filter(failure => failure && !!failure.fileName)) { const info = sfPackage.getSourceFile(failure.fileName.replace(/^src\//i, '')); if (info) { @@ -59,12 +105,6 @@ export default abstract class MetadataCommand extends CommandBase { await this.reportProblem(vscode.Uri.file(info), failure); } } - - // Log all failures to the console even those that have no file info - filterFailures.filter(failure => failure).forEach((failure, i) => { - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - this.logger.warn(` ${i + 1}. ${failure.fullName} -- ${failure.problemType} -- ${failure.problem}`); - }); } protected async reportProblem(localPath: vscode.Uri, failure: { problem: string; lineNumber: any; columnNumber: any }) { diff --git a/packages/vscode-extension/src/lib/commandBase.ts b/packages/vscode-extension/src/lib/commandBase.ts index 95224f96..c2579112 100644 --- a/packages/vscode-extension/src/lib/commandBase.ts +++ b/packages/vscode-extension/src/lib/commandBase.ts @@ -5,12 +5,32 @@ import { container, LogManager } from '@vlocode/core'; import { Command } from '../lib/command'; import { getContext, VlocodeContext } from '../lib/vlocodeContext'; import { lazy } from '@vlocode/util'; +import { OutputChannelManager } from './outputChannelManager'; + +type CommandOutputOptions = Partial & { + appendEmptyLine?: boolean; + focus?: boolean; +} + +const TerminalCharacters = { + HorizontalLine: String.fromCharCode(0x2015), + HorizontalLineBold: String.fromCharCode(0x2501), + VerticalLine: String.fromCharCode(0x2502), + VerticalLineBold: String.fromCharCode(0x2503) +} as const; export abstract class CommandBase implements Command { + protected outputChannelName?: string; protected readonly logger = LogManager.get(this.getName()); protected readonly vlocode = lazy(() => container.get(VlocodeService)); + protected get outputChannel() : vscode.OutputChannel { + return this.outputChannelName + ? OutputChannelManager.get(this.outputChannelName) + : OutputChannelManager.getDefault(); + } + public abstract execute(...args: any[]): any | Promise; public validate?(...args: any[]): any | Promise; @@ -29,7 +49,118 @@ export abstract class CommandBase implements Command { : undefined; } - private getName() : string { + protected getName() : string { return this.constructor?.name || 'Command'; } + + protected output(message: string, options?: CommandOutputOptions<{ args: any[] }>) { + if (options?.args) { + message = message.replace(/{(\d+)}/g, (match, number) => this.formatOutputArg(options.args![number] ?? `${match}`)); + } + + this.outputChannel.appendLine(message); + + if (options?.appendEmptyLine) { + this.outputBlank(); + } + if (options?.focus) { + this.outputFocus(); + } + } + + protected outputTable( + data: T[], + options?: CommandOutputOptions<{ + maxCellWidth: number | Record, + format: (row: T) => any, + columns: Array + }> + ) { + if (options?.format) { + // Map data to formatted data using the provided format function + data = data.map(options.format); + } + + const columns = options?.columns ?? Object.keys(data[0]); + const rows = data.map(row => columns.map(column => this.formatOutputArg(row[column]))); + const columnWidths = columns.map((column, index) => Math.max(column.length, ...rows.map(row => row[index].length))); + + // Adjust column widths to max width if specified + const maxWidths = options?.maxCellWidth; + if (maxWidths) { + columnWidths.forEach((width, index) => { + const maxWidth = typeof maxWidths === 'number' ? maxWidths : maxWidths[columns[index]]; + if (maxWidth) { + columnWidths[index] = Math.min(width, maxWidth); + } + }); + } + + const header = columns.map((column, index) => column.padEnd(columnWidths[index])).join(' '); + + this.outputChannel.appendLine(header); + this.outputSeperator(header.length + 4); + rows.forEach(row => this.outputTableRow(row, columnWidths)); + + if (options?.appendEmptyLine) { + this.outputBlank(); + } + if (options?.focus) { + this.outputFocus(); + } + } + + private outputTableRow(values: string[], columnWidths: number[]) { + const nextRow: string[] = []; + const currentRow: string[] = []; + + for (let i = 0; i < columnWidths.length; i++) { + if (values[i] && values[i].length > columnWidths[i]) { + nextRow[i] = values[i].substring(columnWidths[i]); + values[i] = values[i].substring(0, columnWidths[i]); + } + currentRow.push((values[i] ?? '').padEnd(columnWidths[i])); + } + + this.outputChannel.appendLine(currentRow.join(' ')); + if (nextRow.length) { + this.outputTableRow(nextRow, columnWidths); + } + } + + protected outputSeperator(length: number = 100) { + this.outputChannel.appendLine(TerminalCharacters.HorizontalLine.repeat(length)); + } + + protected outputBlank(count: number = 1) { + for (let i = 0; i < count; i++) { + this.outputChannel.appendLine(''); + } + } + + protected outputFocus() { + this.outputChannel.show(); + } + + private formatOutputArg(arg: unknown) { + if (arg === undefined) { + return ''; + } + if (arg instanceof Error) { + return `Error(${arg.message})` + } + if (arg instanceof Date) { + return arg.toISOString(); + } + if (Buffer.isBuffer(arg)) { + return `(Buffer<${arg.length}>)` + } + if (typeof arg === 'object' && arg !== null) { + return JSON.stringify(arg, null); + } + if (typeof arg === 'string') { + return arg; + } + return String(arg); + } } \ No newline at end of file diff --git a/packages/vscode-extension/src/lib/outputChannelManager.ts b/packages/vscode-extension/src/lib/outputChannelManager.ts new file mode 100644 index 00000000..b9341859 --- /dev/null +++ b/packages/vscode-extension/src/lib/outputChannelManager.ts @@ -0,0 +1,32 @@ +import { OUTPUT_CHANNEL_NAME } from '../constants'; +import * as vscode from 'vscode'; + +/** + * Manages the creation and retrieval of output channels in VS Code. + */ +export class OutputChannelManager { + private static outputChannels: Map = new Map(); + + /** + * Returns the default output channel. + * @returns The default output channel. + */ + public static getDefault() : vscode.OutputChannel { + return this.get(OUTPUT_CHANNEL_NAME); + } + + /** + * Retrieves an output channel with the specified name. If the output channel does not exist, + * it creates a new one and adds it to the collection of output channels. + * @param name The name of the output channel. + * @returns The output channel with the specified name. + */ + public static get(name: string, languageId?: string) : vscode.OutputChannel { + let outputChannel = this.outputChannels.get(name); + if (!outputChannel) { + outputChannel = vscode.window.createOutputChannel(name, languageId); + this.outputChannels.set(name, outputChannel); + } + return outputChannel; + } +} \ No newline at end of file