diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3c341a6e19..c6ccb7dbdc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -253,11 +253,11 @@ Logs provide context for what was happening when the issue occurred. **You shoul your logs for any sensitive information you would not like to share online!** * Before sending through logs, try and reproduce the issue with **log level set to - Diagnostic**. You can set this in the [VS Code Settings][] + Trace**. You can set this in the [VS Code Settings][] (<kbd>Ctrl</kbd>+<kbd>,</kbd>) with: ```json - "powershell.developer.editorServicesLogLevel": "Diagnostic" + "powershell.developer.editorServicesLogLevel": "Trace" ``` * After you have captured the issue with the log level turned up, you may want to return diff --git a/package.json b/package.json index 55b397dab6..73687b5fae 100644 --- a/package.json +++ b/package.json @@ -916,24 +916,24 @@ }, "powershell.developer.editorServicesLogLevel": { "type": "string", - "default": "Normal", + "default": "Warning", "enum": [ - "Diagnostic", - "Verbose", - "Normal", + "Trace", + "Debug", + "Information", "Warning", "Error", "None" ], "markdownEnumDescriptions": [ "Enables all logging possible, please use this setting when submitting logs for bug reports!", - "Enables more logging than normal.", - "The default logging level.", - "Only log warnings and errors.", + "Enables more detailed logging of the extension", + "Logs high-level information about what the extension is doing.", + "Only log warnings and errors. This is the default setting", "Only log errors.", "Disable all logging possible. No log files will be written!" ], - "markdownDescription": "Sets the log verbosity for both the extension and its LSP server, PowerShell Editor Services. **Please set to `Diagnostic` when recording logs for a bug report!**" + "markdownDescription": "Sets the log verbosity for both the extension and its LSP server, PowerShell Editor Services. **Please set to `Trace` when recording logs for a bug report!**" }, "powershell.developer.editorServicesWaitForDebugger": { "type": "boolean", @@ -953,6 +953,21 @@ "default": [], "markdownDescription": "An array of strings that enable experimental features in the PowerShell extension. **No flags are currently available!**" }, + "powershell.developer.traceDap": { + "type": "boolean", + "default": false, + "markdownDescription": "Traces the DAP communication between VS Code and the PowerShell Editor Services [DAP Server](https://microsoft.github.io/debug-adapter-protocol/). The output will be logged and also visible in the Output pane, where the verbosity is configurable. **For extension developers and issue troubleshooting only!**" + }, + "powershell.trace.server": { + "type": "string", + "enum": [ + "off", + "messages", + "verbose" + ], + "default": "off", + "markdownDescription": "Traces the communication between VS Code and the PowerShell Editor Services [LSP Server](https://microsoft.github.io/language-server-protocol/). The output will be logged and also visible in the Output pane, where the verbosity is configurable. **For extension developers and issue troubleshooting only!**" + }, "powershell.developer.waitForSessionFileTimeoutSeconds": { "type": "number", "default": 240, @@ -1002,21 +1017,6 @@ "type": "boolean", "default": false, "markdownDescription": "Show buttons in the editor's title bar for moving the terminals pane (with the PowerShell Extension Terminal) around." - }, - "powershell.trace.server": { - "type": "string", - "enum": [ - "off", - "messages", - "verbose" - ], - "default": "off", - "markdownDescription": "Traces the communication between VS Code and the PowerShell Editor Services [LSP Server](https://microsoft.github.io/language-server-protocol/). **only for extension developers and issue troubleshooting!**" - }, - "powershell.trace.dap": { - "type": "boolean", - "default": false, - "markdownDescription": "Traces the communication between VS Code and the PowerShell Editor Services [DAP Server](https://microsoft.github.io/debug-adapter-protocol/). **This setting is only meant for extension developers and issue troubleshooting!**" } } }, diff --git a/src/extension.ts b/src/extension.ts index 0cb8f3f52e..8676724ab1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,7 +22,7 @@ import { ShowHelpFeature } from "./features/ShowHelp"; import { SpecifyScriptArgsFeature } from "./features/DebugSession"; import { Logger } from "./logging"; import { SessionManager } from "./session"; -import { LogLevel, getSettings } from "./settings"; +import { getSettings } from "./settings"; import { PowerShellLanguageId } from "./utils"; import { LanguageClientConsumer } from "./languageClientConsumer"; @@ -43,14 +43,12 @@ const documentSelector: DocumentSelector = [ ]; export async function activate(context: vscode.ExtensionContext): Promise<IPowerShellExtensionClient> { - const logLevel = vscode.workspace.getConfiguration(`${PowerShellLanguageId}.developer`) - .get<string>("editorServicesLogLevel", LogLevel.Normal); - logger = new Logger(logLevel, context.globalStorageUri); + logger = new Logger(); telemetryReporter = new TelemetryReporter(TELEMETRY_KEY); const settings = getSettings(); - logger.writeVerbose(`Loaded settings:\n${JSON.stringify(settings, undefined, 2)}`); + logger.writeDebug(`Loaded settings:\n${JSON.stringify(settings, undefined, 2)}`); languageConfigurationDisposable = vscode.languages.setLanguageConfiguration( PowerShellLanguageId, @@ -141,6 +139,19 @@ export async function activate(context: vscode.ExtensionContext): Promise<IPower new PesterTestsFeature(sessionManager, logger), new CodeActionsFeature(logger), new SpecifyScriptArgsFeature(context), + + vscode.commands.registerCommand( + "PowerShell.OpenLogFolder", + async () => {await vscode.commands.executeCommand( + "vscode.openFolder", + context.logUri, + { forceNewWindow: true } + );} + ), + vscode.commands.registerCommand( + "PowerShell.ShowLogs", + () => {logger.showLogPanel();} + ) ]; const externalApi = new ExternalApiFeature(context, sessionManager, logger); @@ -169,6 +180,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<IPower getPowerShellVersionDetails: uuid => externalApi.getPowerShellVersionDetails(uuid), waitUntilStarted: uuid => externalApi.waitUntilStarted(uuid), getStorageUri: () => externalApi.getStorageUri(), + getLogUri: () => externalApi.getLogUri(), }; } diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index f9e4feae07..4af8c83b89 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -335,8 +335,8 @@ export class DebugSessionFeature extends LanguageClientConsumer // Create or show the debug terminal (either temporary or session). this.sessionManager.showDebugTerminal(true); - this.logger.writeVerbose(`Connecting to pipe: ${sessionDetails.debugServicePipeName}`); - this.logger.writeVerbose(`Debug configuration: ${JSON.stringify(session.configuration, undefined, 2)}`); + this.logger.writeDebug(`Connecting to pipe: ${sessionDetails.debugServicePipeName}`); + this.logger.writeDebug(`Debug configuration: ${JSON.stringify(session.configuration, undefined, 2)}`); return new DebugAdapterNamedPipeServer(sessionDetails.debugServicePipeName); } @@ -424,7 +424,7 @@ export class DebugSessionFeature extends LanguageClientConsumer // The dispose shorthand demonry for making an event one-time courtesy of: https://github.com/OmniSharp/omnisharp-vscode/blob/b8b07bb12557b4400198895f82a94895cb90c461/test/integrationTests/launchConfiguration.integration.test.ts#L41-L45 startDebugEvent.dispose(); - this.logger.writeVerbose(`Debugger session detected: ${dotnetAttachSession.name} (${dotnetAttachSession.id})`); + this.logger.writeDebug(`Debugger session detected: ${dotnetAttachSession.name} (${dotnetAttachSession.id})`); tempConsoleDotnetAttachSession = dotnetAttachSession; @@ -434,7 +434,7 @@ export class DebugSessionFeature extends LanguageClientConsumer // Makes the event one-time stopDebugEvent.dispose(); - this.logger.writeVerbose(`Debugger session terminated: ${tempConsoleSession.name} (${tempConsoleSession.id})`); + this.logger.writeDebug(`Debugger session terminated: ${tempConsoleSession.name} (${tempConsoleSession.id})`); // HACK: As of 2023-08-17, there is no vscode debug API to request the C# debugger to detach, so we send it a custom DAP request instead. const disconnectRequest: DebugProtocol.DisconnectRequest = { @@ -462,8 +462,8 @@ export class DebugSessionFeature extends LanguageClientConsumer // Start a child debug session to attach the dotnet debugger // TODO: Accommodate multi-folder workspaces if the C# code is in a different workspace folder await debug.startDebugging(undefined, dotnetAttachConfig, session); - this.logger.writeVerbose(`Dotnet attach debug configuration: ${JSON.stringify(dotnetAttachConfig, undefined, 2)}`); - this.logger.writeVerbose(`Attached dotnet debugger to process: ${pid}`); + this.logger.writeDebug(`Dotnet attach debug configuration: ${JSON.stringify(dotnetAttachConfig, undefined, 2)}`); + this.logger.writeDebug(`Attached dotnet debugger to process: ${pid}`); } return this.tempSessionDetails; @@ -606,36 +606,27 @@ export class DebugSessionFeature extends LanguageClientConsumer class PowerShellDebugAdapterTrackerFactory implements DebugAdapterTrackerFactory, Disposable { disposables: Disposable[] = []; - dapLogEnabled: boolean = workspace.getConfiguration("powershell").get<boolean>("trace.dap") ?? false; - constructor(private adapterName = "PowerShell") { - this.disposables.push(workspace.onDidChangeConfiguration(change => { - if ( - change.affectsConfiguration("powershell.trace.dap") - ) { - this.dapLogEnabled = workspace.getConfiguration("powershell").get<boolean>("trace.dap") ?? false; - if (this.dapLogEnabled) { - // Trigger the output pane to appear. This gives the user time to position it before starting a debug. - this.log?.show(true); - } - } - })); - } + constructor(private adapterName = "PowerShell") {} - /* We want to use a shared output log for separate debug sessions as usually only one is running at a time and we - * dont need an output window for every debug session. We also want to leave it active so user can copy and paste - * even on run end. When user changes the setting and disables it getter will return undefined, which will result + + _log: LogOutputChannel | undefined; + /** Lazily creates a {@link LogOutputChannel} for debug tracing, and presents it only when DAP logging is enabled. + * + * We want to use a shared output log for separate debug sessions as usually only one is running at a time and we + * dont need an output window for every debug session. We also want to leave it active so user can copy and paste + * even on run end. When user changes the setting and disables it getter will return undefined, which will result * in a noop for the logging activities, effectively pausing logging but not disposing the output channel. If the * user re-enables, then logging resumes. */ - _log: LogOutputChannel | undefined; get log(): LogOutputChannel | undefined { - if (this.dapLogEnabled && this._log === undefined) { - this._log = window.createOutputChannel(`${this.adapterName} Trace - DAP`, { log: true }); + if (workspace.getConfiguration("powershell.developer").get<boolean>("traceDap") && this._log === undefined) { + this._log = window.createOutputChannel(`${this.adapterName}: Trace DAP`, { log: true }); this.disposables.push(this._log); } - return this.dapLogEnabled ? this._log : undefined; + return this._log; } + // This tracker effectively implements the logging for the debug adapter to a LogOutputChannel createDebugAdapterTracker(session: DebugSession): DebugAdapterTracker { const sessionInfo = `${this.adapterName} Debug Session: ${session.name} [${session.id}]`; return { diff --git a/src/features/ExternalApi.ts b/src/features/ExternalApi.ts index 7943bf8fa6..29e3427f88 100644 --- a/src/features/ExternalApi.ts +++ b/src/features/ExternalApi.ts @@ -19,6 +19,7 @@ export interface IPowerShellExtensionClient { getPowerShellVersionDetails(uuid: string): Promise<IExternalPowerShellDetails>; waitUntilStarted(uuid: string): Promise<void>; getStorageUri(): vscode.Uri; + getLogUri(): vscode.Uri; } /* @@ -55,7 +56,7 @@ export class ExternalApiFeature implements IPowerShellExtensionClient { string session uuid */ public registerExternalExtension(id: string, apiVersion = "v1"): string { - this.logger.writeVerbose(`Registering extension '${id}' for use with API version '${apiVersion}'.`); + this.logger.writeDebug(`Registering extension '${id}' for use with API version '${apiVersion}'.`); // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [_name, externalExtension] of ExternalApiFeature.registeredExternalExtension) { @@ -96,7 +97,7 @@ export class ExternalApiFeature implements IPowerShellExtensionClient { true if it worked, otherwise throws an error. */ public unregisterExternalExtension(uuid = ""): boolean { - this.logger.writeVerbose(`Unregistering extension with session UUID: ${uuid}`); + this.logger.writeDebug(`Unregistering extension with session UUID: ${uuid}`); if (!ExternalApiFeature.registeredExternalExtension.delete(uuid)) { throw new Error(`No extension registered with session UUID: ${uuid}`); } @@ -133,7 +134,7 @@ export class ExternalApiFeature implements IPowerShellExtensionClient { */ public async getPowerShellVersionDetails(uuid = ""): Promise<IExternalPowerShellDetails> { const extension = this.getRegisteredExtension(uuid); - this.logger.writeVerbose(`Extension '${extension.id}' called 'getPowerShellVersionDetails'.`); + this.logger.writeDebug(`Extension '${extension.id}' called 'getPowerShellVersionDetails'.`); await this.sessionManager.waitUntilStarted(); const versionDetails = this.sessionManager.getPowerShellVersionDetails(); @@ -161,7 +162,7 @@ export class ExternalApiFeature implements IPowerShellExtensionClient { */ public async waitUntilStarted(uuid = ""): Promise<void> { const extension = this.getRegisteredExtension(uuid); - this.logger.writeVerbose(`Extension '${extension.id}' called 'waitUntilStarted'.`); + this.logger.writeDebug(`Extension '${extension.id}' called 'waitUntilStarted'.`); await this.sessionManager.waitUntilStarted(); } @@ -171,6 +172,10 @@ export class ExternalApiFeature implements IPowerShellExtensionClient { return this.extensionContext.globalStorageUri.with({ scheme: "file"}); } + public getLogUri(): vscode.Uri { + return this.extensionContext.logUri.with({ scheme: "file"}); + } + public dispose(): void { // Nothing to dispose. } diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 01c31eb385..6805272cc8 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -51,20 +51,20 @@ export class UpdatePowerShell { private shouldCheckForUpdate(): boolean { // Respect user setting. if (!this.sessionSettings.promptToUpdatePowerShell) { - this.logger.writeVerbose("Setting 'promptToUpdatePowerShell' was false."); + this.logger.writeDebug("Setting 'promptToUpdatePowerShell' was false."); return false; } // Respect environment configuration. if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") { - this.logger.writeVerbose("Environment variable 'POWERSHELL_UPDATECHECK' was 'Off'."); + this.logger.writeDebug("Environment variable 'POWERSHELL_UPDATECHECK' was 'Off'."); return false; } // Skip prompting when using Windows PowerShell for now. if (this.localVersion.compare("6.0.0") === -1) { // TODO: Maybe we should announce PowerShell Core? - this.logger.writeVerbose("Not prompting to update Windows PowerShell."); + this.logger.writeDebug("Not prompting to update Windows PowerShell."); return false; } @@ -78,13 +78,13 @@ export class UpdatePowerShell { // Skip if PowerShell is self-built, that is, this contains a commit hash. if (commit.length >= 40) { - this.logger.writeVerbose("Not prompting to update development build."); + this.logger.writeDebug("Not prompting to update development build."); return false; } // Skip if preview is a daily build. if (daily.toLowerCase().startsWith("daily")) { - this.logger.writeVerbose("Not prompting to update daily build."); + this.logger.writeDebug("Not prompting to update daily build."); return false; } } @@ -106,7 +106,7 @@ export class UpdatePowerShell { // "ReleaseTag": "v7.2.7" // } const data = await response.json(); - this.logger.writeVerbose(`Received from '${url}':\n${JSON.stringify(data, undefined, 2)}`); + this.logger.writeDebug(`Received from '${url}':\n${JSON.stringify(data, undefined, 2)}`); return data.ReleaseTag; } @@ -115,18 +115,18 @@ export class UpdatePowerShell { return undefined; } - this.logger.writeVerbose("Checking for PowerShell update..."); + this.logger.writeDebug("Checking for PowerShell update..."); const tags: string[] = []; if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts") { // Only check for update to LTS. - this.logger.writeVerbose("Checking for LTS update..."); + this.logger.writeDebug("Checking for LTS update..."); const tag = await this.getRemoteVersion(UpdatePowerShell.LTSBuildInfoURL); if (tag != undefined) { tags.push(tag); } } else { // Check for update to stable. - this.logger.writeVerbose("Checking for stable update..."); + this.logger.writeDebug("Checking for stable update..."); const tag = await this.getRemoteVersion(UpdatePowerShell.StableBuildInfoURL); if (tag != undefined) { tags.push(tag); @@ -134,7 +134,7 @@ export class UpdatePowerShell { // Also check for a preview update. if (this.localVersion.prerelease.length > 0) { - this.logger.writeVerbose("Checking for preview update..."); + this.logger.writeDebug("Checking for preview update..."); const tag = await this.getRemoteVersion(UpdatePowerShell.PreviewBuildInfoURL); if (tag != undefined) { tags.push(tag); @@ -181,11 +181,11 @@ export class UpdatePowerShell { // If the user cancels the notification. if (!result) { - this.logger.writeVerbose("User canceled PowerShell update prompt."); + this.logger.writeDebug("User canceled PowerShell update prompt."); return; } - this.logger.writeVerbose(`User said '${UpdatePowerShell.promptOptions[result.id].title}'.`); + this.logger.writeDebug(`User said '${UpdatePowerShell.promptOptions[result.id].title}'.`); switch (result.id) { // Yes diff --git a/src/logging.ts b/src/logging.ts index a7176c08ef..7ce8d09e16 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,30 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import utils = require("./utils"); -import os = require("os"); -import vscode = require("vscode"); - -// NOTE: This is not a string enum because the order is used for comparison. -export enum LogLevel { - Diagnostic, - Verbose, - Normal, - Warning, - Error, - None, -} +import { LogOutputChannel, LogLevel, window, Event } from "vscode"; /** Interface for logging operations. New features should use this interface for the "type" of logger. * This will allow for easy mocking of the logger during unit tests. */ export interface ILogger { - logDirectoryPath: vscode.Uri; - updateLogLevel(logLevelName: string): void; write(message: string, ...additionalMessages: string[]): void; writeAndShowInformation(message: string, ...additionalMessages: string[]): Promise<void>; - writeDiagnostic(message: string, ...additionalMessages: string[]): void; - writeVerbose(message: string, ...additionalMessages: string[]): void; + writeTrace(message: string, ...additionalMessages: string[]): void; + writeDebug(message: string, ...additionalMessages: string[]): void; writeWarning(message: string, ...additionalMessages: string[]): void; writeAndShowWarning(message: string, ...additionalMessages: string[]): Promise<void>; writeError(message: string, ...additionalMessages: string[]): void; @@ -35,47 +21,16 @@ export interface ILogger { } export class Logger implements ILogger { - public logDirectoryPath: vscode.Uri; // The folder for all the logs - private logLevel: LogLevel; - private commands: vscode.Disposable[]; - private logChannel: vscode.OutputChannel; - private logFilePath: vscode.Uri; // The client's logs - private logDirectoryCreated = false; - private writingLog = false; - - constructor(logLevelName: string, globalStorageUri: vscode.Uri) { - this.logLevel = Logger.logLevelNameToValue(logLevelName); - this.logChannel = vscode.window.createOutputChannel("PowerShell Extension Logs"); - // We have to override the scheme because it defaults to - // 'vscode-userdata' which breaks UNC paths. - this.logDirectoryPath = vscode.Uri.joinPath( - globalStorageUri.with({ scheme: "file" }), - "logs", - `${Math.floor(Date.now() / 1000)}-${vscode.env.sessionId}`); - this.logFilePath = vscode.Uri.joinPath(this.logDirectoryPath, "vscode-powershell.log"); - - // Early logging of the log paths for debugging. - if (LogLevel.Diagnostic >= this.logLevel) { - const uriMessage = Logger.timestampMessage(`Log file path: '${this.logFilePath}'`, LogLevel.Verbose); - this.logChannel.appendLine(uriMessage); - } - - this.commands = [ - vscode.commands.registerCommand( - "PowerShell.ShowLogs", - () => { this.showLogPanel(); }), + // Log output channel handles all the verbosity management so we don't have to. + private logChannel: LogOutputChannel; + public get logLevel(): LogLevel { return this.logChannel.logLevel;} - vscode.commands.registerCommand( - "PowerShell.OpenLogFolder", - async () => { await this.openLogFolder(); }), - ]; + constructor(logChannel?: LogOutputChannel) { + this.logChannel = logChannel ?? window.createOutputChannel("PowerShell", {log: true}); } public dispose(): void { this.logChannel.dispose(); - for (const command of this.commands) { - command.dispose(); - } } private writeAtLevel(logLevel: LogLevel, message: string, ...additionalMessages: string[]): void { @@ -89,24 +44,24 @@ export class Logger implements ILogger { } public write(message: string, ...additionalMessages: string[]): void { - this.writeAtLevel(LogLevel.Normal, message, ...additionalMessages); + this.writeAtLevel(LogLevel.Info, message, ...additionalMessages); } public async writeAndShowInformation(message: string, ...additionalMessages: string[]): Promise<void> { this.write(message, ...additionalMessages); - const selection = await vscode.window.showInformationMessage(message, "Show Logs", "Okay"); + const selection = await window.showInformationMessage(message, "Show Logs", "Okay"); if (selection === "Show Logs") { this.showLogPanel(); } } - public writeDiagnostic(message: string, ...additionalMessages: string[]): void { - this.writeAtLevel(LogLevel.Diagnostic, message, ...additionalMessages); + public writeTrace(message: string, ...additionalMessages: string[]): void { + this.writeAtLevel(LogLevel.Trace, message, ...additionalMessages); } - public writeVerbose(message: string, ...additionalMessages: string[]): void { - this.writeAtLevel(LogLevel.Verbose, message, ...additionalMessages); + public writeDebug(message: string, ...additionalMessages: string[]): void { + this.writeAtLevel(LogLevel.Debug, message, ...additionalMessages); } public writeWarning(message: string, ...additionalMessages: string[]): void { @@ -116,7 +71,7 @@ export class Logger implements ILogger { public async writeAndShowWarning(message: string, ...additionalMessages: string[]): Promise<void> { this.writeWarning(message, ...additionalMessages); - const selection = await vscode.window.showWarningMessage(message, "Show Logs"); + const selection = await window.showWarningMessage(message, "Show Logs"); if (selection !== undefined) { this.showLogPanel(); } @@ -129,7 +84,7 @@ export class Logger implements ILogger { public async writeAndShowError(message: string, ...additionalMessages: string[]): Promise<void> { this.writeError(message, ...additionalMessages); - const choice = await vscode.window.showErrorMessage(message, "Show Logs"); + const choice = await window.showErrorMessage(message, "Show Logs"); if (choice !== undefined) { this.showLogPanel(); } @@ -147,7 +102,7 @@ export class Logger implements ILogger { const actionKeys: string[] = fullActions.map((action) => action.prompt); - const choice = await vscode.window.showErrorMessage(message, ...actionKeys); + const choice = await window.showErrorMessage(message, ...actionKeys); if (choice) { for (const action of fullActions) { if (choice === action.prompt && action.action !== undefined ) { @@ -158,70 +113,177 @@ export class Logger implements ILogger { } } - // TODO: Make the enum smarter about strings so this goes away. - private static logLevelNameToValue(logLevelName: string): LogLevel { - switch (logLevelName.trim().toLowerCase()) { - case "diagnostic": return LogLevel.Diagnostic; - case "verbose": return LogLevel.Verbose; - case "normal": return LogLevel.Normal; - case "warning": return LogLevel.Warning; - case "error": return LogLevel.Error; - case "none": return LogLevel.None; - default: return LogLevel.Normal; + public showLogPanel(): void { + this.logChannel.show(); + } + + private async writeLine(message: string, level: LogLevel = LogLevel.Info): Promise<void> { + return new Promise<void>((resolve) => { + switch (level) { + case LogLevel.Off: break; + case LogLevel.Trace: this.logChannel.trace(message); break; + case LogLevel.Debug: this.logChannel.debug(message); break; + case LogLevel.Info: this.logChannel.info(message); break; + case LogLevel.Warning: this.logChannel.warn(message); break; + case LogLevel.Error: this.logChannel.error(message); break; + default: this.logChannel.appendLine(message); break; + } + resolve(); + }); + } +} + +/** Parses logs received via the legacy OutputChannel to LogOutputChannel with proper severity. + * + * HACK: This is for legacy compatability and can be removed when https://github.com/microsoft/vscode-languageserver-node/issues/1116 is merged and replaced with a normal LogOutputChannel. We don't use a middleware here because any direct logging calls like client.warn() and server-initiated messages would not be captured by middleware. + */ +export class LanguageClientOutputChannelAdapter implements LogOutputChannel { + private _channel: LogOutputChannel | undefined; + private get channel(): LogOutputChannel { + if (!this._channel) { + this._channel = window.createOutputChannel(this.channelName, {log: true}); } + return this._channel; } - public updateLogLevel(logLevelName: string): void { - this.logLevel = Logger.logLevelNameToValue(logLevelName); + /** + * Creates an instance of the logging class. + * + * @param channelName - The name of the output channel. + * @param parser - A function that parses a log message and returns a tuple containing the parsed message and its log level, or undefined if the log should be filtered. + */ + constructor( + private channelName: string, + private parser: (message: string) => [string, LogLevel] | undefined = LanguageClientOutputChannelAdapter.omnisharpLspParser.bind(this) + ) { } - private showLogPanel(): void { - this.logChannel.show(); + public appendLine(message: string): void { + this.append(message); } - private async openLogFolder(): Promise<void> { - if (this.logDirectoryCreated) { - // Open the folder in VS Code since there isn't an easy way to - // open the folder in the platform's file browser - await vscode.commands.executeCommand("vscode.openFolder", this.logDirectoryPath, true); - } else { - void this.writeAndShowError("Cannot open PowerShell log directory as it does not exist!"); - } + public append(message: string): void { + const parseResult = this.parser(message); + if (parseResult !== undefined) {this.sendLogMessage(...parseResult);} } - private static timestampMessage(message: string, level: LogLevel): string { - const now = new Date(); - return `${now.toLocaleDateString()} ${now.toLocaleTimeString()} [${LogLevel[level].toUpperCase()}] - ${message}${os.EOL}`; + /** Converts from Omnisharp logs since middleware for LogMessage does not currently exist **/ + public static omnisharpLspParser(message: string): [string, LogLevel] { + const logLevelMatch = /^\[(?<level>Trace|Debug|Info|Warn|Error) +- \d+:\d+:\d+ [AP]M\] (?<message>.+)/.exec(message); + const logLevel: LogLevel = logLevelMatch?.groups?.level + ? LogLevel[logLevelMatch.groups.level as keyof typeof LogLevel] + : LogLevel.Info; + const logMessage = logLevelMatch?.groups?.message ?? message; + + return [logMessage, logLevel]; } - // TODO: Should we await this function above? - private async writeLine(message: string, level: LogLevel = LogLevel.Normal): Promise<void> { - const timestampedMessage = Logger.timestampMessage(message, level); - this.logChannel.appendLine(timestampedMessage); - if (this.logLevel !== LogLevel.None) { - // A simple lock because this function isn't re-entrant. - while (this.writingLog) { - await utils.sleep(300); - } - try { - this.writingLog = true; - if (!this.logDirectoryCreated) { - this.writeVerbose(`Creating log directory at: '${this.logDirectoryPath}'`); - await vscode.workspace.fs.createDirectory(this.logDirectoryPath); - this.logDirectoryCreated = true; - } - let log = new Uint8Array(); - if (await utils.checkIfFileExists(this.logFilePath)) { - log = await vscode.workspace.fs.readFile(this.logFilePath); - } - await vscode.workspace.fs.writeFile( - this.logFilePath, - Buffer.concat([log, Buffer.from(timestampedMessage)])); - } catch (err) { - console.log(`Error writing to vscode-powershell log file: ${err}`); - } finally { - this.writingLog = false; - } + protected sendLogMessage(message: string, level: LogLevel): void { + switch (level) { + case LogLevel.Trace: + this.channel.trace(message); + break; + case LogLevel.Debug: + this.channel.debug(message); + break; + case LogLevel.Info: + this.channel.info(message); + break; + case LogLevel.Warning: + this.channel.warn(message); + break; + case LogLevel.Error: + this.channel.error(message); + break; + default: + this.channel.error("!UNKNOWN LOG LEVEL!: " + message); + break; } } + + // #region Passthru Implementation + public get name(): string { + // prevents the window from being created unless we get a log request + return this.channelName; + } + public get logLevel(): LogLevel { + return this.channel.logLevel; + } + replace(value: string): void { + this.channel.replace(value); + } + show(_column?: undefined, preserveFocus?: boolean): void { + this.channel.show(preserveFocus); + } + public get onDidChangeLogLevel(): Event<LogLevel> { + return this.channel.onDidChangeLogLevel; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public trace(message: string, ...args: any[]): void { + this.channel.trace(message, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public debug(message: string, ...args: any[]): void { + this.channel.debug(message, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public info(message: string, ...args: any[]): void { + this.channel.info(message, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public warn(message: string, ...args: any[]): void { + this.channel.warn(message, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public error(message: string, ...args: any[]): void { + this.channel.error(message, ...args); + } + public clear(): void { + this.channel.clear(); + } + public hide(): void { + this.channel.hide(); + } + public dispose(): void { + this.channel.dispose(); + } + // #endregion +} + +/** Special parsing for PowerShell Editor Services LSP messages since the LogLevel cannot be read due to vscode + * LanguageClient Limitations (https://github.com/microsoft/vscode-languageserver-node/issues/1116) + */ +export function PsesParser(message: string): [string, LogLevel] { + const logLevelMatch = /^<(?<level>Trace|Debug|Info|Warning|Error)>(?<message>.+)/.exec(message); + const logLevel: LogLevel = logLevelMatch?.groups?.level + ? LogLevel[logLevelMatch.groups.level as keyof typeof LogLevel] + : LogLevel.Info; + const logMessage = logLevelMatch?.groups?.message ?? message; + + return ["[PSES] " + logMessage, logLevel]; +} + +/** Lsp Trace Parser that does some additional parsing and formatting to make it look nicer */ +export function LspTraceParser(message: string): [string, LogLevel] { + let [parsedMessage, level] = LanguageClientOutputChannelAdapter.omnisharpLspParser(message); + if (parsedMessage.startsWith("Sending ")) { + parsedMessage = parsedMessage.replace("Sending", "➡️"); + level = LogLevel.Debug; + } + if (parsedMessage.startsWith("Received ")) { + parsedMessage = parsedMessage.replace("Received", "⬅️"); + level = LogLevel.Debug; + } + if (parsedMessage.startsWith("Params:") + || parsedMessage.startsWith("Result:") + ) { + level = LogLevel.Trace; + } + + // These are PSES messages that get logged to the output channel anyways so we drop these to trace for easy noise filtering + if (parsedMessage.startsWith("⬅️ notification 'window/logMessage'")) { + level = LogLevel.Trace; + } + + return [parsedMessage.trimEnd(), level]; } diff --git a/src/process.ts b/src/process.ts index d363c8e2ee..052da2b8bf 100644 --- a/src/process.ts +++ b/src/process.ts @@ -30,6 +30,7 @@ export class PowerShellProcess { private isTemp: boolean, private shellIntegrationEnabled: boolean, private logger: ILogger, + private logDirectoryPath: vscode.Uri, private startPsesArgs: string, private sessionFilePath: vscode.Uri, private sessionSettings: Settings) { @@ -51,7 +52,7 @@ export class PowerShellProcess { : ""; this.startPsesArgs += - `-LogPath '${utils.escapeSingleQuotes(this.logger.logDirectoryPath.fsPath)}' ` + + `-LogPath '${utils.escapeSingleQuotes(this.logDirectoryPath.fsPath)}' ` + `-SessionDetailsPath '${utils.escapeSingleQuotes(this.sessionFilePath.fsPath)}' ` + `-FeatureFlags @(${featureFlags}) `; @@ -89,13 +90,13 @@ export class PowerShellProcess { startEditorServices); } else { // Otherwise use -EncodedCommand for better quote support. - this.logger.writeVerbose("Using Base64 -EncodedCommand but logging as -Command equivalent."); + this.logger.writeDebug("Using Base64 -EncodedCommand but logging as -Command equivalent."); powerShellArgs.push( "-EncodedCommand", Buffer.from(startEditorServices, "utf16le").toString("base64")); } - this.logger.writeVerbose(`Starting process: ${this.exePath} ${powerShellArgs.slice(0, -2).join(" ")} -Command ${startEditorServices}`); + this.logger.writeDebug(`Starting process: ${this.exePath} ${powerShellArgs.slice(0, -2).join(" ")} -Command ${startEditorServices}`); // Make sure no old session file exists await this.deleteSessionFile(this.sessionFilePath); @@ -173,7 +174,7 @@ export class PowerShellProcess { } public dispose(): void { - this.logger.writeVerbose(`Disposing PowerShell process with PID: ${this.pid}`); + this.logger.writeDebug(`Disposing PowerShell process with PID: ${this.pid}`); void this.deleteSessionFile(this.sessionFilePath); @@ -226,7 +227,7 @@ export class PowerShellProcess { const warnAt = numOfTries - PowerShellProcess.warnUserThreshold; // Check every second. - this.logger.writeVerbose(`Waiting for session file: ${this.sessionFilePath}`); + this.logger.writeDebug(`Waiting for session file: ${this.sessionFilePath}`); for (let i = numOfTries; i > 0; i--) { if (cancellationToken.isCancellationRequested) { this.logger.writeWarning("Canceled while waiting for session file."); @@ -239,7 +240,7 @@ export class PowerShellProcess { } if (await utils.checkIfFileExists(this.sessionFilePath)) { - this.logger.writeVerbose("Session file found."); + this.logger.writeDebug("Session file found."); return await this.readSessionFile(this.sessionFilePath); } diff --git a/src/session.ts b/src/session.ts index a5f4314845..0b1037a116 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,7 +6,7 @@ import path = require("path"); import vscode = require("vscode"); import TelemetryReporter, { TelemetryEventProperties, TelemetryEventMeasurements } from "@vscode/extension-telemetry"; import { Message } from "vscode-jsonrpc"; -import { ILogger } from "./logging"; +import { ILogger, LanguageClientOutputChannelAdapter, LspTraceParser, PsesParser } from "./logging"; import { PowerShellProcess } from "./process"; import { Settings, changeSetting, getSettings, getEffectiveConfigurationTarget, validateCwdSetting } from "./settings"; import utils = require("./utils"); @@ -14,7 +14,8 @@ import utils = require("./utils"); import { CloseAction, CloseHandlerResult, DocumentSelector, ErrorAction, ErrorHandlerResult, LanguageClientOptions, Middleware, NotificationType, - RequestType0, ResolveCodeLensSignature, RevealOutputChannelOn + RequestType0, ResolveCodeLensSignature, + RevealOutputChannelOn, } from "vscode-languageclient"; import { LanguageClient, StreamInfo } from "vscode-languageclient/node"; @@ -93,6 +94,7 @@ export class SessionManager implements Middleware { private startCancellationTokenSource: vscode.CancellationTokenSource | undefined; private suppressRestartPrompt = false; private versionDetails: IPowerShellVersionDetails | undefined; + private traceLogLevelHandler?: vscode.Disposable; constructor( private extensionContext: vscode.ExtensionContext, @@ -104,7 +106,6 @@ export class SessionManager implements Middleware { hostVersion: string, publisher: string, private telemetryReporter: TelemetryReporter) { - // Create the language status item this.languageStatusItem = this.createStatusBarItem(); // We have to override the scheme because it defaults to @@ -161,7 +162,7 @@ export class SessionManager implements Middleware { return; case SessionStatus.Running: // We're started, just return. - this.logger.writeVerbose("Already started."); + this.logger.writeDebug("Already started."); return; case SessionStatus.Busy: // We're started but busy so notify and return. @@ -170,12 +171,12 @@ export class SessionManager implements Middleware { return; case SessionStatus.Stopping: // Wait until done stopping, then start. - this.logger.writeVerbose("Still stopping."); + this.logger.writeDebug("Still stopping."); await this.waitWhileStopping(); break; case SessionStatus.Failed: // Try to start again. - this.logger.writeVerbose("Previously failed, starting again."); + this.logger.writeDebug("Previously failed, starting again."); break; } @@ -277,6 +278,8 @@ export class SessionManager implements Middleware { this.startCancellationTokenSource?.dispose(); this.startCancellationTokenSource = undefined; this.sessionDetails = undefined; + this.traceLogLevelHandler?.dispose(); + this.traceLogLevelHandler = undefined; this.setSessionStatus("Not Started", SessionStatus.NotStarted); } @@ -291,7 +294,7 @@ export class SessionManager implements Middleware { if (exeNameOverride) { // Reset the version and PowerShell details since we're launching a // new executable. - this.logger.writeVerbose(`Starting with executable overriden to: ${exeNameOverride}`); + this.logger.writeDebug(`Starting with executable overriden to: ${exeNameOverride}`); this.sessionSettings.powerShellDefaultVersion = exeNameOverride; this.versionDetails = undefined; this.PowerShellExeDetails = undefined; @@ -335,7 +338,6 @@ export class SessionManager implements Middleware { // handler when the process is disposed). this.debugSessionProcess?.dispose(); this.debugEventHandler?.dispose(); - if (this.PowerShellExeDetails === undefined) { return Promise.reject(new Error("Required PowerShellExeDetails undefined!")); } @@ -353,6 +355,7 @@ export class SessionManager implements Middleware { true, false, this.logger, + this.extensionContext.logUri, this.getEditorServicesArgs(bundledModulesPath, this.PowerShellExeDetails) + "-DebugServiceOnly ", this.getNewSessionFilePath(), this.sessionSettings); @@ -451,34 +454,58 @@ export class SessionManager implements Middleware { } } - private async onConfigurationUpdated(): Promise<void> { + /** There are some changes we cannot "hot" set, so these require a restart of the session */ + private async restartOnCriticalConfigChange(changeEvent: vscode.ConfigurationChangeEvent): Promise<void> { + if (this.suppressRestartPrompt) {return;} + if (this.sessionStatus !== SessionStatus.Running) {return;} + + // Restart not needed if shell integration is enabled but the shell is backgrounded. const settings = getSettings(); - const shellIntegrationEnabled = vscode.workspace.getConfiguration("terminal.integrated.shellIntegration").get<boolean>("enabled"); - this.logger.updateLogLevel(settings.developer.editorServicesLogLevel); + if (changeEvent.affectsConfiguration("terminal.integrated.shellIntegration.enabled")) { + const shellIntegrationEnabled = vscode.workspace.getConfiguration("terminal.integrated.shellIntegration").get<boolean>("enabled") ?? false; + if (shellIntegrationEnabled && !settings.integratedConsole.startInBackground) { + return this.restartWithPrompt(); + } + } + + // Early return if the change doesn't affect the PowerShell extension settings from this point forward + if (!changeEvent.affectsConfiguration("powershell")) {return;} + // Detect any setting changes that would affect the session. - if (!this.suppressRestartPrompt - && this.sessionStatus === SessionStatus.Running - && ((shellIntegrationEnabled !== this.shellIntegrationEnabled - && !settings.integratedConsole.startInBackground) - || settings.cwd !== this.sessionSettings.cwd + const coldRestartSettingNames = [ + "developer.traceLsp", + "developer.traceDap", + "developer.editorServicesLogLevel", + ]; + for (const settingName of coldRestartSettingNames) { + if (changeEvent.affectsConfiguration("powershell" + "." + settingName)) { + return this.restartWithPrompt(); + } + } + + // TODO: Migrate these to affectsConfiguration style above + if (settings.cwd !== this.sessionSettings.cwd || settings.powerShellDefaultVersion !== this.sessionSettings.powerShellDefaultVersion - || settings.developer.editorServicesLogLevel !== this.sessionSettings.developer.editorServicesLogLevel || settings.developer.bundledModulesPath !== this.sessionSettings.developer.bundledModulesPath || settings.developer.editorServicesWaitForDebugger !== this.sessionSettings.developer.editorServicesWaitForDebugger || settings.developer.setExecutionPolicy !== this.sessionSettings.developer.setExecutionPolicy || settings.integratedConsole.useLegacyReadLine !== this.sessionSettings.integratedConsole.useLegacyReadLine || settings.integratedConsole.startInBackground !== this.sessionSettings.integratedConsole.startInBackground - || settings.integratedConsole.startLocation !== this.sessionSettings.integratedConsole.startLocation)) { + || settings.integratedConsole.startLocation !== this.sessionSettings.integratedConsole.startLocation + ) { + return this.restartWithPrompt(); + } + } - this.logger.writeVerbose("Settings changed, prompting to restart..."); - const response = await vscode.window.showInformationMessage( - "The PowerShell runtime configuration has changed, would you like to start a new session?", - "Yes", "No"); + private async restartWithPrompt(): Promise<void> { + this.logger.writeDebug("Settings changed, prompting to restart..."); + const response = await vscode.window.showInformationMessage( + "The PowerShell runtime configuration has changed, would you like to start a new session?", + "Yes", "No"); - if (response === "Yes") { - await this.restartSession(); - } + if (response === "Yes") { + await this.restartSession(); } } @@ -486,14 +513,14 @@ export class SessionManager implements Middleware { this.registeredCommands = [ vscode.commands.registerCommand("PowerShell.RestartSession", async () => { await this.restartSession(); }), vscode.commands.registerCommand(this.ShowSessionMenuCommandName, async () => { await this.showSessionMenu(); }), - vscode.workspace.onDidChangeConfiguration(async () => { await this.onConfigurationUpdated(); }), + vscode.workspace.onDidChangeConfiguration((e) => this.restartOnCriticalConfigChange(e)), vscode.commands.registerCommand( "PowerShell.ShowSessionConsole", (isExecute?: boolean) => { this.showSessionTerminal(isExecute); }) ]; } private async findPowerShell(): Promise<IPowerShellExeDetails | undefined> { - this.logger.writeVerbose("Finding PowerShell..."); + this.logger.writeDebug("Finding PowerShell..."); const powershellExeFinder = new PowerShellExeFinder( this.platformDetails, this.sessionSettings.powerShellAdditionalExePaths, @@ -539,6 +566,7 @@ export class SessionManager implements Middleware { false, this.shellIntegrationEnabled, this.logger, + this.extensionContext.logUri, this.getEditorServicesArgs(bundledModulesPath, powerShellExeDetails), this.getNewSessionFilePath(), this.sessionSettings); @@ -591,7 +619,7 @@ export class SessionManager implements Middleware { } private sessionStarted(sessionDetails: IEditorServicesSessionDetails): boolean { - this.logger.writeVerbose(`Session details: ${JSON.stringify(sessionDetails, undefined, 2)}`); + this.logger.writeDebug(`Session details: ${JSON.stringify(sessionDetails, undefined, 2)}`); if (sessionDetails.status === "started") { // Successful server start with a session file return true; } @@ -610,7 +638,7 @@ export class SessionManager implements Middleware { } private async startLanguageClient(sessionDetails: IEditorServicesSessionDetails): Promise<LanguageClient> { - this.logger.writeVerbose("Connecting to language service..."); + this.logger.writeDebug("Connecting to language service..."); const connectFunc = (): Promise<StreamInfo> => { return new Promise<StreamInfo>( (resolve, _reject) => { @@ -618,11 +646,12 @@ export class SessionManager implements Middleware { socket.on( "connect", () => { - this.logger.writeVerbose("Language service connected."); + this.logger.writeDebug("Language service connected."); resolve({ writer: socket, reader: socket }); }); }); }; + const clientOptions: LanguageClientOptions = { documentSelector: this.documentSelector, synchronize: { @@ -646,9 +675,11 @@ export class SessionManager implements Middleware { // hangs up (ECONNRESET errors). error: (_error: Error, _message: Message, _count: number): ErrorHandlerResult => { // TODO: Is there any error worth terminating on? + this.logger.writeError(`${_error.name}: ${_error.message} ${_error.cause}`); return { action: ErrorAction.Continue }; }, closed: (): CloseHandlerResult => { + this.logger.write("Language service connection closed."); // We have our own restart experience return { action: CloseAction.DoNotRestart, @@ -656,9 +687,11 @@ export class SessionManager implements Middleware { }; }, }, - revealOutputChannelOn: RevealOutputChannelOn.Never, middleware: this, - traceOutputChannel: vscode.window.createOutputChannel("PowerShell Trace - LSP", {log: true}), + traceOutputChannel: new LanguageClientOutputChannelAdapter("PowerShell: Trace LSP", LspTraceParser), + // This is named the same as the Client log to merge the logs, but will be handled and disposed separately. + outputChannel: new LanguageClientOutputChannelAdapter("PowerShell", PsesParser), + revealOutputChannelOn: RevealOutputChannelOn.Never }; const languageClient = new LanguageClient("powershell", "PowerShell Editor Services Client", connectFunc, clientOptions); @@ -763,8 +796,8 @@ Type 'help' to get help. && this.extensionContext.extensionMode === vscode.ExtensionMode.Development) { editorServicesArgs += "-WaitForDebugger "; } - - editorServicesArgs += `-LogLevel '${this.sessionSettings.developer.editorServicesLogLevel}' `; + const logLevel = vscode.workspace.getConfiguration("powershell.developer").get<string>("editorServicesLogLevel"); + editorServicesArgs += `-LogLevel '${logLevel}' `; return editorServicesArgs; } @@ -836,7 +869,7 @@ Type 'help' to get help. } private setSessionStatus(detail: string, status: SessionStatus): void { - this.logger.writeVerbose(`Session status changing from '${this.sessionStatus}' to '${status}'.`); + this.logger.writeDebug(`Session status changing from '${this.sessionStatus}' to '${status}'.`); this.sessionStatus = status; this.languageStatusItem.text = "$(terminal-powershell)"; this.languageStatusItem.detail = "PowerShell"; diff --git a/src/settings.ts b/src/settings.ts index 9c2ef38452..f29079846a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -56,15 +56,6 @@ export enum PipelineIndentationStyle { None = "None", } -export enum LogLevel { - Diagnostic = "Diagnostic", - Verbose = "Verbose", - Normal = "Normal", - Warning = "Warning", - Error = "Error", - None = "None", -} - export enum CommentType { Disabled = "Disabled", BlockComment = "BlockComment", @@ -120,7 +111,6 @@ class DeveloperSettings extends PartialSettings { // From `<root>/out/main.js` we go to the directory before <root> and // then into the other repo. bundledModulesPath = "../../PowerShellEditorServices/module"; - editorServicesLogLevel = LogLevel.Normal; editorServicesWaitForDebugger = false; setExecutionPolicy = true; waitForSessionFileTimeoutSeconds = 240; @@ -209,7 +199,7 @@ export async function changeSetting( configurationTarget: vscode.ConfigurationTarget | boolean | undefined, logger: ILogger | undefined): Promise<void> { - logger?.writeVerbose(`Changing '${settingName}' at scope '${configurationTarget}' to '${newValue}'.`); + logger?.writeDebug(`Changing '${settingName}' at scope '${configurationTarget}' to '${newValue}'.`); try { const configuration = vscode.workspace.getConfiguration(utils.PowerShellLanguageId); @@ -242,7 +232,7 @@ export async function getChosenWorkspace(logger: ILogger | undefined): Promise<v chosenWorkspace = await vscode.window.showWorkspaceFolderPick(options); - logger?.writeVerbose(`User selected workspace: '${chosenWorkspace?.name}'`); + logger?.writeDebug(`User selected workspace: '${chosenWorkspace?.name}'`); if (chosenWorkspace === undefined) { chosenWorkspace = vscode.workspace.workspaceFolders[0]; } else { @@ -296,7 +286,7 @@ export async function validateCwdSetting(logger: ILogger | undefined): Promise<s // Otherwise get a cwd from the workspace, if possible. const workspace = await getChosenWorkspace(logger); if (workspace === undefined) { - logger?.writeVerbose("Workspace was undefined, using homedir!"); + logger?.writeDebug("Workspace was undefined, using homedir!"); return os.homedir(); } @@ -316,3 +306,84 @@ export async function validateCwdSetting(logger: ILogger | undefined): Promise<s // If all else fails, use the home directory. return os.homedir(); } + + +/** + * Options for the `onSettingChange` function. + * @param scope the scope in which the vscode setting should be evaluated. + * @param run Indicates whether the function should be run now in addition to when settings change, or if it should be run only once and stop listening after a single change. If this is undefined, the function will be run only when the setting changes. + */ +interface onSettingChangeOptions { + scope?: vscode.ConfigurationScope; + run?: "now" | "once"; +} + +/** + * Invokes the specified action when a setting changes + * @param section the section of the vscode settings to evaluate. Defaults to `powershell` + * @param setting a string representation of the setting you wish to evaluate, e.g. `trace.server` + * @param action the action to take when the setting changes + * @param scope the scope in which the vscode setting should be evaluated. + * @returns a Disposable object that can be used to stop listening for changes with dispose() + * @example + * onSettingChange("powershell", "settingName", (newValue) => console.log(newValue)); + */ + +// Because we actually do use the constraint in the callback +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function onSettingChange<T>( + section: string, + setting: string, + action: (newValue: T | undefined) => void, + options?: onSettingChangeOptions, +): vscode.Disposable { + const settingPath = `${section}.${setting}`; + const disposable = vscode.workspace.onDidChangeConfiguration(e => { + if (!e.affectsConfiguration(settingPath, options?.scope)) { return; } + + doOnSettingsChange(section, setting, action, options?.scope); + if (options?.run === "once") { + disposable.dispose(); // Javascript black magic, referring to an outer reference before it exists + } + }); + if (options?.run === "now") { + doOnSettingsChange(section, setting, action, options.scope); + } + return disposable; +} + +/** Implementation is separate to avoid duplicate code for run now */ + +// Because we actually do use the constraint in the callback +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +function doOnSettingsChange<T>( + section: string, + setting: string, + action: (newValue: T | undefined) => void, + scope?: vscode.ConfigurationScope, +): void { + const value = vscode.workspace.getConfiguration(section, scope).get<T>(setting); + action(value); +} + +/** + * Invokes the specified action when a PowerShell setting changes. Convenience function for `onSettingChange` + * @param setting a string representation of the setting you wish to evaluate, e.g. `trace.server` + * @param action the action to take when the setting changes + * @param scope the scope in which the vscode setting should be evaluated.n + * @returns a Disposable object that can be used to stop listening for changes + * @example + * onPowerShellSettingChange("settingName", (newValue) => console.log(newValue)); + */ + +// Because we actually do use the constraint in the callback +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function onPowerShellSettingChange<T>( + setting: string, + action: (newValue: T | undefined) => void, + options?: onSettingChangeOptions + +): vscode.Disposable { + const section = "powershell"; + return onSettingChange(section, setting, action, options); +} diff --git a/test/core/paths.test.ts b/test/core/paths.test.ts index 15f60f5bd1..703b22a53f 100644 --- a/test/core/paths.test.ts +++ b/test/core/paths.test.ts @@ -9,9 +9,11 @@ import { checkIfDirectoryExists, checkIfFileExists, ShellIntegrationScript } fro describe("Path assumptions", function () { let globalStorageUri: vscode.Uri; + let logUri: vscode.Uri; before(async () => { const extension: IPowerShellExtensionClient = await utils.ensureEditorServicesIsConnected(); globalStorageUri = extension.getStorageUri(); + logUri = extension.getLogUri(); }); it("Creates the session folder at the correct path", async function () { @@ -19,7 +21,7 @@ describe("Path assumptions", function () { }); it("Creates the log folder at the correct path", async function () { - assert(await checkIfDirectoryExists(vscode.Uri.joinPath(globalStorageUri, "logs"))); + assert(await checkIfDirectoryExists(logUri)); }); it("Finds the Terminal Shell Integration Script", async function () { diff --git a/test/utils.ts b/test/utils.ts index 7d601aadf2..e62de2d87e 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -25,10 +25,10 @@ export class TestLogger implements ILogger { writeAndShowInformation(_message: string, ..._additionalMessages: string[]): Promise<void> { return Promise.resolve(); } - writeDiagnostic(_message: string, ..._additionalMessages: string[]): void { + writeTrace(_message: string, ..._additionalMessages: string[]): void { return; } - writeVerbose(_message: string, ..._additionalMessages: string[]): void { + writeDebug(_message: string, ..._additionalMessages: string[]): void { return; } writeWarning(_message: string, ..._additionalMessages: string[]): void {