| /* eslint-disable id-blacklist */ |
| /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ |
| /* eslint-disable no-underscore-dangle */ |
| /* eslint-disable @typescript-eslint/consistent-indexed-object-style */ |
| /* eslint-disable arrow-body-style */ |
| /* eslint-disable no-bitwise */ |
| /* eslint-disable object-shorthand */ |
| /* eslint-disable quotes */ |
| /* eslint-disable @typescript-eslint/no-unsafe-return */ |
| /* eslint-disable @typescript-eslint/no-explicit-any */ |
| /* eslint-disable @typescript-eslint/prefer-function-type */ |
| /* eslint-disable @typescript-eslint/no-explicit-any */ |
| |
| /* --------------------------------------------------------- |
| * Copyright 2025 The Go Authors. All rights reserved. |
| * Licensed under the MIT License. See LICENSE in the project root for license information. |
| *--------------------------------------------------------*/ |
| |
| import * as vscode from 'vscode'; |
| import { |
| ClientCapabilities, |
| ExecuteCommandSignature, |
| FeatureState, |
| LanguageClient, |
| LanguageClientOptions, |
| Middleware, |
| RequestType, |
| StaticFeature |
| } from 'vscode-languageclient/node'; |
| |
| // ---------------------------------------------------------------------------- |
| // Form Field Type Definitions |
| // ---------------------------------------------------------------------------- |
| |
| // TODO(hxjiang): extend the support for file input type as a subtype of string. |
| |
| // FormFieldTypeString defines a text input. |
| export interface FormFieldTypeString { |
| kind: 'string'; |
| |
| /* Validators for this form field. |
| * |
| * Field validators only validate the input/answer to a field in isolation |
| * and cannot depend on the answers to other fields. For example a string |
| * field may have a regex validator, or a numeric field may have a range |
| * validator. Multiple validators of the same kind are allowed. |
| * |
| * Answers must pass all (supported) validators to be considered valid. |
| * |
| * Clients must ignore validators they do not support. |
| */ |
| validators?: StringValidator[]; |
| } |
| |
| // FileExistence whether the file denoted by a DocumentURI exists. |
| // |
| // It is a bit set allowing combinations of existence states. For |
| // example, New|Existing allows either state. |
| export enum FileExistence { |
| // New indicates that file has not yet been created. |
| New = 1 << 0, |
| // Existing indicates that the file exists already. |
| Existing = 1 << 1 |
| } |
| |
| // FileType represents the expected filesystem resource type. |
| // |
| // It is a bit set allowing combinations of file types. For example, Regular|Directory |
| // allows either types. |
| export enum FileType { |
| // Regular indicates the resource could be a regular file. |
| Regular = 1 << 0, |
| // Directory indicates the resource could be a directory. |
| Directory = 1 << 1 |
| } |
| |
| // FormFieldTypeFile defines an input for a file or directory URI. |
| // |
| // The client determines the best mechanism to collect this information from |
| // the user (e.g., a graphical file picker, a text input with autocomplete, etc). |
| // |
| // The value returned by the client must be a valid "DocumentUri" as defined |
| // in the LSP specification: |
| // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#documentUri |
| export interface FormFieldTypeFile { |
| kind: 'file'; |
| |
| // Existence constraint. |
| existence?: FileExistence; |
| |
| // Type specifies the set of allowed file types (regular file, directory, etc). |
| // |
| // Only applicable against existing file. |
| type?: FileType; |
| |
| // Filters specifies the allowed file extensions without the leading dot. A file |
| // is valid if it matches any of the extensions (OR logic). e.g. ["png", "jpg"]. |
| // |
| // If omitted or empty, no extension filter is applied. |
| filters?: string[]; |
| } |
| |
| // FormFieldTypeBool defines a boolean input. |
| export interface FormFieldTypeBool { |
| kind: 'bool'; |
| } |
| |
| // FormFieldTypeNumber defines a numeric input. |
| export interface FormFieldTypeNumber { |
| kind: 'number'; |
| } |
| |
| // FormEnumEntry represents a single option in an enumeration. |
| export interface FormEnumEntry { |
| // Value is the unique string identifier for this option. |
| // |
| // This is the value that will be sent back to the server in |
| // 'FormAnswers' if the user selects this option. |
| value: string; |
| |
| // Description is the human-readable label presented to the user. |
| description: string; |
| } |
| |
| // FormFieldTypeEnum defines a selection from a set of values. |
| // |
| // Use this type when: |
| // - The number of options is small (e.g., < 20). |
| // - All options are known at the time the form is created. |
| export interface FormFieldTypeEnum { |
| kind: 'enum'; |
| |
| // Name is an optional identifier for the enum type. |
| name?: string; |
| |
| // Entries is the list of allowable options. |
| entries: FormEnumEntry[]; |
| } |
| |
| // FormFieldTypeLazyEnum defines a selection from a large or dynamic enum entry set. |
| // |
| // Use this type when: |
| // 1. The dataset is too large to send efficiently in a single payload |
| // (e.g., thousands of workspace symbols, file uri or cloud resources). |
| // 2. The available options depend on the user's input (e.g., semantic search). |
| // 3. Generating the list is expensive and should only be done if requested. |
| // |
| // The client is expected to render a search interface (e.g., a combo box with |
| // a text input) and query the server via 'interactive/listEnum' as the user types. |
| export interface FormFieldTypeLazyEnum { |
| kind: 'lazyEnum'; |
| |
| // TODO(hxjiang): consider make debounce configurable since fetching |
| // cloud resources could be expensive and slow. |
| |
| // Source identifies the data source on the server. |
| // |
| // Examples: "workspace/symbol", "database/schema", "git/tags". |
| source: string; |
| |
| // Config contains the static settings for the source. |
| // The client treats this as opaque data and echoes it back in the |
| // 'interactive/listEnum' request. |
| config?: any; |
| } |
| |
| // FormFieldTypeList defines a homogenous list of items. |
| export interface FormFieldTypeList { |
| kind: 'list'; |
| |
| // ElementType specifies the type of the items in the list. |
| // Recursive reference to the union type. |
| elementType: FormFieldType; |
| } |
| |
| // FormFieldType acts as a Discriminated Union based on the 'kind' property. |
| export type FormFieldType = |
| | FormFieldTypeString |
| | FormFieldTypeFile |
| | FormFieldTypeBool |
| | FormFieldTypeNumber |
| | FormFieldTypeEnum |
| | FormFieldTypeLazyEnum |
| | FormFieldTypeList; |
| |
| // ---------------------------------------------------------------------------- |
| // Main Form Definitions |
| // ---------------------------------------------------------------------------- |
| |
| // FormField describes a single question in a form and its validation state. |
| export interface FormField { |
| // ID is a unique identifier for this field. This key is used as the property |
| // name in FormAnswers to map the user's input back to this specific field. |
| id: string; |
| |
| // Description is the text content of the question (the prompt) presented to the user. |
| description: string; |
| |
| // Type specifies the data type and validation constraints for the answer. |
| type: FormFieldType; |
| |
| // Required specifies whether an answer is absolutely required for this field. |
| required: boolean; |
| |
| // Default specifies an optional initial value for the answer. |
| // If Type is FormFieldTypeEnum, this value must be present in the enum's values array. |
| default?: any; |
| |
| // Error provides a validation message from the language server. |
| // If empty or undefined, the current answer is considered valid. |
| error?: string; |
| } |
| |
| // FormAnswer describes a single answer to a FormField, identified by its unique |
| // ID. |
| export interface FormAnswer { |
| // The ID of the FormField being answered. |
| id: string; |
| |
| // The user's answer value. |
| value: any; |
| } |
| |
| // InteractiveParams facilitates a multi-step, interactive dialogue between the |
| // client and server during a Language Server Protocol (LSP) request. |
| // |
| // It implements a non-standard protocol extension microsoft/language-server-protocol#1164 |
| // . By embedding this type into standard request parameters (such as |
| // [ExecuteCommandParams] or [RenameParams]) and pairing them with dedicated |
| // resolution methods (like [Server.ResolveCommand] or other ResolveXXX handlers), |
| // standard operations can be transformed into interactive workflows. |
| // |
| // Standard LSP resolution methods (like "codeAction/resolve") cannot be used |
| // for these interactive forms because editors often trigger them eagerly to |
| // render previews, which would prematurely present UI forms to the user. |
| // The dedicated ResolveXXX pattern ensures the interactive dialogue strictly |
| // begins only *after* the user has explicitly indicated intent (for example, |
| // by clicking a specific Code Action). |
| // |
| // The following sequence illustrates the typical handshake, using a code action |
| // that resolves to a command as an example: |
| // |
| // 1. The client requests code actions for the current text selection. |
| // 2. The server responds with a code action containing a standard LSP Command |
| // (title, command, and arguments). |
| // 3. The client calls [Server.ResolveCommand] with the initial command details |
| // wrapped in an [ExecuteCommandParams] to determine if the execution requires |
| // interactive input. |
| // 4. The server responds with an [ExecuteCommandParams]. If user input is |
| // required, the server populates the FormFields array with the required schema. |
| // 5. The client observes the non-empty FormFields and presents a corresponding |
| // user interface. |
| // 6. The user submits their input, and the client issues another |
| // [Server.ResolveCommand] request, this time populating the FormAnswers array. |
| // 7. The server validates the answers. If invalid, it returns a form with error |
| // messages attached to specific FormFields. Steps 5-7 repeat until the server |
| // omits FormFields entirely, indicating the answers are valid and complete. |
| // 8. The client calls [Server.ExecuteCommand] with the finalized FormAnswers to |
| // execute the action. |
| // |
| // The server populates FormFields to define the input schema. If FormFields is |
| // omitted or empty, the interactive phase is considered complete and the provided |
| // FormAnswers have been fully validated. |
| // |
| // The server may optionally populate FormAnswers alongside FormFields to preserve |
| // previous user input or provide default values for the client to render. |
| export interface InteractiveParams { |
| // FormFields defines the questions and validation errors in previous |
| // answers to the same questions. |
| // |
| // This is a server-to-client field. The language server defines these, and |
| // the client uses them to render the form. |
| // |
| // The interactive phase is considered complete when the server returns a |
| // response where this slice is omitted. |
| formFields?: FormField[]; |
| |
| // FormAnswers contains the answers for the form questions. |
| // |
| // When sent by the language server, this field is optional and contains the |
| // current or default answers to the questions to support editing previous values. |
| // |
| // When sent by the language client, this field contains the user's answers. |
| // Answers are linked to their respective questions using the field's unique |
| // `id` rather than their array index. The list must not contain duplicate IDs, |
| // and each answer's ID must correspond to a field ID defined in `formFields`. |
| // |
| // The client must include answers for all required fields (where `required` |
| // is true). Answers for optional fields (where `required` is false) |
| // may be omitted if no answer was provided, or included if an answer is available. |
| formAnswers?: FormAnswer[]; |
| } |
| |
| // ---------------------------------------------------------------------------- |
| // Command Extension |
| // ---------------------------------------------------------------------------- |
| |
| // InteractiveExecuteCommandParams extends the standard LSP ExecuteCommandParams |
| // with the experimental fields for interactive forms. |
| export interface InteractiveExecuteCommandParams extends InteractiveParams { |
| /** |
| * The identifier of the actual command handler. |
| */ |
| command: string; |
| /** |
| * Arguments that the command should be invoked with. |
| */ |
| arguments?: any[]; |
| } |
| |
| /** |
| * InteractiveListEnumParams defines the parameters for the |
| * 'interactive/listEnum' request. |
| */ |
| export interface InteractiveListEnumParams { |
| /** |
| * Source identifies the data source on the server. |
| * |
| * The client treats this as opaque data and echoes it back in the |
| * 'interactive/listEnum' request. |
| * |
| * Examples: "workspace/symbol", "database/schema", "git/tags". |
| */ |
| source: string; |
| |
| /** |
| * Config contains the static settings for the specified source. |
| * |
| * The client treats this as opaque data and echoes it back in the |
| * 'interactive/listEnum' request. |
| */ |
| config?: any; |
| |
| /** |
| * A query string to filter enum entries by. |
| * |
| * The exact interpretation of this string (e.g., fuzzy matching, exact |
| * match, prefix search, or regular expression) is entirely up to the |
| * server and may vary depending on the source. This follows the similar |
| * semantics as the standard 'workspace/symbol' request. Clients may |
| * send an empty string here to request a default set of enum entries. |
| */ |
| query: string; |
| } |
| |
| // ---------------------------------------------------------------------------- |
| // Client Capability |
| // ---------------------------------------------------------------------------- |
| |
| export interface InteractiveResolveClientCapabilities { |
| /** |
| * The input types the client supports for interactive dialogs. |
| * The presence of this field implies support for interactive refactoring. |
| */ |
| inputTypes?: string[]; |
| |
| /** |
| * Field validators supported by the client. |
| * |
| * Servers may adapt the validators they include based on this field but |
| * they are not required to. Clients must simply ignore validators they |
| * do not understand. Servers must re-validate all fields as part of |
| * the overall form resolution. |
| * |
| * Clients show higher-severity validation messages before lower severity |
| * messages. If they are only showing the result of one validator they |
| * should process them first by severity, and then the order provided by |
| * the server. |
| */ |
| validators?: { |
| /** |
| * Validators supported on string fields. |
| */ |
| string?: string[]; |
| }; |
| } |
| |
| // ---------------------------------------------------------------------------- |
| // Server Capability |
| // ---------------------------------------------------------------------------- |
| |
| export interface interactiveResolveOptions { |
| /** |
| * The kinds of interactive resolutions that the server supports. |
| * |
| * For example, "command" indicates that the server supports resolving |
| * `ExecuteCommandParams` interactively through "command/resolve". |
| */ |
| kinds?: string[]; |
| } |
| |
| /** |
| * InteractiveLanguageClientOptions extends LanguageClientOptions with |
| * interactive middleware support. |
| */ |
| export interface InteractiveLanguageClientOptions extends LanguageClientOptions { |
| middleware?: InteractiveMiddleware; |
| } |
| |
| /** |
| * InteractiveMiddleware extends the standard Language Server Protocol (LSP) |
| * `Middleware` to support interactive refactoring workflows. |
| * |
| * This allows extension authors to intercept key lifecycle events of the |
| * interactive dialog, including the command resolution phase, the execution of |
| * the validated command, and dynamic enumeration requests for lazy-loaded options. |
| */ |
| export type InteractiveMiddleware = Middleware & |
| InteractiveListEnumMiddleware & |
| InteractiveExecuteCommandMiddleware & |
| InteractiveResolveCommandMiddleware; |
| |
| export interface InteractiveResolveCommandSignature { |
| (this: void, param: InteractiveExecuteCommandParams): vscode.ProviderResult<InteractiveExecuteCommandParams>; |
| } |
| |
| export interface InteractiveResolveCommandMiddleware { |
| interactiveResolveCommand?: ( |
| this: void, |
| param: InteractiveExecuteCommandParams, |
| next: InteractiveResolveCommandSignature |
| ) => vscode.ProviderResult<InteractiveExecuteCommandParams>; |
| } |
| |
| /** |
| * Signature for the command execution handler with user form answers. |
| * |
| * This signature is distinct from standard `ExecuteCommandSignature` because it |
| * accepts an additional `formAnswers` argument containing user-provided, |
| * server-validated inputs. |
| */ |
| export interface InteractiveExecuteCommandSignature { |
| (this: void, command: string, args: any[], formAnswers: any[]): vscode.ProviderResult<any>; |
| } |
| |
| /** |
| * InteractiveExecuteCommandMiddleware allows middleware implementations to |
| * intercept the execution of commands that require user-provided form answers. |
| * |
| * Note: This middleware is defined as a new, separate hook rather than reusing |
| * the standard `executeCommand` middleware. Reusing the standard middleware |
| * would require modifying its signature to accept the additional `formAnswers` |
| * parameter, which would break backward compatibility for existing extension |
| * middleware configurations. |
| */ |
| export interface InteractiveExecuteCommandMiddleware { |
| interactiveExecuteCommand?: ( |
| this: void, |
| command: string, |
| args: any[], |
| formAnswers: any[], |
| next: InteractiveExecuteCommandSignature |
| ) => vscode.ProviderResult<any>; |
| } |
| |
| export interface InteractiveListEnumSignature { |
| (this: void, param: InteractiveListEnumParams): vscode.ProviderResult<FormEnumEntry[]>; |
| } |
| |
| export interface InteractiveListEnumMiddleware { |
| interactiveListEnum?: ( |
| this: void, |
| param: InteractiveListEnumParams, |
| next: InteractiveListEnumSignature |
| ) => vscode.ProviderResult<FormEnumEntry[]>; |
| } |
| |
| /** |
| * The severity of a violation of a validator. |
| */ |
| export enum ValidationSeverity { |
| /** |
| * An informational message that does not block submission of the value. |
| */ |
| Info = 1, |
| /** |
| * A warning message that does not block submission of the value. |
| */ |
| Warning = 2, |
| /** |
| * An error message that prevents submission of the value. |
| */ |
| Error = 3 |
| } |
| |
| export interface Validator { |
| /** |
| * The severity of a violation of this validator. |
| */ |
| severity: ValidationSeverity; |
| |
| /** |
| * The message to show if the answer fails this validator. |
| */ |
| message: string; |
| } |
| |
| /** |
| * Validators applicable to string fields. |
| */ |
| export type StringValidator = RegexValidator; /* | FooValidator */ |
| |
| /** |
| * A regex-based validator that ensures an answer matches a given |
| * regex. |
| */ |
| export interface RegexValidator extends Validator { |
| kind: 'regex'; |
| |
| /** |
| * The regex pattern to validate the input. |
| * |
| * The server must only provide regular expressions for the engine supported by the client |
| * in the `general.regularExpressions` client capability. If the server cannot provide an |
| * appropriate regular expression it should not provide the regex validator. |
| */ |
| pattern: string; |
| |
| /** |
| * Whether the answer matching the pattern means it is valid (no message |
| * reported) or invalid (message reported). |
| */ |
| matchIsValid: boolean; |
| |
| /** |
| * The message to show if the answer is not valid according to `pattern` and `matchIsValid`. |
| */ |
| message: string; |
| } |
| |
| export class InteractiveFormsFeature implements StaticFeature { |
| constructor(private readonly client: LanguageClient) { |
| this.addMiddleware(); |
| } |
| |
| public clear() {} |
| public getState(): FeatureState { |
| return { kind: 'static' }; |
| } |
| public initialize() {} |
| |
| private addMiddleware() { |
| const interactiveOptions = this.client.clientOptions as InteractiveLanguageClientOptions; |
| const middleware = interactiveOptions.middleware; |
| const original = middleware?.executeCommand; |
| |
| // Intercept standard command execution to resolve required inputs interactively. |
| // Once resolved, route the execution to the appropriate middleware: |
| // - If the command required user answers, execute it using the |
| // "interactiveExecuteCommand" middleware. |
| // - If no user answers were collected, execute it using the standard |
| // "executeCommand" middleware. |
| const overwrite = async (cmd: string, args: any[], next: ExecuteCommandSignature) => { |
| const option = this.client.initializeResult?.capabilities?.experimental?.interactiveResolveProvider as |
| | interactiveResolveOptions |
| | undefined; |
| |
| // Language server does not support interactive command execution. |
| if (!option || !Array.isArray(option.kinds) || !option.kinds.includes('command')) { |
| return original ? original(cmd, args, next) : next(cmd, args); |
| } |
| |
| const resolved = await this.resolveCommandInteractively({ |
| command: cmd, |
| arguments: args |
| } as InteractiveExecuteCommandParams); |
| if (!resolved) { |
| return undefined; |
| } |
| |
| cmd = resolved.command; |
| args = resolved.arguments || []; |
| const formAnswers = resolved.formAnswers; |
| |
| if (formAnswers === undefined || formAnswers.length === 0) { |
| // Execute the vscode language client provided "workspace/executeCommand" |
| // method if the user does not provide any answers. |
| return original ? original(cmd, args, next) : next(cmd, args); |
| } else { |
| // Execute the interactive language client provided "workspace/executeCommand" |
| // method if the user does provide answers. |
| return this.interactiveExecuteCommand(cmd, args, formAnswers); |
| } |
| }; |
| |
| interactiveOptions.middleware = { |
| ...middleware, |
| executeCommand: overwrite |
| }; |
| } |
| |
| /** |
| * Fills in the LSP client capabilities to support interactive refactoring prompts. |
| */ |
| public fillClientCapabilities(capabilities: ClientCapabilities): void { |
| capabilities.experimental ??= {}; |
| capabilities.experimental.interactiveResolve = { |
| inputTypes: ['bool', 'file', 'enum', 'lazyEnum', 'number', 'string'], |
| validators: { |
| string: ['regex'] |
| } |
| } as InteractiveResolveClientCapabilities; |
| } |
| |
| /** |
| * MAX_RETRY defines the maximum number of user collection allowed for when |
| * resolving a command. |
| */ |
| static MAX_RETRY = 5; |
| |
| private async resolveCommandInteractively( |
| param: InteractiveExecuteCommandParams |
| ): Promise<InteractiveExecuteCommandParams | undefined> { |
| // Invoke "command/resolve" at least once to ensure the command |
| // is fully specified, as the initial input may lack necessary parameters. |
| for (let i = 0; i < InteractiveFormsFeature.MAX_RETRY; i++) { |
| const result = await this.interactiveResolveCommand(param); |
| if (!result) { |
| return undefined; |
| } |
| |
| param = result; |
| |
| // "formAnswers" are validated by the language server. |
| if (param.formFields === undefined) { |
| break; |
| } |
| |
| // Exhaust all retries. |
| if (i === InteractiveFormsFeature.MAX_RETRY - 1) { |
| vscode.window.showWarningMessage( |
| `Retried ${InteractiveFormsFeature.MAX_RETRY} exceeds the maximum allowed attempts` |
| ); |
| return undefined; |
| } |
| |
| for (const [index, field] of param.formFields.entries()) { |
| if (field.error) { |
| vscode.window.showWarningMessage(`Question ${index + 1}: ${field.error}`); |
| } |
| } |
| |
| const answers = await this.collectAnswers(param.formFields, param.formAnswers); |
| if (answers === undefined) { |
| return undefined; |
| } |
| param.formAnswers = answers; |
| param.formFields = undefined; |
| } |
| |
| return param; |
| } |
| |
| /** |
| * Executes a command on the language server with the validated form answers. |
| * |
| * It routes the execution through the `interactiveExecuteCommand` middleware |
| * hook if registered, falling back to sending a `'workspace/executeCommand'` |
| * LSP request with the user's answered form. |
| * |
| * @param command The identifier of the actual command handler to execute. |
| * @param args Arguments that the command should be invoked with. |
| * @param formAnswers The finalized, server-validated answers collected from the user. |
| * @returns A provider result resolving to the command execution result. |
| */ |
| private interactiveExecuteCommand = ( |
| command: string, |
| args: any[], |
| formAnswers: any[] |
| ): vscode.ProviderResult<any> => { |
| const _interactiveExecuteCommand: InteractiveExecuteCommandSignature = (command, args, formAnswers) => { |
| const requestType = new RequestType<InteractiveExecuteCommandParams, any, void>('workspace/executeCommand'); |
| return this.client |
| .sendRequest<any>('workspace/executeCommand', { |
| command: command, |
| arguments: args, |
| formAnswers: formAnswers |
| } as InteractiveExecuteCommandParams) |
| .then(undefined, (error) => { |
| return this.client.handleFailedRequest(requestType, undefined, error, undefined); |
| }); |
| }; |
| |
| const middleware = this.client.clientOptions.middleware as InteractiveMiddleware | undefined; |
| return middleware?.interactiveExecuteCommand |
| ? middleware.interactiveExecuteCommand(command, args, formAnswers, _interactiveExecuteCommand) |
| : _interactiveExecuteCommand(command, args, formAnswers); |
| }; |
| |
| /** |
| * Handles the interactive resolution of a command prior to its execution. |
| * |
| * It processes an [InteractiveExecuteCommandParams] to determine if the command |
| * requires interactive input, or to validate user-provided answers submitted |
| * via the embedded [InteractiveParams]. |
| * |
| * If the command requires user input (e.g., the initial probe) or if the |
| * provided answers are invalid, it returns a modified [InteractiveExecuteCommandParams] |
| * populated with FormFields to prompt the user. If the input is valid and |
| * complete, or if the command requires no interaction at all, it returns an |
| * [InteractiveExecuteCommandParams] with an empty form, signaling the client to |
| * proceed with execution. |
| * |
| * See [InteractiveParams] for the complete multi-step client-server handshake |
| * and the architectural reasoning behind dedicated ResolveXXX methods. |
| * |
| * It routes the resolution through the `interactiveResolveCommand` |
| * middleware hook if registered, falling back to sending a `'command/resolve'` |
| * LSP request. |
| * |
| * @param param The command parameters and previous answers to resolve/validate. |
| * @returns A provider result resolving to the updated command execution parameters. |
| */ |
| private interactiveResolveCommand = ( |
| param: InteractiveExecuteCommandParams |
| ): vscode.ProviderResult<InteractiveExecuteCommandParams> => { |
| const _interactiveResolveCommand: InteractiveResolveCommandSignature = (param) => { |
| const requestType = new RequestType<InteractiveExecuteCommandParams, any, void>('command/resolve'); |
| return this.client |
| .sendRequest<InteractiveExecuteCommandParams>('command/resolve', param) |
| .then(undefined, (error) => { |
| return this.client.handleFailedRequest(requestType, undefined, error, undefined); |
| }); |
| }; |
| |
| const middleware = this.client.clientOptions.middleware as InteractiveMiddleware | undefined; |
| return middleware?.interactiveResolveCommand |
| ? middleware.interactiveResolveCommand(param, _interactiveResolveCommand) |
| : _interactiveResolveCommand(param); |
| }; |
| |
| /** |
| * Queries the language server to dynamically retrieve enumeration entries for |
| * interactive form fields of type `'lazyEnum'`. |
| * |
| * It routes the query through the `interactiveListEnum` middleware hook if |
| * registered, falling back to sending an `'interactive/listEnum'` LSP request. |
| * |
| * @param param The query parameters, including the data source name, static |
| * config, and filter string. |
| * @returns A provider result resolving to the matching list of form |
| * enumeration entries. |
| */ |
| private interactiveListEnum = (param: InteractiveListEnumParams): vscode.ProviderResult<FormEnumEntry[]> => { |
| const _interactiveListEnum: InteractiveListEnumSignature = (param) => { |
| const requestType = new RequestType<InteractiveListEnumParams, FormEnumEntry[], void>( |
| 'interactive/listEnum' |
| ); |
| return this.client.sendRequest<FormEnumEntry[]>('interactive/listEnum', param).then(undefined, (error) => { |
| return this.client.handleFailedRequest(requestType, undefined, error, undefined); |
| }); |
| }; |
| |
| const middleware = this.client.clientOptions.middleware as InteractiveMiddleware | undefined; |
| return middleware?.interactiveListEnum |
| ? middleware.interactiveListEnum(param, _interactiveListEnum) |
| : _interactiveListEnum(param); |
| }; |
| |
| /** |
| * Iterates through the provided form fields and prompts the user for input |
| * using VS Code's native UI (e.g. InputBox, QuickPick...). |
| * |
| * Implementation Note: |
| * While multiple async calls could start this function simultaneously, a mutex |
| * is not needed. VS Code automatically cancels any active input box when a new |
| * one is requested. Because this function treats an 'undefined' result as a |
| * signal to terminate the entire flow, any previous sessions are effectively |
| * dropped, ensuring only the latest interactive refactoring proceeds. |
| * |
| * @param formFields The fields to collect answers for. |
| * @returns An array of answers matching the order of fields, or undefined if |
| * the user cancelled the process. |
| */ |
| private async collectAnswers( |
| formFields: FormField[] | undefined, |
| formAnswers: FormAnswer[] | undefined |
| ): Promise<FormAnswer[] | undefined> { |
| if (formFields === undefined) { |
| return undefined; |
| } |
| |
| const previousAnswers = new Map<string, any>(); |
| if (formAnswers) { |
| for (const answer of formAnswers) { |
| previousAnswers.set(answer.id, answer.value); |
| } |
| } |
| |
| const answers: FormAnswer[] = []; |
| |
| for (const field of formFields) { |
| const previousAnswer = previousAnswers.get(field.id); |
| if (previousAnswer !== undefined && field.error === undefined) { |
| answers.push({ id: field.id, value: previousAnswer } as FormAnswer); |
| continue; |
| } |
| |
| const value = await this.promptForField(field, previousAnswer); |
| |
| // An 'undefined' result occurs if the user manually cancels (e.g., |
| // "Escape" or cancel file picker) or if a new refactoring request is |
| // triggered, which automatically interrupts and cancels the current |
| // active input box. |
| // In both cases, we stop the sequence and drop the entire flow. |
| if (value === undefined) { |
| return undefined; |
| } |
| |
| answers.push({ |
| id: field.id, |
| value: value |
| } as FormAnswer); |
| } |
| |
| return answers; |
| } |
| |
| /** |
| * Opens a Quick Pick that dynamically fetches options from the Language Server. |
| */ |
| private async pickLazyEnum(description: string, source: string, config: any = {}): Promise<string | undefined> { |
| return new Promise((resolve) => { |
| const quickPick = vscode.window.createQuickPick<vscode.QuickPickItem & { value: string }>(); |
| |
| quickPick.title = description; |
| quickPick.placeholder = 'Type to search ' + source; |
| quickPick.matchOnDescription = true; |
| |
| let debounceTimeout: NodeJS.Timeout | undefined; |
| let isResolved = false; |
| |
| // Call "interactive/listEnum" and render result as entries as quick |
| // pick items. |
| const search = async (query: string) => { |
| quickPick.busy = true; |
| try { |
| const params: InteractiveListEnumParams = { |
| source: source, |
| config: config, |
| query: query |
| }; |
| const response = await this.interactiveListEnum(params); |
| |
| if (!response) { |
| quickPick.items = []; |
| return; |
| } |
| |
| quickPick.items = response.map((entry) => ({ |
| label: entry.description, |
| detail: entry.value !== entry.description ? entry.value : undefined, |
| value: entry.value |
| })); |
| } catch (e) { |
| console.error('Error fetching enum options:', e); |
| quickPick.items = []; |
| } finally { |
| quickPick.busy = false; |
| } |
| }; |
| |
| quickPick.onDidChangeValue((value) => { |
| if (debounceTimeout) clearTimeout(debounceTimeout); |
| debounceTimeout = setTimeout(() => search(value), 400); |
| }); |
| |
| quickPick.onDidAccept(() => { |
| const selection = quickPick.selectedItems[0]; |
| isResolved = true; |
| resolve(selection ? selection.value : undefined); |
| quickPick.hide(); |
| }); |
| |
| quickPick.onDidHide(() => { |
| if (!isResolved) resolve(undefined); |
| quickPick.dispose(); |
| }); |
| |
| quickPick.show(); |
| void search(''); // Initial Trigger |
| }); |
| } |
| |
| /** |
| * Validates a string, returning a user-facing error message if it's not valid. |
| */ |
| private validateString( |
| text: string, |
| fieldType: FormFieldTypeString, |
| { required }: { required: boolean } |
| ): vscode.InputBoxValidationMessage | null { |
| if (text.trim() === '') { |
| return required ? this.error('Please enter a value') : null; |
| } |
| |
| if (fieldType.validators) { |
| for (const validator of this.sortedValidators(fieldType.validators)) { |
| if (validator.kind === 'regex') { |
| let isMatch: boolean; |
| try { |
| isMatch = new RegExp(validator.pattern).test(text); |
| } catch { |
| // If the regex pattern is invalid, skip over this validator. |
| continue; |
| } |
| if (isMatch !== validator.matchIsValid) { |
| const message = validator.message; |
| // ValidationSeverity and vscode.InputBoxValidationSeverity match. |
| const severity = (validator.severity as unknown) as vscode.InputBoxValidationSeverity; |
| return { message, severity }; |
| } |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Sorts validators so that highest severity validators are first, but |
| * within each severity they preserve the original order. |
| * |
| * Validating a single value in this order means we can stop validating on |
| * the first validation message. |
| */ |
| private sortedValidators<T extends Validator>(validators: T[]): T[] { |
| return validators.slice().sort((a, b) => b.severity - a.severity); |
| } |
| |
| /** |
| * Validates a number, returning a user-facing error message if it's not valid. |
| */ |
| private validateNumber( |
| text: string, |
| { required, isList }: { required: boolean; isList: boolean } |
| ): vscode.InputBoxValidationMessage | null { |
| if (text.trim() === '') return required ? this.error('Please enter a number') : null; |
| return !Number.isFinite(Number(text)) |
| ? this.error(isList ? 'Please enter only valid numbers' : 'Please enter a valid number') |
| : null; |
| } |
| |
| private error(message: string): vscode.InputBoxValidationMessage { |
| return { message, severity: vscode.InputBoxValidationSeverity.Error }; |
| } |
| |
| /** |
| * Helper to prompt for a single field based on its type. |
| * |
| * Returns `undefined` if an input is cancelled. |
| * Returns `null` if an answer was skipped/no answer (for example an empty input). |
| */ |
| private async promptForField(field: FormField, prevAnswer: any | undefined): Promise<any | undefined> { |
| const fieldType = field.type; |
| |
| switch (fieldType.kind) { |
| case 'file': { |
| let canSelectFiles = true; |
| let canSelectFolders = true; |
| if (fieldType.type !== undefined) { |
| canSelectFiles = (fieldType.type & FileType.Regular) !== 0; |
| canSelectFolders = (fieldType.type & FileType.Directory) !== 0; |
| |
| // Safe fallback: if the constraint evaluates to allowing neither (which is |
| // likely a bug/misconfiguration in the language server), allow both so |
| // the file picker dialog is not completely disabled. |
| if (!canSelectFiles && !canSelectFolders) { |
| canSelectFiles = true; |
| canSelectFolders = true; |
| } |
| } |
| |
| let resourceName = 'File or Folder'; |
| let openIcon = '$(file)'; |
| let newIcon = '$(new-file)'; |
| if (canSelectFiles && !canSelectFolders) { |
| resourceName = 'File'; |
| openIcon = '$(file)'; |
| newIcon = '$(new-file)'; |
| } else if (!canSelectFiles && canSelectFolders) { |
| resourceName = 'Folder'; |
| openIcon = '$(folder)'; |
| newIcon = '$(new-folder)'; |
| } |
| |
| let actionTarget: 'open' | 'save' | undefined; |
| if (fieldType.existence !== undefined) { |
| const allowsNew = (fieldType.existence & FileExistence.New) !== 0; |
| const allowsExisting = (fieldType.existence & FileExistence.Existing) !== 0; |
| if (allowsNew && !allowsExisting) { |
| actionTarget = 'save'; |
| } else if (allowsExisting && !allowsNew) { |
| actionTarget = 'open'; |
| } |
| } |
| |
| if (actionTarget === undefined) { |
| // UX Decision: If the file existence constraint is not specified or |
| // allows both options, we explicitly separate the "Open" and "Create" |
| // flows to bypass a limitation in the native OS Save Dialog. |
| // |
| // While vscode.window.showSaveDialog allows selecting both new |
| // and existing paths, it forces a system-level "Do you want to |
| // replace it?" warning if an existing file is selected. |
| // |
| // Since our server will NOT actually overwrite the file (it |
| // just needs the URI), this warning is a false alarm that |
| // confuses users. We cannot disable this warning in the OS, so |
| // we split the flow: |
| // |
| // - "Select Existing": Uses showOpenDialog (Clean UX, no warnings) |
| // - "Create New": Uses showSaveDialog (The "Overwrite" warning |
| // is unavoidable here, but users expect some friction when |
| // "creating" over an existing name, so it is acceptable). |
| const action = await vscode.window.showQuickPick( |
| [ |
| { |
| label: `${openIcon} Select Existing ${resourceName}`, |
| description: `Select a ${resourceName.toLowerCase()} that already exists`, |
| target: 'open' as const |
| }, |
| { |
| label: `${newIcon} Create New ${resourceName}`, |
| description: `Select a destination for a new ${resourceName.toLowerCase()}`, |
| target: 'save' as const |
| } |
| ], |
| { |
| placeHolder: field.description || `Select ${resourceName.toLowerCase()} action`, |
| ignoreFocusOut: true |
| } |
| ); |
| |
| if (!action) { |
| return undefined; // User cancelled |
| } |
| actionTarget = action.target; |
| } |
| |
| let defaultUri: vscode.Uri | undefined; |
| const defaultUriString = (prevAnswer as string) || (field.default as string); |
| |
| if (defaultUriString) { |
| try { |
| defaultUri = vscode.Uri.parse(defaultUriString); |
| } catch { |
| // Ignore invalid URIs |
| } |
| } |
| |
| let filters: { [name: string]: string[] } | undefined; |
| if (fieldType.filters && fieldType.filters.length > 0) { |
| filters = { |
| 'Supported Files': fieldType.filters |
| }; |
| } |
| |
| if (actionTarget === 'open') { |
| const uri = await vscode.window.showOpenDialog({ |
| canSelectFiles: canSelectFiles, |
| canSelectFolders: canSelectFolders, |
| canSelectMany: false, |
| openLabel: 'Select', |
| defaultUri: defaultUri, |
| filters: canSelectFiles ? filters : undefined, |
| title: field.description || `Select Existing ${resourceName}` |
| } as vscode.OpenDialogOptions); |
| return uri?.[0] ? uri[0].toString() : undefined; |
| } else { |
| const uri = await vscode.window.showSaveDialog({ |
| defaultUri: defaultUri, |
| saveLabel: 'Select', |
| filters: filters, |
| title: field.description || `Create New ${resourceName}` |
| } as vscode.SaveDialogOptions); |
| return uri ? uri.toString() : undefined; |
| } |
| } |
| case 'string': { |
| const value = await vscode.window.showInputBox({ |
| prompt: field.description, |
| value: prevAnswer !== undefined && prevAnswer !== null ? prevAnswer : field.default, |
| placeHolder: field.description, |
| // Keep the input box open when focus is lost. This allows the |
| // user to browse the workspace or inspect code (e.g., checking |
| // destination files or existing struct tags) before answering. |
| ignoreFocusOut: true, |
| validateInput: (text) => this.validateString(text, fieldType, { required: field.required }) |
| } as vscode.InputBoxOptions); |
| |
| if (value === undefined) { |
| return undefined; // Cancelled. |
| } |
| if (value.trim() === '') { |
| return null; // Treat empty as no answer. |
| } |
| return value; |
| } |
| |
| case 'enum': { |
| const pickItems = fieldType.entries.map((entry) => { |
| return { |
| // Use description if it exists, otherwise use value |
| label: entry.description || entry.value, |
| // Show value in detail if description exists |
| description: entry.description ? entry.value : undefined, |
| value: entry.value |
| }; |
| }); |
| |
| const selected = await vscode.window.showQuickPick(pickItems, { |
| placeHolder: field.description, |
| ignoreFocusOut: true |
| }); |
| |
| return selected ? selected.value : undefined; |
| } |
| |
| case 'lazyEnum': { |
| return await this.pickLazyEnum(field.description, fieldType.source, fieldType.config); |
| } |
| |
| case 'bool': { |
| const boolItems = [ |
| { label: 'Yes', value: true }, |
| { label: 'No', value: false } |
| ]; |
| |
| const selectedBool = await vscode.window.showQuickPick(boolItems, { |
| placeHolder: field.description, |
| ignoreFocusOut: true |
| }); |
| |
| return selectedBool ? selectedBool.value : undefined; |
| } |
| |
| case 'number': { |
| let value: string | undefined; |
| if (prevAnswer !== undefined && prevAnswer !== null) { |
| value = String(prevAnswer); |
| } else if (field.default !== undefined && field.default !== null) { |
| value = String(field.default); |
| } |
| const numResult = await vscode.window.showInputBox({ |
| prompt: field.description, |
| value: value, |
| placeHolder: '0', |
| ignoreFocusOut: true, |
| validateInput: (text) => this.validateNumber(text, { required: field.required, isList: false }) |
| }); |
| |
| if (numResult === undefined) { |
| return undefined; // Cancelled. |
| } |
| if (numResult.trim() === '') { |
| return null; // Treat empty as no answer. |
| } |
| return Number(numResult); |
| } |
| |
| case 'list': { |
| // Basic support for lists of primitive strings/numbers via comma-separated input |
| if (fieldType.elementType.kind === 'string' || fieldType.elementType.kind === 'number') { |
| const rawList = await vscode.window.showInputBox({ |
| prompt: `${field.description} (comma separated)`, |
| ignoreFocusOut: true, |
| validateInput: (text) => { |
| if (text.trim() === '') { |
| return field.required ? this.error('Please enter at least one item') : null; |
| } |
| const parts = text.split(',').map((s) => s.trim()); |
| if (fieldType.elementType.kind === 'string') { |
| // For a list, we need to validate each item but keep the most severe validation |
| // message across all. |
| let mostSevereValidationIssue: vscode.InputBoxValidationMessage | undefined; |
| for (const part of parts) { |
| const partValidationResult = this.validateString(part, fieldType.elementType, { |
| required: true |
| }); |
| if (partValidationResult) { |
| if ( |
| !mostSevereValidationIssue || |
| partValidationResult.severity > mostSevereValidationIssue.severity |
| ) { |
| mostSevereValidationIssue = partValidationResult; |
| } |
| } |
| } |
| if (mostSevereValidationIssue) { |
| return mostSevereValidationIssue; |
| } |
| } else if (fieldType.elementType.kind === 'number') { |
| for (const part of parts) { |
| const partValidationResult = this.validateNumber(part, { |
| required: true, |
| isList: true |
| }); |
| if (partValidationResult) { |
| return partValidationResult; |
| } |
| } |
| } |
| return null; |
| } |
| }); |
| |
| if (rawList === undefined) { |
| return undefined; // Cancelled. |
| } |
| |
| if (rawList.trim() === '') { |
| // Treat empty as no answer. We shouldn't get here if required because |
| // of validation. |
| return null; |
| } |
| |
| const parts = rawList.split(',').map((s) => s.trim()); |
| if (fieldType.elementType.kind === 'number') { |
| // Validation should prevent us having NaNs here. |
| return parts.map(Number); |
| } |
| return parts; |
| } |
| |
| vscode.window.showErrorMessage( |
| `List input for ${fieldType.elementType.kind} is not supported in this version.` |
| ); |
| return undefined; |
| } |
| |
| default: |
| return undefined; |
| } |
| } |
| } |