blob: 0f655d975e5c6db0d43f085e4dc1d8bb8abace5c [file]
/* 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 { InitializeParams } from 'vscode-languageserver-protocol';
import { LanguageClient, RequestType, ServerOptions, LanguageClientOptions } 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';
}
// 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;
}
// 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 {
// 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;
// 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;
}
// 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 values for the form questions.
//
// When sent by the language server, this field is optional but recommended
// to support editing previous values.
//
// When sent by the language client as part of the ResolveXXX request, this
// field is required. The slice must have the same length as FormFields (one
// answer per question), where the answer at index i corresponds to the
// field at index i.
formAnswers?: any[];
}
// ----------------------------------------------------------------------------
// 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;
}
export class InteractiveLanguageClient extends LanguageClient {
constructor(
id: string,
name: string,
serverOptions: ServerOptions,
clientOptions: LanguageClientOptions,
forceDebug?: boolean
) {
super(id, name, serverOptions, clientOptions, forceDebug);
}
/**
* Fills in the LSP initialize parameters during the handshake, and registers
* client capabilities to support interactive refactoring prompts.
*
* @important Subclasses overriding this method must:
* 1. Call `super.fillInitializeParams(params)` first to preserve base client
* configurations.
* 2. Amend or merge properties into `params.capabilities.experimental`
* rather than overwriting the entire field, to prevent erasing the
* interactive capabilities.
*/
protected fillInitializeParams(params: InitializeParams): void {
super.fillInitializeParams(params);
const experimental = params.capabilities.experimental || {};
experimental.interactiveInputTypes = ['bool', 'file', 'enum', 'lazyEnum', 'number', 'string'];
params.capabilities.experimental = experimental;
}
/**
* MAX_RETRY defined the maximum number of user collection allowed for when
* resolving a command.
*/
static MAX_RETRY = 5;
async resolveCommandInteractively(
param: InteractiveExecuteCommandParams
): Promise<InteractiveExecuteCommandParams | undefined> {
// Avoid resolving for frequently triggered commands for performance.
if (param.command === 'gopls.package_symbols') {
return param;
}
// 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 < InteractiveLanguageClient.MAX_RETRY; i++) {
const result = await this.ResolveCommand(param);
if (!result) {
return undefined;
}
param = result;
// "formAnswers" are validated by the language server.
if (param.formFields === undefined) {
break;
}
// Exhaust all retries.
if (i === InteractiveLanguageClient.MAX_RETRY - 1) {
vscode.window.showWarningMessage(
`Retried ${InteractiveLanguageClient.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;
}
// ResolveCommand 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.
async ResolveCommand(param: InteractiveExecuteCommandParams): Promise<InteractiveExecuteCommandParams | undefined> {
const requestType = new RequestType<InteractiveExecuteCommandParams, InteractiveExecuteCommandParams, void>(
'command/resolve'
);
return this.sendRequest<InteractiveExecuteCommandParams>('command/resolve', param).then(undefined, (error) => {
return this.handleFailedRequest(requestType, undefined, error, undefined);
});
}
// Executes an LSP command with an extended payload containing interactive form
// answers.
async InteractiveExecuteCommand(command: string, args: any[], formAnswers: any[]): Promise<any> {
const requestType = new RequestType<InteractiveExecuteCommandParams, any, void>('workspace/executeCommand');
return this.sendRequest('workspace/executeCommand', {
command: command,
arguments: args,
formAnswers: formAnswers
} as InteractiveExecuteCommandParams).then(undefined, (error) => {
return this.handleFailedRequest(requestType, undefined, error, undefined);
});
}
// Queries the language server to dynamically retrieve enumeration entries for
// interactive form fields of type 'lazyEnum'.
async InteractiveListEnum(param: InteractiveListEnumParams): Promise<FormEnumEntry[] | undefined> {
const requestType = new RequestType<InteractiveListEnumParams, FormEnumEntry[], void>('interactive/listEnum');
return this.sendRequest<FormEnumEntry[]>('interactive/listEnum', param).then(undefined, (error) => {
return this.handleFailedRequest(requestType, undefined, error, undefined);
});
}
/**
* 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: any[] | undefined
): Promise<any[] | undefined> {
if (formFields === undefined) {
return undefined;
}
const answers: any[] = [];
for (let i = 0; i < formFields.length; i++) {
const field = formFields[i];
const previousAnswer = formAnswers && i < formAnswers.length ? formAnswers[i] : undefined;
const answer = 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 (answer === undefined) {
return undefined;
}
answers.push(answer);
}
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();
search(''); // Initial Trigger
});
}
/**
* Helper to prompt for a single field based on its type.
*/
private async promptForField(field: FormField, prevAnswer: any | undefined): Promise<any | undefined> {
const type = field.type;
switch (type.kind) {
case 'file': {
// UX Decision: Explicitly separate "Open" and "Create" flows.
//
// We use this "Intent Menu" 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:
//
// - "Open 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: '$(file) Open Existing File',
description: 'Select a file that already exists',
target: 'open'
},
{
label: '$(new-file) Create New File',
description: 'Select a destination for a new file',
target: 'save'
}
],
{
placeHolder: field.description || 'Select file action',
ignoreFocusOut: true
}
);
// TODO(hxjiang): support reading the resource kind & existence
// from the file kind.
if (!action) {
return undefined; // User cancelled
}
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
}
}
if (action.target === 'open') {
const uri = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Select',
defaultUri: defaultUri,
title: field.description || 'Select Existing File'
} as vscode.OpenDialogOptions);
return uri && uri[0] ? uri[0].toString() : undefined;
} else {
const uri = await vscode.window.showSaveDialog({
defaultUri: defaultUri,
saveLabel: 'Select',
title: field.description || 'Create New File'
} as vscode.SaveDialogOptions);
return uri ? uri.toString() : undefined;
}
}
case 'string':
return await vscode.window.showInputBox({
prompt: field.description,
value: prevAnswer ? 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
} as vscode.InputBoxOptions);
case 'enum': {
const pickItems = type.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, type.source, type.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) {
value = String(prevAnswer);
} else if (field.default) {
value = String(field.default);
}
const numResult = await vscode.window.showInputBox({
prompt: field.description,
value: value,
placeHolder: '0',
ignoreFocusOut: true,
validateInput: (text) => {
return isNaN(Number(text)) ? 'Please enter a valid number' : null;
}
});
return numResult !== undefined ? Number(numResult) : undefined;
}
case 'list': {
// Basic support for lists of primitive strings/numbers via comma-separated input
if (type.elementType.kind === 'string' || type.elementType.kind === 'number') {
const rawList = await vscode.window.showInputBox({
prompt: `${field.description} (comma separated)`,
ignoreFocusOut: true
});
if (rawList === undefined) {
return undefined;
}
// If empty input, return empty list
if (rawList.trim() === '') {
return [];
}
const parts = rawList.split(',').map((s) => s.trim());
if (type.elementType.kind === 'number') {
return parts.map(Number).filter((n) => !isNaN(n));
}
return parts;
}
vscode.window.showErrorMessage(
`List input for ${type.elementType.kind} is not supported in this version.`
);
return undefined;
}
default:
return undefined;
}
}
}