Skip to content

Commit a8fb60c

Browse files
committed
extension/src: preserve FormAnswers after interactive resolution
Update the interactive command loop to terminate as soon as the server returns an empty FormFields array. Instead of waiting for FormAnswers to also be empty, the client now retains the server-validated FormAnswers and attaches them to the final executeCommand payload. This aligns with the new gopls contract to support interactive refactorings on standard LSP methods. gopls CL 758060 For golang/go#76331 Change-Id: If4e82d699b593865a3fdf4e8f14956fd25b9f017 Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/758080 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Madeline Kalil <mkalil@google.com>
1 parent 482492d commit a8fb60c

2 files changed

Lines changed: 97 additions & 16 deletions

File tree

extension/src/language/form.ts

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,69 @@ export interface FormField {
135135
error?: string;
136136
}
137137

138+
// InteractiveParams facilitates a multi-step, interactive dialogue between the
139+
// client and server during a Language Server Protocol (LSP) request.
140+
//
141+
// It implements a non-standard protocol extension microsoft/language-server-protocol#1164
142+
// . By embedding this type into standard request parameters (such as
143+
// [ExecuteCommandParams] or [RenameParams]) and pairing them with dedicated
144+
// resolution methods (like [Server.ResolveCommand] or other ResolveXXX handlers),
145+
// standard operations can be transformed into interactive workflows.
146+
//
147+
// Standard LSP resolution methods (like "codeAction/resolve") cannot be used
148+
// for these interactive forms because editors often trigger them eagerly to
149+
// render previews, which would prematurely present UI forms to the user.
150+
// The dedicated ResolveXXX pattern ensures the interactive dialogue strictly
151+
// begins only *after* the user has explicitly indicated intent (for example,
152+
// by clicking a specific Code Action).
153+
//
154+
// The following sequence illustrates the typical handshake, using a code action
155+
// that resolves to a command as an example:
156+
//
157+
// 1. The client requests code actions for the current text selection.
158+
// 2. The server responds with a code action containing a standard LSP Command
159+
// (title, command, and arguments).
160+
// 3. The client calls [Server.ResolveCommand] with the initial command details
161+
// wrapped in an [ExecuteCommandParams] to determine if the execution requires
162+
// interactive input.
163+
// 4. The server responds with an [ExecuteCommandParams]. If user input is
164+
// required, the server populates the FormFields array with the required schema.
165+
// 5. The client observes the non-empty FormFields and presents a corresponding
166+
// user interface.
167+
// 6. The user submits their input, and the client issues another
168+
// [Server.ResolveCommand] request, this time populating the FormAnswers array.
169+
// 7. The server validates the answers. If invalid, it returns a form with error
170+
// messages attached to specific FormFields. Steps 5-7 repeat until the server
171+
// omits FormFields entirely, indicating the answers are valid and complete.
172+
// 8. The client calls [Server.ExecuteCommand] with the finalized FormAnswers to
173+
// execute the action.
174+
//
175+
// The server populates FormFields to define the input schema. If FormFields is
176+
// omitted or empty, the interactive phase is considered complete and the provided
177+
// FormAnswers have been fully validated.
178+
//
179+
// The server may optionally populate FormAnswers alongside FormFields to preserve
180+
// previous user input or provide default values for the client to render.
138181
export interface InteractiveParams {
139-
/**
140-
* FormFields defines the questions and validation errors.
141-
* This is a server-to-client field.
142-
*/
182+
// FormFields defines the questions and validation errors in previous
183+
// answers to the same questions.
184+
//
185+
// This is a server-to-client field. The language server defines these, and
186+
// the client uses them to render the form.
187+
//
188+
// The interactive phase is considered complete when the server returns a
189+
// response where this slice is omitted.
143190
formFields?: FormField[];
144191

145-
/**
146-
* FormAnswers contains the values for the form questions.
147-
* When sent by the language server, this acts as preserved/previous input.
148-
* When sent by the client (in a resolve request), this is required when
149-
* formFields are defined.
150-
*/
192+
// FormAnswers contains the values for the form questions.
193+
//
194+
// When sent by the language server, this field is optional but recommended
195+
// to support editing previous values.
196+
//
197+
// When sent by the language client as part of the ResolveXXX request, this
198+
// field is required. The slice must have the same length as FormFields (one
199+
// answer per question), where the answer at index i corresponds to the
200+
// field at index i.
151201
formAnswers?: any[];
152202
}
153203

