Author: Matthew Bastien Date: 2025-03-27T14:09:09-07:00 New Revision: 0d4f12ee0046b83d28dbf3a8aca07a0f27b77786
URL: https://github.com/llvm/llvm-project/commit/0d4f12ee0046b83d28dbf3a8aca07a0f27b77786 DIFF: https://github.com/llvm/llvm-project/commit/0d4f12ee0046b83d28dbf3a8aca07a0f27b77786.diff LOG: [lldb-dap] Allow providing debug adapter arguments in the extension (#129262) Added a new setting called `lldb-dap.arguments` and a debug configuration attribute called `debugAdapterArgs` that can be used to set the arguments used to launch the debug adapter. Right now this is mostly useful for debugging purposes to add the `--wait-for-debugger` option to lldb-dap. Additionally, the extension will now check for a changed lldb-dap executable or arguments when launching a debug session in server mode. I had to add a new `DebugConfigurationProvider` to do this because VSCode will show an unhelpful error modal when the `DebugAdapterDescriptorFactory` returns `undefined`. In order to facilitate this, I had to add two new properties to the launch configuration that are used by the `DebugAdapterDescriptorFactory` to tell VS Code how to launch the debug adapter: - `debugAdapterHostname` - the hostname for an existing lldb-dap server - `debugAdapterPort` - the port for an existing lldb-dap server I've also removed the check for the `executable` argument in `LLDBDapDescriptorFactory.createDebugAdapterDescriptor()`. This argument is only set by VS Code when the debug adapter executable properties are set in the `package.json`. The LLDB DAP extension does not currently do this (and I don't think it ever will). So, this makes the debug adapter descriptor factory a little easier to read. The check for whether or not `lldb-dap` exists has been moved into the new `DebugConfigurationProvider` as well. This way the extension won't get in the user's way unless they actually try to start a debugging session. The error will show up as a modal which will also make it more obvious when something goes wrong, rather than popping up as a warning at the bottom right of the screen. Added: lldb/tools/lldb-dap/src-ts/debug-configuration-provider.ts lldb/tools/lldb-dap/src-ts/lldb-dap-server.ts lldb/tools/lldb-dap/src-ts/ui/error-with-notification.ts lldb/tools/lldb-dap/src-ts/ui/show-error-message.ts Modified: lldb/tools/lldb-dap/package.json lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts lldb/tools/lldb-dap/src-ts/extension.ts Removed: ################################################################################ diff --git a/lldb/tools/lldb-dap/package.json b/lldb/tools/lldb-dap/package.json index cb4b1f1aa22ce..289e07c12682c 100644 --- a/lldb/tools/lldb-dap/package.json +++ b/lldb/tools/lldb-dap/package.json @@ -76,6 +76,15 @@ "type": "string", "description": "The path to the lldb-dap binary." }, + "lldb-dap.arguments": { + "scope": "resource", + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "The list of additional arguments used to launch the debug adapter executable." + }, "lldb-dap.log-path": { "scope": "resource", "type": "string", @@ -149,19 +158,30 @@ { "type": "lldb-dap", "label": "LLDB DAP Debugger", - "program": "./bin/lldb-dap", - "windows": { - "program": "./bin/lldb-dap.exe" - }, "configurationAttributes": { "launch": { "required": [ "program" ], "properties": { + "debugAdapterHostname": { + "type": "string", + "markdownDescription": "The hostname that an existing lldb-dap executable is listening on." + }, + "debugAdapterPort": { + "type": "number", + "markdownDescription": "The port that an existing lldb-dap executable is listening on." + }, "debugAdapterExecutable": { "type": "string", - "markdownDescription": "The absolute path to the LLDB debug adapter executable to use." + "markdownDescription": "The absolute path to the LLDB debug adapter executable to use. Overrides any user or workspace settings." + }, + "debugAdapterArgs": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "The list of additional arguments used to launch the debug adapter executable. Overrides any user or workspace settings." }, "program": { "type": "string", @@ -349,9 +369,24 @@ }, "attach": { "properties": { + "debugAdapterHostname": { + "type": "string", + "markdownDescription": "The hostname that an existing lldb-dap executable is listening on." + }, + "debugAdapterPort": { + "type": "number", + "markdownDescription": "The port that an existing lldb-dap executable is listening on." + }, "debugAdapterExecutable": { "type": "string", - "markdownDescription": "The absolute path to the LLDB debug adapter executable to use." + "markdownDescription": "The absolute path to the LLDB debug adapter executable to use. Overrides any user or workspace settings." + }, + "debugAdapterArgs": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "The list of additional arguments used to launch the debug adapter executable. Overrides any user or workspace settings." }, "program": { "type": "string", diff --git a/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts b/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts index c2244dcbde8f2..e23d717a70101 100644 --- a/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts +++ b/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts @@ -3,10 +3,12 @@ import * as util from "util"; import * as vscode from "vscode"; import * as child_process from "child_process"; import * as fs from "node:fs/promises"; +import { ConfigureButton, OpenSettingsButton } from "./ui/show-error-message"; +import { ErrorWithNotification } from "./ui/error-with-notification"; const exec = util.promisify(child_process.execFile); -export async function isExecutable(path: string): Promise<Boolean> { +async function isExecutable(path: string): Promise<Boolean> { try { await fs.access(path, fs.constants.X_OK); } catch { @@ -25,7 +27,7 @@ async function findWithXcrun(executable: string): Promise<string | undefined> { if (stdout) { return stdout.toString().trimEnd(); } - } catch (error) { } + } catch (error) {} } return undefined; } @@ -65,37 +67,127 @@ async function findDAPExecutable(): Promise<string | undefined> { return undefined; } +/** + * Retrieves the lldb-dap executable path either from settings or the provided + * {@link vscode.DebugConfiguration}. + * + * @param workspaceFolder The {@link vscode.WorkspaceFolder} that the debug session will be launched within + * @param configuration The {@link vscode.DebugConfiguration} that will be launched + * @throws An {@link ErrorWithNotification} if something went wrong + * @returns The path to the lldb-dap executable + */ async function getDAPExecutable( - session: vscode.DebugSession, -): Promise<string | undefined> { + workspaceFolder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise<string> { // Check if the executable was provided in the launch configuration. - const launchConfigPath = session.configuration["debugAdapterExecutable"]; + const launchConfigPath = configuration["debugAdapterExecutable"]; if (typeof launchConfigPath === "string" && launchConfigPath.length !== 0) { + if (!(await isExecutable(launchConfigPath))) { + throw new ErrorWithNotification( + `Debug adapter path "${launchConfigPath}" is not a valid file. The path comes from your launch configuration.`, + new ConfigureButton(), + ); + } return launchConfigPath; } // Check if the executable was provided in the extension's configuration. - const config = vscode.workspace.getConfiguration( - "lldb-dap", - session.workspaceFolder, - ); + const config = vscode.workspace.getConfiguration("lldb-dap", workspaceFolder); const configPath = config.get<string>("executable-path"); if (configPath && configPath.length !== 0) { + if (!(await isExecutable(configPath))) { + throw new ErrorWithNotification( + `Debug adapter path "${configPath}" is not a valid file. The path comes from your settings.`, + new OpenSettingsButton("lldb-dap.executable-path"), + ); + } return configPath; } // Try finding the lldb-dap binary. const foundPath = await findDAPExecutable(); if (foundPath) { + if (!(await isExecutable(foundPath))) { + throw new ErrorWithNotification( + `Found a potential debug adapter on your system at "${configPath}", but it is not a valid file.`, + new OpenSettingsButton("lldb-dap.executable-path"), + ); + } return foundPath; } - return undefined; + throw new ErrorWithNotification( + "Unable to find the path to the LLDB debug adapter executable.", + new OpenSettingsButton("lldb-dap.executable-path"), + ); } -async function isServerModeSupported(exe: string): Promise<boolean> { - const { stdout } = await exec(exe, ['--help']); - return /--connection/.test(stdout); +/** + * Retrieves the arguments that will be provided to lldb-dap either from settings or the provided + * {@link vscode.DebugConfiguration}. + * + * @param workspaceFolder The {@link vscode.WorkspaceFolder} that the debug session will be launched within + * @param configuration The {@link vscode.DebugConfiguration} that will be launched + * @throws An {@link ErrorWithNotification} if something went wrong + * @returns The arguments that will be provided to lldb-dap + */ +async function getDAPArguments( + workspaceFolder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise<string[]> { + // Check the debug configuration for arguments first. + const debugConfigArgs = configuration.debugAdapterArgs; + if (debugConfigArgs) { + if ( + !Array.isArray(debugConfigArgs) || + debugConfigArgs.findIndex((entry) => typeof entry !== "string") !== -1 + ) { + throw new ErrorWithNotification( + "The debugAdapterArgs property must be an array of string values. Please update your launch configuration", + new ConfigureButton(), + ); + } + return debugConfigArgs; + } + // Fall back on the workspace configuration. + return vscode.workspace + .getConfiguration("lldb-dap", workspaceFolder) + .get<string[]>("arguments", []); +} + +/** + * Creates a new {@link vscode.DebugAdapterExecutable} based on the provided workspace folder and + * debug configuration. Assumes that the given debug configuration is for a local launch of lldb-dap. + * + * @param workspaceFolder The {@link vscode.WorkspaceFolder} that the debug session will be launched within + * @param configuration The {@link vscode.DebugConfiguration} that will be launched + * @throws An {@link ErrorWithNotification} if something went wrong + * @returns The {@link vscode.DebugAdapterExecutable} that can be used to launch lldb-dap + */ +export async function createDebugAdapterExecutable( + workspaceFolder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise<vscode.DebugAdapterExecutable> { + const config = vscode.workspace.getConfiguration("lldb-dap", workspaceFolder); + const log_path = config.get<string>("log-path"); + let env: { [key: string]: string } = {}; + if (log_path) { + env["LLDBDAP_LOG"] = log_path; + } + const configEnvironment = + config.get<{ [key: string]: string }>("environment") || {}; + const dapPath = await getDAPExecutable(workspaceFolder, configuration); + + const dbgOptions = { + env: { + ...configEnvironment, + ...env, + }, + }; + const dbgArgs = await getDAPArguments(workspaceFolder, configuration); + + return new vscode.DebugAdapterExecutable(dapPath, dbgArgs, dbgOptions); } /** @@ -103,104 +195,29 @@ async function isServerModeSupported(exe: string): Promise<boolean> { * depending on the session configuration. */ export class LLDBDapDescriptorFactory - implements vscode.DebugAdapterDescriptorFactory, vscode.Disposable { - private server?: Promise<{ process: child_process.ChildProcess, host: string, port: number }>; - - dispose() { - this.server?.then(({ process }) => { - process.kill(); - }); - } - + implements vscode.DebugAdapterDescriptorFactory +{ async createDebugAdapterDescriptor( session: vscode.DebugSession, executable: vscode.DebugAdapterExecutable | undefined, ): Promise<vscode.DebugAdapterDescriptor | undefined> { - const config = vscode.workspace.getConfiguration( - "lldb-dap", - session.workspaceFolder, - ); - - const log_path = config.get<string>("log-path"); - let env: { [key: string]: string } = {}; - if (log_path) { - env["LLDBDAP_LOG"] = log_path; - } - const configEnvironment = - config.get<{ [key: string]: string }>("environment") || {}; - const dapPath = (await getDAPExecutable(session)) ?? executable?.command; - - if (!dapPath) { - LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(); - return undefined; - } - - if (!(await isExecutable(dapPath))) { - LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(dapPath); - return; - } - - const dbgOptions = { - env: { - ...executable?.options?.env, - ...configEnvironment, - ...env, - }, - }; - const dbgArgs = executable?.args ?? []; - - const serverMode = config.get<boolean>('serverMode', false); - if (serverMode && await isServerModeSupported(dapPath)) { - const { host, port } = await this.startServer(dapPath, dbgArgs, dbgOptions); - return new vscode.DebugAdapterServer(port, host); + if (executable) { + throw new Error( + "Setting the debug adapter executable in the package.json is not supported.", + ); } - return new vscode.DebugAdapterExecutable(dapPath, dbgArgs, dbgOptions); - } - - startServer(dapPath: string, args: string[], options: child_process.CommonSpawnOptions): Promise<{ host: string, port: number }> { - if (this.server) return this.server; - - this.server = new Promise(resolve => { - args.push( - '--connection', - 'connect://localhost:0' + // Use a server connection if the debugAdapterPort is provided + if (session.configuration.debugAdapterPort) { + return new vscode.DebugAdapterServer( + session.configuration.debugAdapterPort, + session.configuration.debugAdapterHost, ); - const server = child_process.spawn(dapPath, args, options); - server.stdout!.setEncoding('utf8').once('data', (data: string) => { - const connection = /connection:\/\/\[([^\]]+)\]:(\d+)/.exec(data); - if (connection) { - const host = connection[1]; - const port = Number(connection[2]); - resolve({ process: server, host, port }); - } - }); - server.on('exit', () => { - this.server = undefined; - }) - }); - return this.server; - } + } - /** - * Shows a message box when the debug adapter's path is not found - */ - static async showLLDBDapNotFoundMessage(path?: string) { - const message = - path - ? `Debug adapter path: ${path} is not a valid file.` - : "Unable to find the path to the LLDB debug adapter executable."; - const openSettingsAction = "Open Settings"; - const callbackValue = await vscode.window.showErrorMessage( - message, - openSettingsAction, + return createDebugAdapterExecutable( + session.workspaceFolder, + session.configuration, ); - - if (openSettingsAction === callbackValue) { - vscode.commands.executeCommand( - "workbench.action.openSettings", - "lldb-dap.executable-path", - ); - } } } diff --git a/lldb/tools/lldb-dap/src-ts/debug-configuration-provider.ts b/lldb/tools/lldb-dap/src-ts/debug-configuration-provider.ts new file mode 100644 index 0000000000000..0272509ee55f7 --- /dev/null +++ b/lldb/tools/lldb-dap/src-ts/debug-configuration-provider.ts @@ -0,0 +1,103 @@ +import * as vscode from "vscode"; +import * as child_process from "child_process"; +import * as util from "util"; +import { LLDBDapServer } from "./lldb-dap-server"; +import { createDebugAdapterExecutable } from "./debug-adapter-factory"; +import { ConfigureButton, showErrorMessage } from "./ui/show-error-message"; +import { ErrorWithNotification } from "./ui/error-with-notification"; + +const exec = util.promisify(child_process.execFile); + +/** + * Determines whether or not the given lldb-dap executable supports executing + * in server mode. + * + * @param exe the path to the lldb-dap executable + * @returns a boolean indicating whether or not lldb-dap supports server mode + */ +async function isServerModeSupported(exe: string): Promise<boolean> { + const { stdout } = await exec(exe, ["--help"]); + return /--connection/.test(stdout); +} + +export class LLDBDapConfigurationProvider + implements vscode.DebugConfigurationProvider +{ + constructor(private readonly server: LLDBDapServer) {} + + async resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + debugConfiguration: vscode.DebugConfiguration, + _token?: vscode.CancellationToken, + ): Promise<vscode.DebugConfiguration | null | undefined> { + try { + if ( + "debugAdapterHost" in debugConfiguration && + !("debugAdapterPort" in debugConfiguration) + ) { + throw new ErrorWithNotification( + "A debugAdapterPort must be provided when debugAdapterHost is set. Please update your launch configuration.", + new ConfigureButton(), + ); + } + + // Check if we're going to launch a debug session or use an existing process + if ("debugAdapterPort" in debugConfiguration) { + if ( + "debugAdapterExecutable" in debugConfiguration || + "debugAdapterArgs" in debugConfiguration + ) { + throw new ErrorWithNotification( + "The debugAdapterPort property is incompatible with debugAdapterExecutable and debugAdapterArgs. Please update your launch configuration.", + new ConfigureButton(), + ); + } + } else { + // Always try to create the debug adapter executable as this will show the user errors + // if there are any. + const executable = await createDebugAdapterExecutable( + folder, + debugConfiguration, + ); + if (!executable) { + return undefined; + } + + // Server mode needs to be handled here since DebugAdapterDescriptorFactory + // will show an unhelpful error if it returns undefined. We'd rather show a + // nicer error message here and allow stopping the debug session gracefully. + const config = vscode.workspace.getConfiguration("lldb-dap", folder); + if ( + config.get<boolean>("serverMode", false) && + (await isServerModeSupported(executable.command)) + ) { + const serverInfo = await this.server.start( + executable.command, + executable.args, + executable.options, + ); + if (!serverInfo) { + return undefined; + } + // Use a debug adapter host and port combination rather than an executable + // and list of arguments. + delete debugConfiguration.debugAdapterExecutable; + delete debugConfiguration.debugAdapterArgs; + debugConfiguration.debugAdapterHost = serverInfo.host; + debugConfiguration.debugAdapterPort = serverInfo.port; + } + } + + return debugConfiguration; + } catch (error) { + // Show a better error message to the user if possible + if (!(error instanceof ErrorWithNotification)) { + throw error; + } + return await error.showNotification({ + modal: true, + showConfigureButton: true, + }); + } + } +} diff --git a/lldb/tools/lldb-dap/src-ts/extension.ts b/lldb/tools/lldb-dap/src-ts/extension.ts index f0c7fb5bd1a71..0b014f953d5ba 100644 --- a/lldb/tools/lldb-dap/src-ts/extension.ts +++ b/lldb/tools/lldb-dap/src-ts/extension.ts @@ -1,11 +1,10 @@ import * as vscode from "vscode"; -import { - LLDBDapDescriptorFactory, - isExecutable, -} from "./debug-adapter-factory"; +import { LLDBDapDescriptorFactory } from "./debug-adapter-factory"; import { DisposableContext } from "./disposable-context"; import { LaunchUriHandler } from "./uri-launch-handler"; +import { LLDBDapConfigurationProvider } from "./debug-configuration-provider"; +import { LLDBDapServer } from "./lldb-dap-server"; /** * This class represents the extension and manages its life cycle. Other extensions @@ -14,33 +13,26 @@ import { LaunchUriHandler } from "./uri-launch-handler"; export class LLDBDapExtension extends DisposableContext { constructor() { super(); - const factory = new LLDBDapDescriptorFactory(); - this.pushSubscription(factory); + + const lldbDapServer = new LLDBDapServer(); + this.pushSubscription(lldbDapServer); + this.pushSubscription( - vscode.debug.registerDebugAdapterDescriptorFactory( + vscode.debug.registerDebugConfigurationProvider( "lldb-dap", - factory, - ) + new LLDBDapConfigurationProvider(lldbDapServer), + ), ); - this.pushSubscription( - vscode.workspace.onDidChangeConfiguration(async (event) => { - if (event.affectsConfiguration("lldb-dap.executable-path")) { - const dapPath = vscode.workspace - .getConfiguration("lldb-dap") - .get<string>("executable-path"); - if (dapPath) { - if (await isExecutable(dapPath)) { - return; - } - } - LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(dapPath || ""); - } - }), + this.pushSubscription( + vscode.debug.registerDebugAdapterDescriptorFactory( + "lldb-dap", + new LLDBDapDescriptorFactory(), + ), ); this.pushSubscription( - vscode.window.registerUriHandler(new LaunchUriHandler()) + vscode.window.registerUriHandler(new LaunchUriHandler()), ); } } diff --git a/lldb/tools/lldb-dap/src-ts/lldb-dap-server.ts b/lldb/tools/lldb-dap/src-ts/lldb-dap-server.ts new file mode 100644 index 0000000000000..f40dbf049a4bb --- /dev/null +++ b/lldb/tools/lldb-dap/src-ts/lldb-dap-server.ts @@ -0,0 +1,132 @@ +import * as child_process from "node:child_process"; +import { isDeepStrictEqual } from "util"; +import * as vscode from "vscode"; + +/** + * Represents a running lldb-dap process that is accepting connections (i.e. in "server mode"). + * + * Handles startup of the process if it isn't running already as well as prompting the user + * to restart when arguments have changed. + */ +export class LLDBDapServer implements vscode.Disposable { + private serverProcess?: child_process.ChildProcessWithoutNullStreams; + private serverInfo?: Promise<{ host: string; port: number }>; + + /** + * Starts the server with the provided options. The server will be restarted or reused as + * necessary. + * + * @param dapPath the path to the debug adapter executable + * @param args the list of arguments to provide to the debug adapter + * @param options the options to provide to the debug adapter process + * @returns a promise that resolves with the host and port information or `undefined` if unable to launch the server. + */ + async start( + dapPath: string, + args: string[], + options?: child_process.SpawnOptionsWithoutStdio, + ): Promise<{ host: string; port: number } | undefined> { + const dapArgs = [...args, "--connection", "connect://localhost:0"]; + if (!(await this.shouldContinueStartup(dapPath, dapArgs))) { + return undefined; + } + + if (this.serverInfo) { + return this.serverInfo; + } + + this.serverInfo = new Promise((resolve, reject) => { + const process = child_process.spawn(dapPath, dapArgs, options); + process.on("error", (error) => { + reject(error); + this.serverProcess = undefined; + this.serverInfo = undefined; + }); + process.on("exit", (code, signal) => { + let errorMessage = "Server process exited early"; + if (code !== undefined) { + errorMessage += ` with code ${code}`; + } else if (signal !== undefined) { + errorMessage += ` due to signal ${signal}`; + } + reject(new Error(errorMessage)); + this.serverProcess = undefined; + this.serverInfo = undefined; + }); + process.stdout.setEncoding("utf8").on("data", (data) => { + const connection = /connection:\/\/\[([^\]]+)\]:(\d+)/.exec( + data.toString(), + ); + if (connection) { + const host = connection[1]; + const port = Number(connection[2]); + resolve({ host, port }); + process.stdout.removeAllListeners(); + } + }); + this.serverProcess = process; + }); + return this.serverInfo; + } + + /** + * Checks to see if the server needs to be restarted. If so, it will prompt the user + * to ask if they wish to restart. + * + * @param dapPath the path to the debug adapter + * @param args the arguments for the debug adapter + * @returns whether or not startup should continue depending on user input + */ + private async shouldContinueStartup( + dapPath: string, + args: string[], + ): Promise<boolean> { + if (!this.serverProcess || !this.serverInfo) { + return true; + } + + if (isDeepStrictEqual(this.serverProcess.spawnargs, [dapPath, ...args])) { + return true; + } + + const userInput = await vscode.window.showInformationMessage( + "The arguments to lldb-dap have changed. Would you like to restart the server?", + { + modal: true, + detail: `An existing lldb-dap server (${this.serverProcess.pid}) is running with diff erent arguments. + +The previous lldb-dap server was started with: + +${this.serverProcess.spawnargs.join(" ")} + +The new lldb-dap server will be started with: + +${dapPath} ${args.join(" ")} + +Restarting the server will interrupt any existing debug sessions and start a new server.`, + }, + "Restart", + "Use Existing", + ); + switch (userInput) { + case "Restart": + this.serverProcess.kill(); + this.serverProcess = undefined; + this.serverInfo = undefined; + return true; + case "Use Existing": + return true; + case undefined: + return false; + } + } + + dispose() { + if (!this.serverProcess) { + return; + } + this.serverProcess.kill(); + this.serverProcess = undefined; + this.serverInfo = undefined; + } +} diff --git a/lldb/tools/lldb-dap/src-ts/ui/error-with-notification.ts b/lldb/tools/lldb-dap/src-ts/ui/error-with-notification.ts new file mode 100644 index 0000000000000..1f8676d3eb135 --- /dev/null +++ b/lldb/tools/lldb-dap/src-ts/ui/error-with-notification.ts @@ -0,0 +1,68 @@ +import * as vscode from "vscode"; +import { + ConfigureButton, + NotificationButton, + showErrorMessage, +} from "./show-error-message"; + +/** Options used to configure {@link ErrorWithNotification.showNotification}. */ +export interface ShowNotificationOptions extends vscode.MessageOptions { + /** + * Whether or not to show the configure launch configuration button. + * + * **IMPORTANT**: the configure launch configuration button will do nothing if the + * callee isn't a {@link vscode.DebugConfigurationProvider}. + */ + showConfigureButton?: boolean; +} + +/** + * An error that is able to be displayed to the user as a notification. + * + * Used in combination with {@link showErrorMessage showErrorMessage()} when whatever caused + * the error was the result of a direct action by the user. E.g. launching a debug session. + */ +export class ErrorWithNotification extends Error { + private readonly buttons: NotificationButton<any, null | undefined>[]; + + constructor( + message: string, + ...buttons: NotificationButton<any, null | undefined>[] + ) { + super(message); + this.buttons = buttons; + } + + /** + * Shows the notification to the user including the configure launch configuration button. + * + * **IMPORTANT**: the configure launch configuration button will do nothing if the + * callee isn't a {@link vscode.DebugConfigurationProvider}. + * + * @param options Configure the behavior of the notification + */ + showNotification( + options: ShowNotificationOptions & { showConfigureButton: true }, + ): Promise<null | undefined>; + + /** + * Shows the notification to the user. + * + * @param options Configure the behavior of the notification + */ + showNotification(options?: ShowNotificationOptions): Promise<undefined>; + + // Actual implementation of showNotification() + async showNotification( + options: ShowNotificationOptions = {}, + ): Promise<null | undefined> { + // Filter out the configure button unless explicitly requested + let buttons = this.buttons; + if (options.showConfigureButton !== true) { + buttons = buttons.filter( + (button) => !(button instanceof ConfigureButton), + ); + } + return showErrorMessage(this.message, options, ...buttons); + } +} diff --git a/lldb/tools/lldb-dap/src-ts/ui/show-error-message.ts b/lldb/tools/lldb-dap/src-ts/ui/show-error-message.ts new file mode 100644 index 0000000000000..de7b07dc97a64 --- /dev/null +++ b/lldb/tools/lldb-dap/src-ts/ui/show-error-message.ts @@ -0,0 +1,98 @@ +import * as vscode from "vscode"; + +/** + * A button with a particular label that can perform an action when clicked. + * + * Used to add buttons to {@link showErrorMessage showErrorMessage()}. + */ +export interface NotificationButton<T, Result> { + readonly label: T; + action(): Promise<Result>; +} + +/** + * Represents a button that, when clicked, will open a particular VS Code setting. + */ +export class OpenSettingsButton + implements NotificationButton<"Open Settings", undefined> +{ + readonly label = "Open Settings"; + + constructor(private readonly settingId?: string) {} + + async action(): Promise<undefined> { + await vscode.commands.executeCommand( + "workbench.action.openSettings", + this.settingId ?? "@ext:llvm-vs-code-extensions.lldb-dap ", + ); + } +} + +/** + * Represents a button that, when clicked, will return `null`. + * + * Used by a {@link vscode.DebugConfigurationProvider} to indicate that VS Code should + * cancel a debug session and open its launch configuration. + * + * **IMPORTANT**: this button will do nothing if the callee isn't a + * {@link vscode.DebugConfigurationProvider}. + */ +export class ConfigureButton + implements NotificationButton<"Configure", null | undefined> +{ + readonly label = "Configure"; + + async action(): Promise<null | undefined> { + return null; // Opens the launch.json if returned from a DebugConfigurationProvider + } +} + +/** Gets the Result type from a {@link NotificationButton} or string value. */ +type ResultOf<T> = T extends string + ? T + : T extends NotificationButton<any, infer Result> + ? Result + : never; + +/** + * Shows an error message to the user with an optional array of buttons. + * + * This can be used with common buttons such as {@link OpenSettingsButton} or plain + * strings as would normally be accepted by {@link vscode.window.showErrorMessage}. + * + * @param message The error message to display to the user + * @param options Configures the behaviour of the message. + * @param buttons An array of {@link NotificationButton buttons} or strings that the user can click on + * @returns `undefined` or the result of a button's action + */ +export async function showErrorMessage< + T extends string | NotificationButton<any, any>, +>( + message: string, + options: vscode.MessageOptions = {}, + ...buttons: T[] +): Promise<ResultOf<T> | undefined> { + const userSelection = await vscode.window.showErrorMessage( + message, + options, + ...buttons.map((button) => { + if (typeof button === "string") { + return button; + } + return button.label; + }), + ); + + for (const button of buttons) { + if (typeof button === "string") { + if (userSelection === button) { + // Type assertion is required to let TypeScript know that "button" isn't just any old string. + return button as ResultOf<T>; + } + } else if (userSelection === button.label) { + return await button.action(); + } + } + + return undefined; +} _______________________________________________ lldb-commits mailing list lldb-commits@lists.llvm.org https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits