From ee62554c482043ea4ddb086cda789b14fa7bffc4 Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Tue, 2 Jul 2024 12:14:23 -0700 Subject: [PATCH] Add ability to select a documents project context. - Adds select project context commands - Adds the select command to the Project Context status item - Updates middleware to send selected context with server requests. --- l10n/bundle.l10n.json | 2 + package.json | 8 +- package.nls.json | 1 + src/lsptoolshost/commands.ts | 52 +++++++++++++ src/lsptoolshost/languageStatusBar.ts | 6 +- src/lsptoolshost/roslynLanguageServer.ts | 30 +++++++- .../services/projectContextService.ts | 77 +++++++++++++++++-- 7 files changed, 164 insertions(+), 12 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 6ad19dccd..698958bb2 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -187,10 +187,12 @@ "Open solution": "Open solution", "Restart server": "Restart server", "C# Workspace Status": "C# Workspace Status", + "Select context": "Select context", "C# Project Context Status": "C# Project Context Status", "Active File Context": "Active File Context", "Pick a fix all scope": "Pick a fix all scope", "Fix All Code Action": "Fix All Code Action", + "Select project context": "Select project context", "Failed to set extension directory": "Failed to set extension directory", "Failed to set debugadpter directory": "Failed to set debugadpter directory", "Failed to set install complete file path": "Failed to set install complete file path", diff --git a/package.json b/package.json index 563e1cadc..48b4dc637 100644 --- a/package.json +++ b/package.json @@ -1833,6 +1833,12 @@ "category": ".NET", "enablement": "dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'OmniSharp'" }, + { + "command": "csharp.changeProjectContext", + "title": "%command.csharp.changeProjectContext%", + "category": "CSharp", + "enablement": "dotnet.server.activationContext == 'Roslyn'" + }, { "command": "csharp.listProcess", "title": "%command.csharp.listProcess%", @@ -5584,4 +5590,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.nls.json b/package.nls.json index 42ebf7acf..59571394f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -10,6 +10,7 @@ "command.dotnet.generateAssets.currentProject": "Generate Assets for Build and Debug", "command.dotnet.restore.project": "Restore Project", "command.dotnet.restore.all": "Restore All Projects", + "command.csharp.changeProjectContext": "Change the active document's project context", "command.csharp.downloadDebugger": "Download .NET Core Debugger", "command.csharp.listProcess": "List process for attach", "command.csharp.listRemoteProcess": "List processes on remote connection for attach", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 5d8d1edf3..dfcf09e5f 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -12,6 +12,8 @@ import reportIssue from '../shared/reportIssue'; import { getDotnetInfo } from '../shared/utils/getDotnetInfo'; import { IHostExecutableResolver } from '../shared/constants/IHostExecutableResolver'; import { getCSharpDevKit } from '../utils/getCSharpDevKit'; +import { VSProjectContext, VSProjectContextList } from './roslynProtocol'; +import { CancellationToken } from 'vscode-languageclient/node'; export function registerCommands( context: vscode.ExtensionContext, @@ -44,6 +46,11 @@ export function registerCommands( vscode.commands.registerCommand('dotnet.openSolution', async () => openSolution(languageServer)) ); } + context.subscriptions.push( + vscode.commands.registerCommand('csharp.changeProjectContext', async (options) => + changeProjectContext(languageServer, options) + ) + ); context.subscriptions.push( vscode.commands.registerCommand('csharp.reportIssue', async () => reportIssue( @@ -194,3 +201,48 @@ async function openSolution(languageServer: RoslynLanguageServer): Promise { + const editor = vscode.window.activeTextEditor; + if (editor === undefined) { + return; + } + const contextList = await languageServer._projectContextService.getProjectContexts( + editor.document.uri, + CancellationToken.None + ); + if (contextList === undefined) { + return; + } + + let context: VSProjectContext | undefined = undefined; + + if (options !== undefined) { + const contextLabel = `${options.projectName} (${options.tfm})`; + context = contextList._vs_projectContexts.find((context) => context._vs_label === contextLabel); + } else { + const items = contextList._vs_projectContexts.map((context) => { + return { label: context._vs_label, context }; + }); + const selectedItem = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select project context'), + }); + context = selectedItem?.context; + } + + if (context === undefined) { + return; + } + + languageServer._projectContextService.setActiveFileContext(contextList, context); + // TODO: Replace this with proper server-side onDidChange notifications + editor.edit(() => 0); +} + +interface ChangeProjectContextOptions { + projectName: string; + tfm: string; +} diff --git a/src/lsptoolshost/languageStatusBar.ts b/src/lsptoolshost/languageStatusBar.ts index d0656e47f..b95a3aec4 100644 --- a/src/lsptoolshost/languageStatusBar.ts +++ b/src/lsptoolshost/languageStatusBar.ts @@ -64,7 +64,10 @@ class ProjectContextStatus { RazorLanguage.documentSelector ); const projectContextService = languageServer._projectContextService; - + const selectContextCommand = { + command: 'csharp.changeProjectContext', + title: vscode.l10n.t('Select context'), + }; const item = vscode.languages.createLanguageStatusItem('csharp.projectContextStatus', documentSelector); item.name = vscode.l10n.t('C# Project Context Status'); item.detail = vscode.l10n.t('Active File Context'); @@ -72,6 +75,7 @@ class ProjectContextStatus { projectContextService.onActiveFileContextChanged((e) => { item.text = e.context._vs_label; + item.command = e.hasAdditionalContexts ? selectContextCommand : undefined; // Show a warning when the active file is part of the Miscellaneous File workspace and // project initialization is complete. diff --git a/src/lsptoolshost/roslynLanguageServer.ts b/src/lsptoolshost/roslynLanguageServer.ts index e391bdf2d..41cb6bbf9 100644 --- a/src/lsptoolshost/roslynLanguageServer.ts +++ b/src/lsptoolshost/roslynLanguageServer.ts @@ -57,7 +57,7 @@ import { registerRazorCommands } from './razorCommands'; import { registerOnAutoInsert } from './onAutoInsert'; import { registerCodeActionFixAllCommands } from './fixAllCodeAction'; import { commonOptions, languageServerOptions, omnisharpOptions, razorOptions } from '../shared/options'; -import { NamedPipeInformation } from './roslynProtocol'; +import { NamedPipeInformation, VSTextDocumentIdentifier } from './roslynProtocol'; import { IDisposable } from '../disposable'; import { registerNestedCodeActionCommands } from './nestedCodeAction'; import { registerRestoreCommands } from './restore'; @@ -144,7 +144,7 @@ export class RoslynLanguageServer { this._buildDiagnosticService = new BuildDiagnosticsService(diagnosticsReportedByBuild); this.registerDocumentOpenForDiagnostics(); - this._projectContextService = new ProjectContextService(this, this._languageServerEvents); + this._projectContextService = new ProjectContextService(this, _languageServerEvents); // Register Razor dynamic file info handling this.registerDynamicFileInfo(); @@ -273,6 +273,7 @@ export class RoslynLanguageServer { }; const documentSelector = languageServerOptions.documentSelector; + let server: RoslynLanguageServer | undefined = undefined; // Options to control the language client const clientOptions: LanguageClientOptions = { @@ -293,6 +294,12 @@ export class RoslynLanguageServer { protocol2Code: UriConverter.deserialize, }, middleware: { + async sendRequest(type, param, token, next) { + if (server !== undefined && type !== RoslynProtocol.VSGetProjectContextsRequest.type) { + await RoslynLanguageServer.tryAddProjectContext(param, server); + } + return next(type, param, token); + }, workspace: { configuration: (params) => readConfigurations(params), }, @@ -309,7 +316,7 @@ export class RoslynLanguageServer { client.registerProposedFeatures(); - const server = new RoslynLanguageServer(client, platformInfo, context, telemetryReporter, languageServerEvents); + server = new RoslynLanguageServer(client, platformInfo, context, telemetryReporter, languageServerEvents); client.registerFeature(server._onAutoInsertFeature); @@ -318,6 +325,19 @@ export class RoslynLanguageServer { return server; } + private static async tryAddProjectContext(param: unknown | undefined, server: RoslynLanguageServer): Promise { + if (!isObject(param)) { + return; + } + + const textDocument = (param['textDocument'] || param['_vs_textDocument']); + if (!textDocument) { + return; + } + + textDocument._vs_projectContext = await server._projectContextService.getDocumentContext(textDocument.uri); + } + public async stop(): Promise { await this._languageClient.stop(RoslynLanguageServer._stopTimeout); } @@ -1215,3 +1235,7 @@ function getSessionId(): string { export function isString(value: any): value is string { return typeof value === 'string' || value instanceof String; } + +export function isObject(value: any): value is { [key: string]: any } { + return value !== null && typeof value === 'object'; +} diff --git a/src/lsptoolshost/services/projectContextService.ts b/src/lsptoolshost/services/projectContextService.ts index 8dd9589b7..cb5aba451 100644 --- a/src/lsptoolshost/services/projectContextService.ts +++ b/src/lsptoolshost/services/projectContextService.ts @@ -10,12 +10,14 @@ import { TextDocumentIdentifier } from 'vscode-languageserver-protocol'; import { UriConverter } from '../uriConverter'; import { LanguageServerEvents } from '../languageServerEvents'; import { ServerState } from '../serverStateChange'; +import { CancellationToken } from 'vscode-languageclient/node'; export interface ProjectContextChangeEvent { languageId: string; uri: vscode.Uri; context: VSProjectContext; isVerified: boolean; + hasAdditionalContexts: boolean; } const VerificationDelay = 2 * 1000; @@ -24,6 +26,7 @@ let _verifyTimeout: NodeJS.Timeout | undefined; let _documentUriToVerify: vscode.Uri | undefined; export class ProjectContextService { + private readonly _projectContextMap: Map = new Map(); private readonly _contextChangeEmitter = new vscode.EventEmitter(); private _source = new vscode.CancellationTokenSource(); private readonly _emptyProjectContext: VSProjectContext = { @@ -50,6 +53,53 @@ export class ProjectContextService { return this._contextChangeEmitter.event; } + public async getDocumentContext(uri: string | vscode.Uri): Promise; + public async getDocumentContext( + uri: string | vscode.Uri, + contextList?: VSProjectContextList | undefined + ): Promise; + public async getDocumentContext( + uri: string | vscode.Uri, + contextList?: VSProjectContextList | undefined + ): Promise { + // To find the current context for the specified document we need to know the list + // of contexts that it is a part of. + contextList ??= await this.getProjectContexts(uri, CancellationToken.None); + if (contextList === undefined) { + return undefined; + } + + const key = this.getContextKey(contextList); + + // If this list of contexts hasn't been queried before that set the context to the default. + if (!this._projectContextMap.has(key)) { + this._projectContextMap.set(key, contextList._vs_projectContexts[contextList._vs_defaultIndex]); + } + + return this._projectContextMap.get(key); + } + + private getContextKey(contextList: VSProjectContextList): string { + return contextList._vs_projectContexts + .map((context) => context._vs_label) + .sort() + .join(';'); + } + + public setActiveFileContext(contextList: VSProjectContextList, context: VSProjectContext): void { + const textEditor = vscode.window.activeTextEditor; + const uri = textEditor?.document?.uri; + const languageId = textEditor?.document?.languageId; + if (!uri || languageId !== 'csharp') { + return; + } + + const key = this.getContextKey(contextList); + this._projectContextMap.set(key, context); + + this._contextChangeEmitter.fire({ languageId, uri, context, isVerified: true, hasAdditionalContexts: true }); + } + public async refresh() { const textEditor = vscode.window.activeTextEditor; const languageId = textEditor?.document?.languageId; @@ -83,19 +133,32 @@ export class ProjectContextService { } if (!this._languageServer.isRunning()) { - this._contextChangeEmitter.fire({ languageId, uri, context: this._emptyProjectContext, isVerified: false }); + this._contextChangeEmitter.fire({ + languageId, + uri, + context: this._emptyProjectContext, + isVerified: false, + hasAdditionalContexts: false, + }); return; } const contextList = await this.getProjectContexts(uri, this._source.token); if (!contextList) { - this._contextChangeEmitter.fire({ languageId, uri, context: this._emptyProjectContext, isVerified: false }); + this._contextChangeEmitter.fire({ + languageId, + uri, + context: this._emptyProjectContext, + isVerified: false, + hasAdditionalContexts: false, + }); return; } - const context = contextList._vs_projectContexts[contextList._vs_defaultIndex]; + const context = await this.getDocumentContext(uri, contextList); const isVerified = !context._vs_is_miscellaneous || isVerifyPass; - this._contextChangeEmitter.fire({ languageId, uri, context, isVerified }); + const hasAdditionalContexts = contextList._vs_projectContexts.length > 1; + this._contextChangeEmitter.fire({ languageId, uri, context, isVerified, hasAdditionalContexts }); if (context._vs_is_miscellaneous && !isVerifyPass) { // Request the active project context be refreshed but delay the request to give @@ -114,11 +177,11 @@ export class ProjectContextService { } } - private async getProjectContexts( - uri: vscode.Uri, + public async getProjectContexts( + uri: string | vscode.Uri, token: vscode.CancellationToken ): Promise { - const uriString = UriConverter.serialize(uri); + const uriString = uri instanceof vscode.Uri ? UriConverter.serialize(uri) : uri; const textDocument = TextDocumentIdentifier.create(uriString); try {