@@ -174,11 +224,27 @@ export interface InteractiveExecuteCommandParams extends InteractiveParams {
174224
*/
175225
const MAX_RETRY = 5;
176226

227+
// ResolveCommand handles the interactive resolution of a command prior to
228+
// its execution.
229+
//
230+
// It processes an [ExecuteCommandParams] to determine if the command requires
231+
// interactive input, or to validate user-provided answers submitted via the
232+
// embedded [InteractiveParams].
233+
//
234+
// If the command requires user input (e.g., the initial probe) or if the
235+
// provided answers are invalid, it returns a modified [ExecuteCommandParams]
236+
// populated with FormFields to prompt the user. If the input is valid and
237+
// complete, or if the command requires no interaction at all, it returns an
238+
// [ExecuteCommandParams] with an empty form, signaling the client to proceed
239+
// with execution.
240+
//
241+
// See [InteractiveParams] for the complete multi-step client-server handshake
242+
// and the architectural reasoning behind dedicated ResolveXXX methods.
177243
export async function ResolveCommand(
178244
goCtx: GoExtensionContext,
179245
command: string,
180246
args: any[]
181-
): Promise<{ command: string; args: any[] } | undefined> {
247+
): Promise<{ command: string; args: any[]; formAnswers?: any[] } | undefined> {
182248
// Avoid resolving for frequently triggered commands for performance.
183249
if (command === 'gopls.package_symbols') {
184250
return { command: command, args: args };
@@ -220,7 +286,7 @@ export async function ResolveCommand(
220286

221287
param = response as InteractiveExecuteCommandParams;
222288

223-
// No information needed from the gopls.
289+
// "formAnswers" are validated by the language server.
224290
if (param.formFields === undefined) {
225291
break;
226292
}
@@ -245,7 +311,7 @@ export async function ResolveCommand(
245311
param.formFields = undefined;
246312
}
247313

248-
return { command: param.command, args: param.arguments ? param.arguments : [] };
314+
return { command: param.command, args: param.arguments ? param.arguments : [], formAnswers: param.formAnswers };
249315
}
250316

251317
/**
@@ -460,7 +526,7 @@ async function promptForField(field: FormField, prevAnswer: any | undefined): Pr
460526
if (action.target === 'open') {
461527
const uri = await vscode.window.showOpenDialog({
462528
canSelectFiles: true,
463-
canSelectFolders: false,
529+
canSelectFolders: true,
464530
canSelectMany: false,
465531
openLabel: 'Select',
466532
defaultUri: defaultUri,

extension/src/language/goLanguageServer.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import { GoDocumentSelector } from '../goMode';
6969
import { COMMAND as GOPLS_ADD_TEST_COMMAND } from '../goGenerateTests';
7070
import { COMMAND as GOPLS_MODIFY_TAGS_COMMAND } from '../goModifytags';
7171
import { TelemetryKey, telemetryReporter } from '../goTelemetry';
72-
import { ResolveCommand } from './form';
72+
import { InteractiveExecuteCommandParams, ResolveCommand } from './form';
7373

7474
export interface LanguageServerConfig {
7575
serverName: string;
@@ -579,6 +579,7 @@ export async function buildLanguageClient(
579579
next(token, params);
580580
},
581581
executeCommand: async (command: string, args: any[], next: ExecuteCommandSignature) => {
582+
let formAnswers: any[] | undefined;
582583
const supported = c.initializeResult?.capabilities?.experimental?.interactiveResolveProvider;
583584
if (Array.isArray(supported) && supported.includes('command')) {
584585
const resolved = await ResolveCommand(goCtx, command, args);
@@ -589,6 +590,7 @@ export async function buildLanguageClient(
589590
// Replace original command and result with resolved command and args.
590591
command = resolved.command;
591592
args = resolved.args;
593+
formAnswers = resolved.formAnswers;
592594
}
593595

594596
try {
@@ -609,7 +611,20 @@ export async function buildLanguageClient(
609611
govulncheckTerminal.appendLine(`⚡ govulncheck -C ${dir} ./...\n\n`);
610612
govulncheckTerminal.show();
611613
}
612-
const res = await next(command, args);
614+
615+
let res: any;
616+
if (formAnswers === undefined || formAnswers.length === 0) {
617+
res = await next(command, args);
618+
} else {
619+
res = await vscode.commands.executeCommand('gopls.lsp', {
620+
method: 'workspace/executeCommand',
621+
param: {
622+
command: command,
623+
arguments: args,
624+
formAnswers: formAnswers
625+
} as InteractiveExecuteCommandParams
626+
});
627+
}
613628

614629
const progressToken = res?.Token as ProgressToken;
615630
// The progressToken from executeCommand indicates that

0 commit comments

Comments
 (0)