diff --git a/packages/databricks-vscode/src/bundle/models/BundlePreValidateModel.ts b/packages/databricks-vscode/src/bundle/models/BundlePreValidateModel.ts new file mode 100644 index 000000000..02845b511 --- /dev/null +++ b/packages/databricks-vscode/src/bundle/models/BundlePreValidateModel.ts @@ -0,0 +1,145 @@ +import {Disposable, Uri} from "vscode"; +import {BundleFileSet, BundleWatcher} from ".."; +import {BundleTarget} from "../types"; +import {CachedValue} from "../../locking/CachedValue"; +import { + BundlePreValidateConfig, + isBundlePreValidateConfigKey, +} from "../../configuration/types"; +/** + * Reads and writes bundle configs. This class does not notify when the configs change. + * We use the BundleWatcher to notify when the configs change. + */ +export class BundlePreValidateModel implements Disposable { + private disposables: Disposable[] = []; + + private readonly stateCache = new CachedValue< + BundlePreValidateConfig | undefined + >(async () => { + if (this.target === undefined) { + return undefined; + } + return this.readState(this.target); + }); + + public readonly onDidChange = this.stateCache.onDidChange; + + private target: string | undefined; + + private readonly readerMapping: Record< + keyof BundlePreValidateConfig, + ( + t?: BundleTarget + ) => Promise< + BundlePreValidateConfig[keyof BundlePreValidateConfig] | undefined + > + > = { + authParams: this.getAuthParams, + mode: this.getMode, + host: this.getHost, + }; + + constructor( + private readonly bundleFileSet: BundleFileSet, + private readonly bunldeFileWatcher: BundleWatcher + ) { + this.disposables.push( + this.bunldeFileWatcher.onDidChange(async () => { + await this.stateCache.refresh(); + }) + ); + } + + private async getHost(target?: BundleTarget) { + return target?.workspace?.host; + } + + private async getMode(target?: BundleTarget) { + return target?.mode; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + private async getAuthParams(target?: BundleTarget) { + return undefined; + } + /* eslint-enable @typescript-eslint/no-unused-vars */ + + get targets() { + return this.bundleFileSet.bundleDataCache.value.then( + (data) => data?.targets + ); + } + + get defaultTarget() { + return this.targets.then((targets) => { + if (targets === undefined) { + return undefined; + } + const defaultTarget = Object.keys(targets).find( + (target) => targets[target].default + ); + return defaultTarget; + }); + } + + public async setTarget(target: string | undefined) { + this.target = target; + await this.stateCache.refresh(); + } + + private async readState(target: string) { + const configs = {} as any; + const targetObject = (await this.bundleFileSet.bundleDataCache.value) + .targets?.[target]; + + for (const key of Object.keys(this.readerMapping)) { + if (!isBundlePreValidateConfigKey(key)) { + continue; + } + configs[key] = await this.readerMapping[key](targetObject); + } + return configs as BundlePreValidateConfig; + } + + public async getFileToWrite( + key: T + ) { + const filesWithTarget: Uri[] = []; + const filesWithConfig = ( + await this.bundleFileSet.findFile(async (data, file) => { + const bundleTarget = data.targets?.[this.target ?? ""]; + if (bundleTarget) { + filesWithTarget.push(file); + } + if ( + (await this.readerMapping[key](bundleTarget)) === undefined + ) { + return false; + } + return true; + }) + ).map((file) => file.file); + + if (filesWithConfig.length > 1) { + throw new Error( + `Multiple files found to write the config ${key} for target ${this.target}` + ); + } + + if (filesWithConfig.length === 0 && filesWithTarget.length === 0) { + throw new Error( + `No files found to write the config ${key} for target ${this.target}` + ); + } + + return [...filesWithConfig, ...filesWithTarget][0]; + } + + public async load() { + return await this.stateCache.value; + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/packages/databricks-vscode/src/bundle/models/BundleValidateModel.ts b/packages/databricks-vscode/src/bundle/models/BundleValidateModel.ts new file mode 100644 index 000000000..0b6addaf9 --- /dev/null +++ b/packages/databricks-vscode/src/bundle/models/BundleValidateModel.ts @@ -0,0 +1,146 @@ +import {Disposable, Uri, EventEmitter} from "vscode"; +import {BundleWatcher} from "../BundleWatcher"; +import {AuthProvider} from "../../configuration/auth/AuthProvider"; +import {Mutex} from "../../locking"; +import {CliWrapper} from "../../cli/CliWrapper"; +import {BundleTarget} from "../types"; +import {CachedValue} from "../../locking/CachedValue"; +import {onError} from "../../utils/onErrorDecorator"; +import lodash from "lodash"; +import {workspaceConfigs} from "../../vscode-objs/WorkspaceConfigs"; +import {BundleValidateConfig} from "../../configuration/types"; + +type BundleValidateState = BundleValidateConfig & BundleTarget; + +export class BundleValidateModel implements Disposable { + private disposables: Disposable[] = []; + private mutex = new Mutex(); + + private target: string | undefined; + private authProvider: AuthProvider | undefined; + + private readonly stateCache = new CachedValue< + BundleValidateState | undefined + >(this.readState.bind(this)); + + private readonly onDidChangeKeyEmitters = new Map< + keyof BundleValidateState, + EventEmitter + >(); + + onDidChangeKey(key: keyof BundleValidateState) { + if (!this.onDidChangeKeyEmitters.has(key)) { + this.onDidChangeKeyEmitters.set(key, new EventEmitter()); + } + return this.onDidChangeKeyEmitters.get(key)!.event; + } + + public onDidChange = this.stateCache.onDidChange; + + constructor( + private readonly bundleWatcher: BundleWatcher, + private readonly cli: CliWrapper, + private readonly workspaceFolder: Uri + ) { + this.disposables.push( + this.bundleWatcher.onDidChange(async () => { + await this.stateCache.refresh(); + }), + // Emit an event for each key that changes + this.stateCache.onDidChange(async ({oldValue, newValue}) => { + for (const key of Object.keys({ + ...oldValue, + ...newValue, + }) as (keyof BundleValidateState)[]) { + if ( + oldValue === null || + !lodash.isEqual(oldValue?.[key], newValue?.[key]) + ) { + this.onDidChangeKeyEmitters.get(key)?.fire(); + } + } + }) + ); + } + + private readerMapping: { + [K in keyof BundleValidateState]: ( + t?: BundleTarget + ) => BundleValidateState[K]; + } = { + clusterId: (target) => target?.bundle?.compute_id, + workspaceFsPath: (target) => target?.workspace?.file_path, + resources: (target) => target?.resources, + }; + + @Mutex.synchronise("mutex") + public async setTarget(target: string | undefined) { + if (this.target === target) { + return; + } + this.target = target; + this.authProvider = undefined; + await this.stateCache.refresh(); + } + + @Mutex.synchronise("mutex") + public async setAuthProvider(authProvider: AuthProvider | undefined) { + if ( + !lodash.isEqual(this.authProvider?.toJSON(), authProvider?.toJSON()) + ) { + this.authProvider = authProvider; + await this.stateCache.refresh(); + } + } + + @onError({popup: {prefix: "Failed to read bundle config."}}) + @Mutex.synchronise("mutex") + private async readState() { + if (this.target === undefined || this.authProvider === undefined) { + return; + } + + const targetObject = JSON.parse( + await this.cli.bundleValidate( + this.target, + this.authProvider, + this.workspaceFolder, + workspaceConfigs.databrickscfgLocation + ) + ) as BundleTarget; + + const configs: any = {}; + + for (const key of Object.keys( + this.readerMapping + ) as (keyof BundleValidateState)[]) { + configs[key] = this.readerMapping[key]?.(targetObject); + } + + return {...configs, ...targetObject} as BundleValidateState; + } + + @Mutex.synchronise("mutex") + public async load( + keys: T[] = [] + ): Promise> | undefined> { + if (keys.length === 0) { + return await this.stateCache.value; + } + + const target = await this.stateCache.value; + const configs: Partial<{ + [K in T]: BundleValidateState[K]; + }> = {}; + + for (const key of keys) { + configs[key] = this.readerMapping[key]?.(target); + } + + return configs; + } + + dispose() { + this.disposables.forEach((i) => i.dispose()); + } +} diff --git a/packages/databricks-vscode/src/cli/CliWrapper.ts b/packages/databricks-vscode/src/cli/CliWrapper.ts index 04f5ab7ae..2f8194b59 100644 --- a/packages/databricks-vscode/src/cli/CliWrapper.ts +++ b/packages/databricks-vscode/src/cli/CliWrapper.ts @@ -1,5 +1,5 @@ import {execFile as execFileCb, spawn} from "child_process"; -import {ExtensionContext, window, commands} from "vscode"; +import {ExtensionContext, window, commands, Uri} from "vscode"; import {SyncDestinationMapper} from "../sync/SyncDestination"; import {workspaceConfigs} from "../vscode-objs/WorkspaceConfigs"; import {promisify} from "node:util"; @@ -7,6 +7,8 @@ import {logging} from "@databricks/databricks-sdk"; import {Loggers} from "../logger"; import {Context, context} from "@databricks/databricks-sdk/dist/context"; import {Cloud} from "../utils/constants"; +import {AuthProvider} from "../configuration/auth/AuthProvider"; +import {EnvVarGenerators} from "../utils"; const withLogContext = logging.withLogContext; const execFile = promisify(execFileCb); @@ -86,12 +88,8 @@ export class CliWrapper { const cmd = await this.getListProfilesCommand(); const res = await execFile(cmd.command, cmd.args, { env: { - /* eslint-disable @typescript-eslint/naming-convention */ - HOME: process.env.HOME, - DATABRICKS_CONFIG_FILE: - configfilePath || process.env.DATABRICKS_CONFIG_FILE, - DATABRICKS_OUTPUT_FORMAT: "json", - /* eslint-enable @typescript-eslint/naming-convention */ + ...EnvVarGenerators.getEnvVarsForCli(configfilePath), + ...EnvVarGenerators.getProxyEnvVars(), }, }); const profiles = JSON.parse(res.stdout).profiles || []; @@ -134,7 +132,6 @@ export class CliWrapper { } public async getBundleSchema(): Promise { - const execFile = promisify(execFileCb); const {stdout} = await execFile(this.cliPath, ["bundle", "schema"]); return stdout; } @@ -172,4 +169,30 @@ export class CliWrapper { child.on("exit", resolve); }); } + + async bundleValidate( + target: string, + authProvider: AuthProvider, + workspaceFolder: Uri, + configfilePath?: string + ) { + const {stdout, stderr} = await execFile( + this.cliPath, + ["bundle", "validate", "--target", target], + { + cwd: workspaceFolder.fsPath, + env: { + ...EnvVarGenerators.getEnvVarsForCli(configfilePath), + ...EnvVarGenerators.getProxyEnvVars(), + ...authProvider.toEnv(), + }, + shell: true, + } + ); + + if (stderr !== "") { + throw new Error(stderr); + } + return stdout; + } } diff --git a/packages/databricks-vscode/src/configuration/ConnectionCommands.ts b/packages/databricks-vscode/src/configuration/ConnectionCommands.ts index 3384e6d4e..153f45065 100644 --- a/packages/databricks-vscode/src/configuration/ConnectionCommands.ts +++ b/packages/databricks-vscode/src/configuration/ConnectionCommands.ts @@ -200,7 +200,7 @@ export class ConnectionCommands implements Disposable { } async selectTarget() { - const targets = await this.configModel.bundleFileConfigModel.targets; + const targets = await this.configModel.bundlePreValidateModel.targets; const currentTarget = this.configModel.target; if (targets === undefined) { return; diff --git a/packages/databricks-vscode/src/configuration/ConnectionManager.ts b/packages/databricks-vscode/src/configuration/ConnectionManager.ts index 20c19ecb6..f4defae65 100644 --- a/packages/databricks-vscode/src/configuration/ConnectionManager.ts +++ b/packages/databricks-vscode/src/configuration/ConnectionManager.ts @@ -225,7 +225,7 @@ export class ConnectionManager implements Disposable { await this.updateSyncDestinationMapper(); await this.updateClusterManager(); await this.configModel.set("authParams", authProvider.toJSON()); - + await this.configModel.setAuthProvider(authProvider); this.updateState("CONNECTED"); }); } catch (e) { diff --git a/packages/databricks-vscode/src/configuration/models/ConfigModel.ts b/packages/databricks-vscode/src/configuration/models/ConfigModel.ts index 0e1df7df9..0ab7cb4d0 100644 --- a/packages/databricks-vscode/src/configuration/models/ConfigModel.ts +++ b/packages/databricks-vscode/src/configuration/models/ConfigModel.ts @@ -2,17 +2,20 @@ import {Disposable, EventEmitter, Uri, Event} from "vscode"; import { DATABRICKS_CONFIG_KEYS, DatabricksConfig, - isBundleConfigKey, + isBundlePreValidateConfigKey, isOverrideableConfigKey, DatabricksConfigSourceMap, + BUNDLE_VALIDATE_CONFIG_KEYS, } from "../types"; import {Mutex} from "../../locking"; import {CachedValue} from "../../locking/CachedValue"; import {StateStorage} from "../../vscode-objs/StateStorage"; import lodash from "lodash"; import {onError} from "../../utils/onErrorDecorator"; -import {BundleFileConfigModel} from "./BundleFileConfigModel"; +import {AuthProvider} from "../auth/AuthProvider"; import {OverrideableConfigModel} from "./OverrideableConfigModel"; +import {BundlePreValidateModel} from "../../bundle/models/BundlePreValidateModel"; +import {BundleValidateModel} from "../../bundle/models/BundleValidateModel"; const defaults: DatabricksConfig = { mode: "development", @@ -32,10 +35,14 @@ export class ConfigModel implements Disposable { if (this.target === undefined) { return {config: {}, source: {}}; } + const bundleValidateConfig = await this.bundleValidateModel.load([ + ...BUNDLE_VALIDATE_CONFIG_KEYS, + ]); const overrides = await this.overrideableConfigModel.load(); - const bundleConfigs = await this.bundleFileConfigModel.load(); + const bundleConfigs = await this.bundlePreValidateModel.load(); const newValue: DatabricksConfig = { ...bundleConfigs, + ...bundleValidateConfig, ...overrides, }; @@ -70,8 +77,9 @@ export class ConfigModel implements Disposable { private _target: string | undefined; constructor( + private readonly bundleValidateModel: BundleValidateModel, private readonly overrideableConfigModel: OverrideableConfigModel, - public readonly bundleFileConfigModel: BundleFileConfigModel, + public readonly bundlePreValidateModel: BundlePreValidateModel, private readonly stateStorage: StateStorage ) { this.disposables.push( @@ -79,11 +87,17 @@ export class ConfigModel implements Disposable { //refresh cache to trigger onDidChange event await this.configCache.refresh(); }), - this.bundleFileConfigModel.onDidChange(async () => { + this.bundlePreValidateModel.onDidChange(async () => { await this.readTarget(); //refresh cache to trigger onDidChange event await this.configCache.refresh(); }), + ...BUNDLE_VALIDATE_CONFIG_KEYS.map((key) => + this.bundleValidateModel.onDidChangeKey(key)(async () => { + //refresh cache to trigger onDidChange event + this.configCache.refresh(); + }) + ), this.configCache.onDidChange(({oldValue, newValue}) => { DATABRICKS_CONFIG_KEYS.forEach((key) => { if ( @@ -123,7 +137,7 @@ export class ConfigModel implements Disposable { */ private async readTarget() { const targets = Object.keys( - (await this.bundleFileConfigModel.targets) ?? {} + (await this.bundlePreValidateModel.targets) ?? {} ); if (targets.includes(this.target ?? "")) { return; @@ -136,7 +150,7 @@ export class ConfigModel implements Disposable { if (savedTarget !== undefined && targets.includes(savedTarget)) { return; } - savedTarget = await this.bundleFileConfigModel.defaultTarget; + savedTarget = await this.bundlePreValidateModel.defaultTarget; }); await this.setTarget(savedTarget); } @@ -156,7 +170,10 @@ export class ConfigModel implements Disposable { if ( this.target !== undefined && - !(this.target in ((await this.bundleFileConfigModel.targets) ?? {})) + !( + this.target in + ((await this.bundlePreValidateModel.targets) ?? {}) + ) ) { throw new Error( `Target '${this.target}' doesn't exist in the bundle` @@ -167,12 +184,18 @@ export class ConfigModel implements Disposable { await this.stateStorage.set("databricks.bundle.target", target); this.changeEmitters.get("target")?.emitter.fire(); await Promise.all([ - this.bundleFileConfigModel.setTarget(target), + this.bundlePreValidateModel.setTarget(target), + this.bundleValidateModel.setTarget(target), this.overrideableConfigModel.setTarget(target), ]); }); } + @onError({popup: {prefix: "Failed to set auth provider."}}) + public async setAuthProvider(authProvider: AuthProvider | undefined) { + await this.bundleValidateModel.setAuthProvider(authProvider); + } + @Mutex.synchronise("configsMutex") public async get( key: T @@ -222,9 +245,9 @@ export class ConfigModel implements Disposable { if (isOverrideableConfigKey(key)) { return this.overrideableConfigModel.write(key, this.target, value); } - if (isBundleConfigKey(key) && handleInteractiveWrite) { + if (isBundlePreValidateConfigKey(key) && handleInteractiveWrite) { await handleInteractiveWrite( - await this.bundleFileConfigModel.getFileToWrite(key) + await this.bundlePreValidateModel.getFileToWrite(key) ); } } diff --git a/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts b/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts index 4e452bf8f..1454fcc87 100644 --- a/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts +++ b/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts @@ -9,23 +9,23 @@ export class OverrideableConfigModel implements Disposable { private disposables: Disposable[] = []; - private readonly overrideableConfigCache = new CachedValue< + private readonly stateCache = new CachedValue< OverrideableConfig | undefined >(async () => { if (this.target === undefined) { return undefined; } - return this.readAll(this.target); + return this.readState(this.target); }); - public readonly onDidChange = this.overrideableConfigCache.onDidChange; + public readonly onDidChange = this.stateCache.onDidChange; private target: string | undefined; constructor(private readonly storage: StateStorage) { this.disposables.push( this.storage.onDidChange("databricks.bundle.overrides")( - async () => await this.overrideableConfigCache.refresh() + async () => await this.stateCache.refresh() ) ); } @@ -34,12 +34,12 @@ export class OverrideableConfigModel implements Disposable { this.target = target; } - private async readAll(target: string) { + private async readState(target: string) { return this.storage.get("databricks.bundle.overrides")[target]; } public async load() { - return this.overrideableConfigCache.value; + return this.stateCache.value; } /** diff --git a/packages/databricks-vscode/src/configuration/types.ts b/packages/databricks-vscode/src/configuration/types.ts index 6b255e513..0d5f480ac 100644 --- a/packages/databricks-vscode/src/configuration/types.ts +++ b/packages/databricks-vscode/src/configuration/types.ts @@ -21,16 +21,35 @@ export type OverrideableConfig = Pick< (typeof OVERRIDEABLE_CONFIG_KEYS)[number] >; -export const BUNDLE_FILE_CONFIG_KEYS = ["authParams", "mode", "host"] as const; +export const BUNDLE_PRE_VALIDATE_CONFIG_KEYS = [ + "authParams", + "mode", + "host", +] as const; /** These are configs which can be loaded from the bundle */ -export type BundleFileConfig = Pick< +export type BundlePreValidateConfig = Pick< DatabricksConfig, - (typeof BUNDLE_FILE_CONFIG_KEYS)[number] + (typeof BUNDLE_PRE_VALIDATE_CONFIG_KEYS)[number] >; -export const DATABRICKS_CONFIG_KEYS = Array.from( - new Set([...OVERRIDEABLE_CONFIG_KEYS, ...BUNDLE_FILE_CONFIG_KEYS]) +export const BUNDLE_VALIDATE_CONFIG_KEYS = [ + "clusterId", + "workspaceFsPath", +] as const; + +/** These are configs which can be loaded from the bundle */ +export type BundleValidateConfig = Pick< + DatabricksConfig, + (typeof BUNDLE_VALIDATE_CONFIG_KEYS)[number] +>; + +export const DATABRICKS_CONFIG_KEYS: (keyof DatabricksConfig)[] = Array.from( + new Set([ + ...OVERRIDEABLE_CONFIG_KEYS, + ...BUNDLE_PRE_VALIDATE_CONFIG_KEYS, + ...BUNDLE_VALIDATE_CONFIG_KEYS, + ]) ); export function isOverrideableConfigKey( @@ -39,11 +58,14 @@ export function isOverrideableConfigKey( return OVERRIDEABLE_CONFIG_KEYS.includes(key); } -export function isBundleConfigKey(key: any): key is keyof BundleFileConfig { - return BUNDLE_FILE_CONFIG_KEYS.includes(key); +export function isBundlePreValidateConfigKey( + key: any +): key is keyof BundlePreValidateConfig { + return BUNDLE_PRE_VALIDATE_CONFIG_KEYS.includes(key); } -export interface ConfigReaderWriter { - readAll(target: string): Promise; - write(key: T, target: string, value?: DatabricksConfig[T]): Promise; +export function isBundleValidateConfigKey( + key: any +): key is keyof BundleValidateConfig { + return BUNDLE_VALIDATE_CONFIG_KEYS.includes(key); } diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 99c22e969..6080ebd57 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -54,9 +54,10 @@ import { registerBundleAutocompleteProvider, } from "./bundle"; import {showWhatsNewPopup} from "./whatsNewPopup"; +import {BundleValidateModel} from "./bundle/models/BundleValidateModel"; import {ConfigModel} from "./configuration/models/ConfigModel"; import {OverrideableConfigModel} from "./configuration/models/OverrideableConfigModel"; -import {BundleFileConfigModel} from "./configuration/models/BundleFileConfigModel"; +import {BundlePreValidateModel} from "./bundle/models/BundlePreValidateModel"; export async function activate( context: ExtensionContext @@ -150,23 +151,28 @@ export async function activate( workspace.onDidChangeConfiguration(updateFeatureContexts) ); + const cli = new CliWrapper(context); const bundleFileSet = new BundleFileSet(workspace.workspaceFolders[0].uri); const bundleFileWatcher = new BundleWatcher(bundleFileSet); context.subscriptions.push(bundleFileWatcher); + const bundleValidateModel = new BundleValidateModel( + bundleFileWatcher, + cli, + workspaceUri + ); const overrideableConfigModel = new OverrideableConfigModel(stateStorage); - const bundleFileConfigModel = new BundleFileConfigModel( + const bundlePreValidateModel = new BundlePreValidateModel( bundleFileSet, bundleFileWatcher ); const configModel = new ConfigModel( + bundleValidateModel, overrideableConfigModel, - bundleFileConfigModel, + bundlePreValidateModel, stateStorage ); - // Configuration group - const cli = new CliWrapper(context); const connectionManager = new ConnectionManager( cli, configModel, diff --git a/packages/databricks-vscode/src/utils/envVarGenerators.ts b/packages/databricks-vscode/src/utils/envVarGenerators.ts index a44031c9a..c7966d731 100644 --- a/packages/databricks-vscode/src/utils/envVarGenerators.ts +++ b/packages/databricks-vscode/src/utils/envVarGenerators.ts @@ -127,6 +127,18 @@ export function getProxyEnvVars() { }; } +export function getEnvVarsForCli(configfilePath?: string) { + /* eslint-disable @typescript-eslint/naming-convention */ + return { + HOME: process.env.HOME, + PATH: process.env.PATH, + DATABRICKS_CONFIG_FILE: + configfilePath ?? process.env.DATABRICKS_CONFIG_FILE, + DATABRICKS_OUTPUT_FORMAT: "json", + }; + /* eslint-enable @typescript-eslint/naming-convention */ +} + export function removeUndefinedKeys< T extends Record, >(envVarMap?: T): T | undefined { diff --git a/packages/databricks-vscode/src/utils/onErrorDecorator.ts b/packages/databricks-vscode/src/utils/onErrorDecorator.ts index 041b71c3e..21ba8c114 100644 --- a/packages/databricks-vscode/src/utils/onErrorDecorator.ts +++ b/packages/databricks-vscode/src/utils/onErrorDecorator.ts @@ -60,7 +60,7 @@ export function onError(props: Props) { ? props.popup.prefix ?? "" : ""; window.showErrorMessage( - prefix.trimEnd() + ": " + e.message.trimStart() + prefix.trimEnd() + " " + e.message.trimStart() ); } if (props.log !== undefined && props.log !== false) { @@ -76,7 +76,7 @@ export function onError(props: Props) { ? prefix : props.log.prefix; logger?.error( - prefix.trimEnd() + ": " + e.message.trimStart(), + prefix.trimEnd() + " " + e.message.trimStart(), e ); }