From a7f76c368998127176d0a41dd4f76eac9f750b5c Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Feb 2025 14:20:22 -0800 Subject: [PATCH 001/255] Update C# onEnterRules to account for documentation comments --- extensions/csharp/language-configuration.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/extensions/csharp/language-configuration.json b/extensions/csharp/language-configuration.json index 60814ae02f4..7db6640f4c5 100644 --- a/extensions/csharp/language-configuration.json +++ b/extensions/csharp/language-configuration.json @@ -84,9 +84,10 @@ }, "onEnterRules": [ // Add // when pressing enter from inside line comment + // We do not want to match /// (a documentation comment) { "beforeText": { - "pattern": "\/\/.*" + "pattern": "[^\/]\/\/[^\/].*" }, "afterText": { "pattern": "^(?!\\s*$).+" @@ -96,5 +97,16 @@ "appendText": "// " } }, + // Add /// when pressing enter from anywhere inside a documentation comment. + // Documentation comments are not valid after non-whitespace. + { + "beforeText": { + "pattern": "^\\s*\/\/\/" + }, + "action": { + "indent": "none", + "appendText": "/// " + } + }, ] } From eb700003ebf3797af1de1f9e222478d6d7f7eea3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sat, 8 Mar 2025 10:19:08 -0800 Subject: [PATCH 002/255] mcp: initial hacking This is some basic work to get MCP hooked up. It's enough to discover and establish connections to MCP servers on the machine with very very basic commands to manage them: ![](https://memes.peet.io/img/25-03-2220f93e-3d1f-41ab-867e-0b9ba616ec6f.mp4) Refs #242864 The McpRegistry registers both collections of servers (from various config files) and 'delegates', which is currently _only_ the node extension host but is pretty generic and so could point at other processes in the future. SSE could even be served from the renderer when we aren't on a remote. It wraps into IMcpServerConnection's, which do some basic connection management and expose the McpServerRequestHandler which speaks JSON-RPC to the MCP server. This is wrapped into the IMcpServer which is the complete, stateful representation of the server. It does stuff like caching discovered tools such that they can be viewed and controlled even when the MCP server isn't running. The IMcpService is the 'public' entry for other VS Code features. Its API is very simple right now, exposing an observable of the available servers, which should be easy for chat to consume. Still need to get some good tests going, add proper and more discovery, and excercise currently-untested API. SSE is also a stub right now. --- .../api/browser/extensionHost.contribution.ts | 1 + src/vs/workbench/api/browser/mainThreadMcp.ts | 104 ++ .../workbench/api/common/extHost.api.impl.ts | 2 + .../api/common/extHost.common.services.ts | 2 + .../workbench/api/common/extHost.protocol.ts | 17 +- src/vs/workbench/api/common/extHostMcp.ts | 37 + .../api/node/extHost.node.services.ts | 3 + src/vs/workbench/api/node/extHostMpcNode.ts | 99 ++ .../contrib/mcp/browser/mcp.contribution.ts | 19 + .../contrib/mcp/browser/mcpCommands.ts | 147 +++ .../contrib/mcp/browser/mcpDiscovery.ts | 79 ++ .../contrib/mcp/common/mcpRegistry.ts | 124 ++ .../contrib/mcp/common/mcpRegistryTypes.ts | 44 + .../workbench/contrib/mcp/common/mcpServer.ts | 226 ++++ .../contrib/mcp/common/mcpServerConnection.ts | 124 ++ .../mcp/common/mcpServerRequestHandler.ts | 444 +++++++ .../contrib/mcp/common/mcpService.ts | 78 ++ .../workbench/contrib/mcp/common/mcpTypes.ts | 219 ++++ .../mcp/common/modelContextProtocol.ts | 1138 +++++++++++++++++ .../baseConfigurationResolverService.ts | 3 +- src/vs/workbench/workbench.common.main.ts | 1 + 21 files changed, 2909 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/api/browser/mainThreadMcp.ts create mode 100644 src/vs/workbench/api/common/extHostMcp.ts create mode 100644 src/vs/workbench/api/node/extHostMpcNode.ts create mode 100644 src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts create mode 100644 src/vs/workbench/contrib/mcp/browser/mcpCommands.ts create mode 100644 src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpRegistry.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpServer.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpService.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpTypes.ts create mode 100644 src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 3e73acf1abd..f0bf62c67ff 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -88,6 +88,7 @@ import './mainThreadShare.js'; import './mainThreadProfileContentHandlers.js'; import './mainThreadAiRelatedInformation.js'; import './mainThreadAiEmbeddingVector.js'; +import './mainThreadMcp.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts new file mode 100644 index 00000000000..fd6b783011f --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { observableValue } from '../../../base/common/observable.js'; +import { IMcpRegistry, IMcpMessageTransport } from '../../contrib/mcp/common/mcpRegistryTypes.js'; +import { McpConnectionState, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; +import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadMcp) +export class MainThreadMcp extends Disposable implements MainThreadMcpShape { + + private _serverIdCounter = 0; + + private readonly _servers: Map = new Map(); + + constructor( + extHostContext: IExtHostContext, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + ) { + super(); + const proxy = extHostContext.getProxy(ExtHostContext.ExtHostMcp); + this._register(this._mcpRegistry.registerDelegate({ + canStart(collection, serverDefinition) { + // todo: SSE MPC servers without a remote authority could be served from the renderer + if (collection.remoteAuthority !== extHostContext.remoteAuthority) { + return false; + } + if (serverDefinition.launch.type === McpServerTransportType.Stdio && extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker) { + return false; + } + return true; + }, + start: (collection, _serverDefiniton, resolveLaunch) => { + const id = ++this._serverIdCounter; + const launch = new ExtHostMcpServerLaunch( + () => proxy.$stopMcp(id), + msg => proxy.$sendMessage(id, JSON.stringify(msg)), + ); + + this._servers.set(id, launch); + proxy.$startMcp(id, resolveLaunch); + + return launch; + }, + })); + } + + $onDidChangeState(id: number, update: McpConnectionState): void { + this._servers.get(id)?.state.set(update, undefined); + + if (update.state === McpConnectionState.Kind.Stopped || update.state === McpConnectionState.Kind.Error) { + this._servers.delete(id); + } + } + $onDidPublishLog(id: number, log: string): void { + this._servers.get(id)?.pushLog(log); + } + $onDidReceiveMessage(id: number, message: string): void { + this._servers.get(id)?.pushMessage(message); + } +} + + +class ExtHostMcpServerLaunch extends Disposable implements IMcpMessageTransport { + public readonly state = observableValue('mcpServerState', { state: McpConnectionState.Kind.Starting }); + + private readonly _onDidLog = this._register(new Emitter()); + public readonly onDidLog = this._onDidLog.event; + + private readonly _onDidReceiveMessage = this._register(new Emitter()); + public readonly onDidReceiveMessage = this._onDidReceiveMessage.event; + + pushLog(log: string): void { + this._onDidLog.fire(log); + } + + pushMessage(message: string): void { + let parsed: MCP.JSONRPCMessage | undefined; + try { + parsed = JSON.parse(message); + } catch (e) { + this.pushLog(`Failed to parse message: ${JSON.stringify(message)}`); + } + + if (parsed) { + this._onDidReceiveMessage.fire(parsed); + } + } + + constructor( + public readonly stop: () => void, + public readonly send: (message: MCP.JSONRPCMessage) => void, + ) { + super(); + } + +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 898d747ddfb..c35994fc4ed 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -108,6 +108,7 @@ import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifie import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; import type * as vscode from 'vscode'; import { ExtHostCodeMapper } from './extHostCodeMapper.js'; +import { IExtHostMpcService } from './extHostMcp.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -218,6 +219,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); + rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index c81dd9fab4b..5551bfa7452 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -31,6 +31,7 @@ import { ExtHostAuthentication, IExtHostAuthentication } from './extHostAuthenti import { ExtHostLanguageModels, IExtHostLanguageModels } from './extHostLanguageModels.js'; import { IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration } from './extHostTerminalShellIntegration.js'; import { ExtHostTesting, IExtHostTesting } from './extHostTesting.js'; +import { ExtHostMcpService, IExtHostMpcService } from './extHostMcp.js'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); @@ -58,3 +59,4 @@ registerSingleton(IExtHostWorkspace, ExtHostWorkspace, InstantiationType.Eager); registerSingleton(IExtHostSecretState, ExtHostSecretState, InstantiationType.Eager); registerSingleton(IExtHostEditorTabs, ExtHostEditorTabs, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, ExtHostVariableResolverProviderService, InstantiationType.Eager); +registerSingleton(IExtHostMpcService, ExtHostMcpService, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2fb436bd7f9..b4b30908019 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -60,6 +60,7 @@ import { IChatRequestVariableValue } from '../../contrib/chat/common/chatVariabl import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsChangeEvent } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IToolData, IToolInvocation, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; +import { McpServerLaunch, McpConnectionState } from '../../contrib/mcp/common/mcpTypes.js'; import * as notebookCommon from '../../contrib/notebook/common/notebookCommon.js'; import { CellExecutionUpdateType } from '../../contrib/notebook/common/notebookExecutionService.js'; import { ICellExecutionComplete, ICellExecutionStateUpdate } from '../../contrib/notebook/common/notebookExecutionStateService.js'; @@ -2967,6 +2968,18 @@ export interface ExtHostTestingShape { $disposeTestFollowups(id: number[]): void; } +export interface ExtHostMcpShape { + $startMcp(id: number, launch: McpServerLaunch): void; + $stopMcp(id: number): void; + $sendMessage(id: number, message: string): void; +} + +export interface MainThreadMcpShape { + $onDidChangeState(id: number, state: McpConnectionState): void; + $onDidPublishLog(id: number, log: string): void; + $onDidReceiveMessage(id: number, message: string): void; +} + export interface ExtHostLocalizationShape { getMessage(extensionId: string, details: IStringDetails): string; getBundle(extensionId: string): { [key: string]: string } | undefined; @@ -3113,6 +3126,7 @@ export const MainContext = { MainThreadTimeline: createProxyIdentifier('MainThreadTimeline'), MainThreadTesting: createProxyIdentifier('MainThreadTesting'), MainThreadLocalization: createProxyIdentifier('MainThreadLocalizationShape'), + MainThreadMcp: createProxyIdentifier('MainThreadMcpShape'), MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector') }; @@ -3184,5 +3198,6 @@ export const ExtHostContext = { ExtHostTimeline: createProxyIdentifier('ExtHostTimeline'), ExtHostTesting: createProxyIdentifier('ExtHostTesting'), ExtHostTelemetry: createProxyIdentifier('ExtHostTelemetry'), - ExtHostLocalization: createProxyIdentifier('ExtHostLocalization') + ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), + ExtHostMcp: createProxyIdentifier('ExtHostMcp'), }; diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts new file mode 100644 index 00000000000..da65186caea --- /dev/null +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { McpServerLaunch } from '../../contrib/mcp/common/mcpTypes.js'; +import { ExtHostMcpShape, MainContext, MainThreadMcpShape } from './extHost.protocol.js'; +import { IExtHostRpcService } from './extHostRpcService.js'; + + +export const IExtHostMpcService = createDecorator('IExtHostMpcService'); + +export interface IExtHostMpcService extends ExtHostMcpShape { } + +export class ExtHostMcpService implements IExtHostMpcService { + protected _proxy: MainThreadMcpShape; + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + ) { + this._proxy = extHostRpc.getProxy(MainContext.MainThreadMcp); + } + + $startMcp(id: number, launch: McpServerLaunch): void { + // todo: SSE launches can be implemented in this common layer + throw new Error('not implemented'); + } + + $stopMcp(id: number): void { + // no-op + } + + $sendMessage(id: number, message: string): void { + // no-op + } +} diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index ade6d90abb4..331d9a7b180 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -27,6 +27,8 @@ import { SyncDescriptor } from '../../../platform/instantiation/common/descripto import { ISignService } from '../../../platform/sign/common/sign.js'; import { SignService } from '../../../platform/sign/node/signService.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; +import { IExtHostMpcService } from '../common/extHostMcp.js'; +import { NodeExtHostMpcService } from './extHostMpcNode.js'; // ######################################################################### // ### ### @@ -47,3 +49,4 @@ registerSingleton(IExtHostTask, ExtHostTask, InstantiationType.Eager); registerSingleton(IExtHostTerminalService, ExtHostTerminalService, InstantiationType.Eager); registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); +registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostMpcNode.ts b/src/vs/workbench/api/node/extHostMpcNode.ts new file mode 100644 index 00000000000..1277212df65 --- /dev/null +++ b/src/vs/workbench/api/node/extHostMpcNode.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import { mapValues } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { StreamSplitter } from '../../../base/node/nodeStreams.js'; +import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { ExtHostMcpService } from '../common/extHostMcp.js'; +import { IExtHostRpcService } from '../common/extHostRpcService.js'; + +export class NodeExtHostMpcService extends ExtHostMcpService { + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + ) { + super(extHostRpc); + } + + private nodeServers = new Map(); + + override $startMcp(id: number, launch: McpServerLaunch): void { + if (launch.type === McpServerTransportType.Stdio) { + this.startNodeMpc(id, launch); + } else { + super.$startMcp(id, launch); + } + } + + override $stopMcp(id: number): void { + const nodeServer = this.nodeServers.get(id); + if (nodeServer) { + nodeServer.abortCtrl.abort(); + this.nodeServers.delete(id); + } else { + super.$stopMcp(id); + } + } + + override $sendMessage(id: number, message: string): void { + const nodeServer = this.nodeServers.get(id); + if (nodeServer) { + nodeServer.child.stdin.write(message + '\n'); + } else { + super.$sendMessage(id, message); + } + } + + private startNodeMpc(id: number, launch: McpServerTransportStdio): void { + const onError = (err: Error) => this._proxy.$onDidChangeState(id, { + state: McpConnectionState.Kind.Error, + message: err.message, + }); + + const abortCtrl = new AbortController(); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(launch.command, launch.args, { + stdio: 'pipe', + cwd: URI.revive(launch.cwd).fsPath, + signal: abortCtrl.signal, + env: { + ...process.env, + ...mapValues(launch.env, v => typeof v === 'number' ? String(v) : (v === null ? undefined : v)), + }, + }); + } catch (e) { + onError(e); + abortCtrl.abort(); + return; + } + + this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting }); + + child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); + + // Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177 + // Just treat it as generic log data for now + child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, line.toString())); + + child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running })); + + child.on('error', onError); + child.on('exit', code => + code === 0 + ? this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped }) + : this._proxy.$onDidChangeState(id, { + state: McpConnectionState.Kind.Error, + message: `Process exited with code ${code}`, + }) + ); + + this.nodeServers.set(id, { abortCtrl, child }); + } +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts new file mode 100644 index 00000000000..0e31680a731 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { McpRegistry } from '../common/mcpRegistry.js'; +import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; +import { McpService } from '../common/mcpService.js'; +import { IMcpService } from '../common/mcpTypes.js'; + +import './mcpCommands.js'; +import { McpDiscovery } from './mcpDiscovery.js'; + +registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); +registerSingleton(IMcpService, McpService, InstantiationType.Delayed); + +registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts new file mode 100644 index 00000000000..af168e5d07a --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ILocalizedString, localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IMcpService, McpConnectionState } from '../common/mcpTypes.js'; + +// acroynms do not get localized +const category: ILocalizedString = { + original: 'MCP', + value: 'MCP', +}; + +class ListMcpServerCommand extends Action2 { + public static readonly id = 'workbench.mcp.listServer'; + constructor() { + super({ + id: ListMcpServerCommand.id, + title: localize2('mcp.list', 'List Servers'), + category, + f1: true, + }); + } + + override async run(accessor: ServicesAccessor) { + const mcpService = accessor.get(IMcpService); + const commandService = accessor.get(ICommandService); + const quickInput = accessor.get(IQuickInputService); + + type ItemType = { id: string } & IQuickPickItem; + + const store = new DisposableStore(); + const pick = quickInput.createQuickPick(); + + store.add(pick); + store.add(autorun(reader => { + const servers = mcpService.servers.read(reader); + pick.items = servers.map(server => ({ + id: server.definition.id, + label: server.definition.label, + description: McpConnectionState.toString(server.state.read(reader)), + })); + })); + + + const picked = await new Promise(resolve => { + pick.onDidAccept(() => { + resolve(pick.activeItems[0]); + }); + pick.onDidHide(() => { + resolve(undefined); + }); + pick.show(); + }); + + store.dispose(); + + if (picked) { + commandService.executeCommand(McpServerOptionsCommand.id, picked.id); + } + } +} +registerAction2(ListMcpServerCommand); + + +class McpServerOptionsCommand extends Action2 { + public static readonly id = 'workbench.mcp.serverOptions'; + + constructor() { + super({ + id: McpServerOptionsCommand.id, + title: localize2('mcp.options', 'Server Options'), + category, + f1: true, + }); + } + + override async run(accessor: ServicesAccessor, id: string): Promise { + const mcpService = accessor.get(IMcpService); + const quickInputService = accessor.get(IQuickInputService); + const server = mcpService.servers.get().find(s => s.definition.id === id); + if (!server) { + return; + } + + interface ActionItem extends IQuickPickItem { + action: 'start' | 'stop' | 'restart' | 'showOutput'; + } + + const items: ActionItem[] = []; + const serverState = server.state.get(); + + // Only show start when server is stopped or in error state + if (McpConnectionState.canBeStarted(serverState.state)) { + items.push({ + label: localize2('mcp.start', 'Start Server').value, + action: 'start' + }); + } else { + items.push({ + label: localize2('mcp.stop', 'Stop Server').value, + action: 'stop' + }); + items.push({ + label: localize2('mcp.restart', 'Restart Server').value, + action: 'restart' + }); + } + + items.push({ + label: localize2('mcp.showOutput', 'Show Output').value, + action: 'showOutput' + }); + + const pick = await quickInputService.pick(items, { + placeHolder: localize('mcp.selectAction', 'Select Server Action') + }); + + if (!pick) { + return; + } + + switch (pick.action) { + case 'start': + await server.start(); + break; + case 'stop': + await server.stop(); + break; + case 'restart': + await server.stop(); + await server.start(); + break; + case 'showOutput': + server.showOutput(); + break; + } + } +} +registerAction2(McpServerOptionsCommand); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts new file mode 100644 index 00000000000..87b575e5a1a --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { observableValue } from '../../../../base/common/observable.js'; +import { Platform, platform } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; +import { McpServerDefinition, McpServerTransportType } from '../common/mcpTypes.js'; + +export class McpDiscovery implements IWorkbenchContribution { + public static readonly ID = 'workbench.contrib.mcp.discovery'; + + constructor( + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IFileService fileService: IFileService, + @IMcpRegistry mcpRegistry: IMcpRegistry + ) { + + const homeDir = environmentService.userHome; + //#region hacked in reading for claude desktop config + let configPath: URI; + if (platform === Platform.Windows) { + const appData = /* process.env.APPDATA ||*/ URI.joinPath(environmentService.userHome, 'AppData', 'Roaming'); + configPath = URI.joinPath(appData, 'Claude', 'claude_desktop_config.json'); + } else if (platform === Platform.Mac) { + configPath = URI.joinPath(environmentService.userHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } else { + const configDir = /*process.env.XDG_CONFIG_HOME || */URI.joinPath(environmentService.userHome, '.config'); + configPath = URI.joinPath(configDir, 'Claude', 'claude_desktop_config.json'); + } + + + fileService.readFile(configPath).then((content) => { + let parsed: { + mcpServers: Record; + }>; + }; + + try { + parsed = JSON.parse(content.value.toString()); + } catch { + return; + } + const definitions = Object.entries(parsed.mcpServers).map(([name, server]): McpServerDefinition => { + return { + id: `claude_desktop_config.${name}`, + label: name, + launch: { + type: McpServerTransportType.Stdio, + args: server.args || [], + command: server.command, + env: server.env || {}, + cwd: homeDir, + } + }; + }); + + mcpRegistry.registerCollection({ + id: 'claude_desktop_config', + label: 'Claude Desktop', + isTrustedByDefault: false, + remoteAuthority: null, + scope: StorageScope.APPLICATION, + serverDefinitions: observableValue(this, definitions), + }); + }, () => { }); + + //#endregion + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts new file mode 100644 index 00000000000..417599b5d87 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { isEmptyObject } from '../../../../base/common/types.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; +import { IMcpHostDelegate, IMcpRegistry } from './mcpRegistryTypes.js'; +import { McpServerConnection } from './mcpServerConnection.js'; +import { McpCollectionDefinition, IMcpServerConnection, McpServerDefinition } from './mcpTypes.js'; + +export class McpRegistry extends Disposable implements IMcpRegistry { + declare public readonly _serviceBrand: undefined; + + private readonly _collections = observableValue('collections', []); + private readonly _delegates: IMcpHostDelegate[] = []; + + public readonly collections: IObservable = this._collections; + + public get delegates(): readonly IMcpHostDelegate[] { + return this._delegates; + } + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + } + + public registerDelegate(delegate: IMcpHostDelegate): IDisposable { + this._delegates.push(delegate); + return { + dispose: () => { + const index = this._delegates.indexOf(delegate); + if (index !== -1) { + this._delegates.splice(index, 1); + } + } + }; + } + + public registerCollection(collection: McpCollectionDefinition): IDisposable { + const currentCollections = this._collections.get(); + this._collections.set([...currentCollections, collection], undefined); + + return { + dispose: () => { + const currentCollections = this._collections.get(); + this._collections.set(currentCollections.filter(c => c !== collection), undefined); + } + }; + } + + public hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean { + const stored = this.getInputStorageData(collection, definition); + return !!stored && !isEmptyObject(stored.map); + } + + public clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition) { + const stored = this.getInputStorageData(collection, definition); + if (stored) { + this._storageService.remove(stored.key, stored.scope); + } + } + + public async resolveConnection( + collection: McpCollectionDefinition, + definition: McpServerDefinition + ): Promise { + const delegate = this._delegates.find(d => d.canStart(collection, definition)); + if (!delegate) { + throw new Error('No delegate found that can handle the connection'); + } + + let launch = definition.launch; + + const storage = this.getInputStorageData(collection, definition); + if (definition.variableReplacement && storage) { + const { folder, section, target } = definition.variableReplacement; + // based on _configurationResolverService.resolveWithInteractionReplace + launch = await this._configurationResolverService.resolveAnyAsync(folder, section); + + const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, storage.map, target); + + if (newVariables?.size) { + launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, Object.fromEntries(newVariables)); + this._storageService.store(storage.key, JSON.stringify(Object.fromEntries(newVariables)), storage.scope, StorageTarget.MACHINE); + } + } + + return this._instantiationService.createInstance( + McpServerConnection, + collection, + definition, + delegate, + launch, + ); + } + + private getInputStorageData(collection: McpCollectionDefinition, definition: McpServerDefinition) { + if (!definition.variableReplacement) { + return undefined; + } + + const key = `mcpConfig.${collection.id}.${definition.id}`; + const scope = definition.variableReplacement.folder ? StorageScope.WORKSPACE : StorageScope.APPLICATION; + + let map: Record | undefined; + try { + map = this._storageService.getObject(key, scope); + } catch { + // ignord + } + + return { key, scope, map }; + } +} + diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts new file mode 100644 index 00000000000..9659bc986da --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpConnectionState, IMcpServerConnection } from './mcpTypes.js'; +import { MCP } from './modelContextProtocol.js'; + +export const IMcpRegistry = createDecorator('mcpRegistry'); + +/** Message transport to a single MCP server. */ +export interface IMcpMessageTransport extends IDisposable { + readonly state: IObservable; + readonly onDidLog: Event; + readonly onDidReceiveMessage: Event; + send(message: MCP.JSONRPCMessage): void; + stop(): void; +} + +export interface IMcpHostDelegate { + canStart(collectionDefinition: McpCollectionDefinition, serverDefinition: McpServerDefinition): boolean; + start(collectionDefinition: McpCollectionDefinition, serverDefinition: McpServerDefinition, resolvedLaunch: McpServerLaunch): IMcpMessageTransport; +} + +export interface IMcpRegistry { + readonly _serviceBrand: undefined; + + readonly collections: IObservable; + readonly delegates: readonly IMcpHostDelegate[]; + + registerDelegate(delegate: IMcpHostDelegate): IDisposable; + registerCollection(collection: McpCollectionDefinition): IDisposable; + + /** Gets whether there are saved inputs used to resolve the connection */ + hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean; + /** Resets any saved inputs for the connection. */ + clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): void; + /** Createse a connection for the collection and definition. */ + resolveConnection(collection: McpCollectionDefinition, definition: McpServerDefinition): Promise; +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts new file mode 100644 index 00000000000..8d8c2c294b0 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { LRUCache } from '../../../../base/common/map.js'; +import { autorun, autorunWithStore, derived, disposableObservableValue, IObservable, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IMcpRegistry } from './mcpRegistryTypes.js'; +import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; +import { McpCollectionDefinition, IMcpServer, IMcpServerConnection, McpServerDefinition, IMcpTool, McpConnectionFailedError, McpConnectionState } from './mcpTypes.js'; +import { MCP } from './modelContextProtocol.js'; + + +interface IMetadataCacheEntry { + /** Cached tools so we can show what's available before it's started */ + readonly tools: readonly MCP.Tool[]; +} + +export class McpServerMetadataCache extends Disposable { + private didChange = false; + private readonly cache = new LRUCache(128); + + constructor( + scope: StorageScope, + @IStorageService storageService: IStorageService, + ) { + super(); + + type StoredType = [string, IMetadataCacheEntry][]; + + const storageKey = 'mcpToolCache'; + this._register(storageService.onWillSaveState(() => { + if (this.didChange) { + storageService.store(storageKey, this.cache.toJSON() satisfies StoredType, scope, StorageTarget.MACHINE); + this.didChange = false; + } + })); + + try { + const cached: StoredType | undefined = storageService.getObject(storageKey, scope); + cached?.forEach(([k, v]) => this.cache.set(k, v)); + } catch { + // ignored + } + } + + getTools(definitionId: string): readonly MCP.Tool[] | undefined { + return this.cache.get(definitionId)?.tools; + } + + storeTools(definitionId: string, tools: readonly MCP.Tool[]): void { + this.cache.set(definitionId, { ...this.cache.get(definitionId), tools }); + this.didChange = true; + } +} + +export class McpServer extends Disposable implements IMcpServer { + private readonly _connectionSequencer = new Sequencer(); + private readonly _connection = this._register(disposableObservableValue(this, undefined)); + + public readonly state: IObservable = derived(reader => this._connection.read(reader)?.state.read(reader) ?? { state: McpConnectionState.Kind.Stopped }); + + private get toolsFromCache() { + return this._toolCache.getTools(this.definition.id); + } + private readonly toolsFromServerPromise = observableValue | undefined>(this, undefined); + private readonly toolsFromServer = derived(reader => this.toolsFromServerPromise.read(reader)?.promiseResult.read(reader)?.data); + + public readonly tools = derived(reader => { + const serverTools = this.toolsFromServer.read(reader); + const definitions = serverTools ?? this.toolsFromCache ?? []; + return definitions.map(def => new McpTool(this, def)); + }); + + constructor( + public readonly collection: McpCollectionDefinition, + public readonly definition: McpServerDefinition, + private readonly _toolCache: McpServerMetadataCache, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IWorkspaceContextService workspacesService: IWorkspaceContextService, + ) { + super(); + + // 1. Reflect workspaces into the MCP roots + const workspaces = observableFromEvent( + this, + workspacesService.onDidChangeWorkspaceFolders, + () => workspacesService.getWorkspace().folders, + ); + + this._register(autorunWithStore(reader => { + const cnx = this._connection.read(reader)?.handler.read(reader); + if (!cnx) { + return; + } + + cnx.roots = workspaces.read(reader).map(wf => ({ + uri: wf.uri.toString(), + name: wf.name, + })); + })); + + // 2. Populate this.tools when we connect to a server. + this._register(autorunWithStore((reader, store) => { + const cnx = this._connection.read(reader)?.handler.read(reader); + if (cnx) { + this.populateLiveData(cnx, store); + } else { + this.resetLiveData(); + } + })); + + // 3. Update the cache when tools update + this._register(autorun(reader => { + const tools = this.toolsFromServer.read(reader); + if (tools) { + this._toolCache.storeTools(definition.id, tools); + } + })); + } + + public showOutput(): void { + this._connection.get()?.showOutput(); + } + + public start(): Promise { + return this._connectionSequencer.queue(async () => { + let connection = this._connection.get(); + if (!connection) { + connection = await this._mcpRegistry.resolveConnection(this.collection, this.definition); + if (this._store.isDisposed) { + connection.dispose(); + return { state: McpConnectionState.Kind.Stopped }; + } + + this._connection.set(connection, undefined); + } + + return connection.start(); + }); + } + + public stop(): Promise { + return this._connection.get()?.stop() || Promise.resolve(); + } + + private resetLiveData() { + transaction(tx => { + this.toolsFromServerPromise.set(undefined, tx); + }); + } + + private populateLiveData(handler: McpServerRequestHandler, store: DisposableStore) { + const cts = new CancellationTokenSource(); + store.add(toDisposable(() => cts.dispose(true))); + + // todo: add more than just tools here + + const updateTools = (tx: ITransaction | undefined) => { + this.toolsFromServerPromise.set(new ObservablePromise(handler.listTools({}, cts.token)), tx); + }; + + store.add(handler.onDidChangeToolList(() => updateTools(undefined))); + + transaction(tx => { + updateTools(tx); + }); + } + + /** + * Helper function to call the function on the handler once it's online. The + * connection started if it is not already. + */ + public callOn(fn: (handler: McpServerRequestHandler) => Promise, token?: CancellationToken): Promise { + const store = new DisposableStore(); + this.start(); // idempotent + + try { + return new Promise((resolve, reject) => { + if (token) { + store.add(token.onCancellationRequested(() => { + reject(new CancellationError()); + })); + } + store.add(autorun(reader => { + const connection = this._connection.read(reader); + if (!connection) { + return; + } + + const handler = connection.handler.read(reader); + if (handler) { + resolve(fn(handler)); + store.dispose(); // aggressive dispose to prevent multiple racey calls + } else { + const state = connection.state.read(reader); + if (state.state === McpConnectionState.Kind.Error) { + reject(new McpConnectionFailedError(`MCP server could not be started: ${state.message}`)); + } else { + reject(new McpConnectionFailedError('MCP server has stopped')); + } + } + })); + }); + } finally { + store.dispose(); + } + } +} + +export class McpTool implements IMcpTool { + constructor( + private readonly _server: McpServer, + public readonly definition: MCP.Tool, + ) { } + + call(params: Record, token?: CancellationToken): Promise { + return this._server.callOn(h => h.callTool({ name: this.definition.name, arguments: params }), token); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts new file mode 100644 index 00000000000..ee65077e9da --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; +import { IOutputService } from '../../../services/output/common/output.js'; +import { IMcpHostDelegate, IMcpMessageTransport } from './mcpRegistryTypes.js'; +import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; +import { McpCollectionDefinition, IMcpServerConnection, McpServerDefinition, McpConnectionState, McpServerLaunch } from './mcpTypes.js'; + +export class McpServerConnection extends Disposable implements IMcpServerConnection { + private readonly _launch = this._register(new MutableDisposable>()); + private readonly _state = observableValue('mcpServerState', { state: McpConnectionState.Kind.Stopped }); + private readonly _requestHandler = observableValue('mcpServerRequestHandler', undefined); + + public readonly state: IObservable = this._state; + public readonly handler: IObservable = this._requestHandler; + + private readonly _loggerId: string; + private readonly _logger: ILogger; + + constructor( + private readonly _collection: McpCollectionDefinition, + public readonly definition: McpServerDefinition, + private readonly _delegate: IMcpHostDelegate, + private readonly _launchDefinition: McpServerLaunch, + @ILoggerService private readonly _loggerService: ILoggerService, + @IOutputService private readonly _outputService: IOutputService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._loggerId = `mcpServer/${definition.id}`; + this._logger = this._register(_loggerService.createLogger(this._loggerId, { hidden: true, name: `MCP: ${definition.label}` })); + } + + /** @inheritdoc */ + public showOutput(): void { + this._loggerService.setVisibility(this._loggerId, true); + this._outputService.showChannel(this._loggerId); + } + + /** @inheritdoc */ + public async start(): Promise { + const currentState = this._state.get(); + if (!McpConnectionState.canBeStarted(currentState.state)) { + return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); + } + + this._launch.value = undefined; + this._state.set({ state: McpConnectionState.Kind.Starting }, undefined); + this._logger.info(localize('mcpServer.starting', 'Starting server {0}', this.definition.label)); + + try { + const launch = this._delegate.start(this._collection, this.definition, this._launchDefinition); + this._launch.value = this.adoptLaunch(launch); + return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); + } catch (e) { + const errorState: McpConnectionState = { + state: McpConnectionState.Kind.Error, + message: e instanceof Error ? e.message : String(e) + }; + this._state.set(errorState, undefined); + return errorState; + } + } + + private adoptLaunch(launch: IMcpMessageTransport): IReference { + const store = new DisposableStore(); + const cts = new CancellationTokenSource(); + + store.add(toDisposable(() => cts.dispose(true))); + store.add(launch); + store.add(launch.onDidLog(msg => { + this._logger.info(msg); + })); + store.add(autorun(reader => { + const state = launch.state.read(reader); + this._state.set(state, undefined); + this._logger.info(localize('mcpServer.state', 'Connection state: {0}', McpConnectionState.toString(state))); + + if (state.state === McpConnectionState.Kind.Running) { + McpServerRequestHandler.create(this._instantiationService, launch, this._logger, cts.token).then( + handler => this._requestHandler.set(handler, undefined), + err => { + store.dispose(); + this._logger.error(err); + this._state.set({ state: McpConnectionState.Kind.Error, message: `Could not initialize MCP server: ${err.message}` }, undefined); + }, + ); + } + })); + + return { dispose: () => store.dispose(), object: launch }; + } + + public async stop(): Promise { + this._logger.info(localize('mcpServer.stopping', 'Stopping server {0}', this.definition.label)); + this._launch.value?.object.stop(); + await this._waitForState(McpConnectionState.Kind.Stopped, McpConnectionState.Kind.Error); + } + + public override dispose(): void { + super.dispose(); + this._state.set({ state: McpConnectionState.Kind.Stopped }, undefined); + } + + private _waitForState(...kinds: McpConnectionState.Kind[]): Promise { + return new Promise(resolve => { + const disposable = autorun(reader => { + const state = this._state.read(reader); + if (kinds.includes(state.state)) { + disposable.dispose(); + resolve(state); + } + }); + }); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts new file mode 100644 index 00000000000..572c4ef2e15 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -0,0 +1,444 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { equals } from '../../../../base/common/arrays.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogger } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IMcpMessageTransport } from './mcpRegistryTypes.js'; +import { MpcResponseError } from './mcpTypes.js'; +import { MCP } from './modelContextProtocol.js'; + +/** + * Maps request IDs to handlers. + */ +interface PendingRequest { + promise: DeferredPromise; +} + +export interface McpRoot { + uri: string; + name?: string; +} + +/** + * Request handler for communicating with an MCP server. + * + * Handles sending requests and receiving responses, with automatic + * handling of ping requests and typed client request methods. + */ +export class McpServerRequestHandler extends Disposable { + private _nextRequestId = 1; + private readonly _pendingRequests = new Map(); + + private _hasAnnouncedRoots = false; + private _roots: MCP.Root[] = []; + + public set roots(roots: MCP.Root[]) { + if (!equals(this._roots, roots)) { + this._roots = roots; + if (this._hasAnnouncedRoots) { + this.sendNotification({ method: 'notifications/roots/list_changed' }); + this._hasAnnouncedRoots = false; + } + } + } + + private _serverInit!: MCP.InitializeResult; + public get capabilities(): MCP.ServerCapabilities { + return this._serverInit.capabilities; + } + + // Event emitters for server notifications + private readonly _onDidReceiveCancelledNotification = this._register(new Emitter()); + readonly onDidReceiveCancelledNotification = this._onDidReceiveCancelledNotification.event; + + private readonly _onDidReceiveProgressNotification = this._register(new Emitter()); + readonly onDidReceiveProgressNotification = this._onDidReceiveProgressNotification.event; + + private readonly _onDidChangeResourceList = this._register(new Emitter()); + readonly onDidChangeResourceList = this._onDidChangeResourceList.event; + + private readonly _onDidUpdateResource = this._register(new Emitter()); + readonly onDidUpdateResource = this._onDidUpdateResource.event; + + private readonly _onDidChangeToolList = this._register(new Emitter()); + readonly onDidChangeToolList = this._onDidChangeToolList.event; + + private readonly _onDidChangePromptList = this._register(new Emitter()); + readonly onDidChangePromptList = this._onDidChangePromptList.event; + + /** + * Connects to the MCP server and does the initialization handshake. + * @throws MpcResponseError if the server fails to initialize. + */ + public static async create(instaService: IInstantiationService, launch: IMcpMessageTransport, logger: ILogger, token?: CancellationToken) { + const mcp = new McpServerRequestHandler(launch, logger); + try { + await instaService.invokeFunction(async accessor => { + const productService = accessor.get(IProductService); + const initialized = await mcp.sendRequest({ + method: 'initialize', + params: { + protocolVersion: MCP.LATEST_PROTOCOL_VERSION, + capabilities: { + roots: { listChanged: true }, + }, + clientInfo: { + name: productService.nameLong, + version: productService.version, + } + } + }, token); + + mcp._serverInit = initialized; + }); + + return mcp; + } catch (e) { + mcp.dispose(); + throw e; + } + } + + protected constructor( + private readonly launch: IMcpMessageTransport, + private readonly logger: ILogger, + ) { + super(); + this._register(launch.onDidReceiveMessage(message => this.handleMessage(message))); + } + + /** + * Send a client request to the server and return the response. + * + * @param request The request to send + * @param token Cancellation token + * @param timeoutMs Optional timeout in milliseconds + * @returns A promise that resolves with the response + */ + private async sendRequest( + request: Pick, + token: CancellationToken = CancellationToken.None + ): Promise { + const id = this._nextRequestId++; + + // Create the full JSON-RPC request + const jsonRpcRequest: MCP.JSONRPCRequest = { + jsonrpc: MCP.JSONRPC_VERSION, + id, + ...request + }; + + const promise = new DeferredPromise(); + // Store the pending request + this._pendingRequests.set(id, { promise }); + + // Set up cancellation + const cancelListener = token.onCancellationRequested(() => { + if (!promise.isSettled) { + this._pendingRequests.delete(id); + this.sendNotification({ method: 'notifications/cancelled', params: { requestId: id } }); + promise.cancel(); + } + }); + + // Send the request + this.launch.send(jsonRpcRequest); + promise.p.finally(() => { + cancelListener.dispose(); + this._pendingRequests.delete(id); + }); + + return promise.p as Promise; + } + + /** + * Handles paginated requests by making multiple requests until all items are retrieved. + * + * @param method The method name to call + * @param getItems Function to extract the array of items from a result + * @param initialParams Initial parameters + * @param token Cancellation token + * @returns Promise with all items combined + */ + private async sendRequestPaginated(method: T['method'], getItems: (result: R) => I[], initialParams?: Omit, token: CancellationToken = CancellationToken.None): Promise { + let allItems: I[] = []; + let nextCursor: MCP.Cursor | undefined = undefined; + + do { + const params: T['params'] = { + ...initialParams, + cursor: nextCursor + }; + + const result: R = await this.sendRequest({ method, params }, token); + allItems = allItems.concat(getItems(result)); + nextCursor = result.nextCursor; + } while (nextCursor !== undefined && !token.isCancellationRequested); + + return allItems; + } + + private sendNotification(notification: N): void { + this.launch.send({ ...notification, jsonrpc: MCP.JSONRPC_VERSION }); + } + + /** + * Handle incoming messages from the server + */ + private handleMessage(message: MCP.JSONRPCMessage): void { + // Handle responses to our requests + if ('id' in message) { + if ('result' in message) { + this.handleResult(message); + } else if ('error' in message) { + this.handleError(message); + } + } + + // Handle requests from the server + if ('method' in message) { + if ('id' in message) { + this.handleServerRequest(message as MCP.JSONRPCRequest & MCP.ServerRequest); + } else { + this.handleServerNotification(message as MCP.JSONRPCNotification & MCP.ServerNotification); + + } + } + } + + /** + * Handle successful responses + */ + private handleResult(response: MCP.JSONRPCResponse): void { + const request = this._pendingRequests.get(response.id); + if (request) { + this._pendingRequests.delete(response.id); + request.promise.complete(response.result); + } + } + + /** + * Handle error responses + */ + private handleError(response: MCP.JSONRPCError): void { + const request = this._pendingRequests.get(response.id); + if (request) { + this._pendingRequests.delete(response.id); + request.promise.error(new MpcResponseError(response.error.message, response.error.code, response.error.data)); + } + } + + /** + * Handle incoming server requests + */ + private handleServerRequest(request: MCP.JSONRPCRequest & MCP.ServerRequest): void { + switch (request.method) { + case 'ping': + return this.respondToRequest(request, this.handlePing(request)); + case 'roots/list': + return this.respondToRequest(request, this.handleRootsList(request)); + + default: { + const errorResponse: MCP.JSONRPCError = { + jsonrpc: MCP.JSONRPC_VERSION, + id: request.id, + error: { + code: MCP.METHOD_NOT_FOUND, + message: `Method not found: ${request.method}` + } + }; + this.launch.send(errorResponse); + break; + } + } + } + /** + * Handle incoming server notifications + */ + private handleServerNotification(request: MCP.JSONRPCNotification & MCP.ServerNotification): void { + switch (request.method) { + case 'notifications/message': + return this.handleLoggingNotification(request); + case 'notifications/cancelled': + this._onDidReceiveCancelledNotification.fire(request); + return this.handleCancelledNotification(request); + case 'notifications/progress': + this._onDidReceiveProgressNotification.fire(request); + return; + case 'notifications/resources/list_changed': + this._onDidChangeResourceList.fire(); + return; + case 'notifications/resources/updated': + this._onDidUpdateResource.fire(request); + return; + case 'notifications/tools/list_changed': + this._onDidChangeToolList.fire(); + return; + case 'notifications/prompts/list_changed': + this._onDidChangePromptList.fire(); + return; + } + } + + private handleCancelledNotification(request: MCP.CancelledNotification): void { + const pendingRequest = this._pendingRequests.get(request.params.requestId); + if (pendingRequest) { + this._pendingRequests.delete(request.params.requestId); + pendingRequest.promise.cancel(); + } + } + + private handleLoggingNotification(request: MCP.LoggingMessageNotification): void { + let contents = typeof request.params.data === 'string' ? request.params.data : JSON.stringify(request.params.data); + if (request.params.logger) { + contents = `${request.params.logger}: ${contents}`; + } + + switch (request.params?.level) { + case 'debug': + this.logger.debug(contents); + break; + case 'info': + case 'notice': + this.logger.info(contents); + break; + case 'warning': + this.logger.warn(contents); + break; + case 'error': + case 'critical': + case 'alert': + case 'emergency': + this.logger.error(contents); + break; + default: + this.logger.info(contents); + break; + } + } + + /** + * Send a generic response to a request + */ + private respondToRequest(request: MCP.JSONRPCRequest, result: MCP.Result): void { + const response: MCP.JSONRPCResponse = { + jsonrpc: MCP.JSONRPC_VERSION, + id: request.id, + result + }; + this.launch.send(response); + } + + /** + * Send a response to a ping request + */ + private handlePing(_request: MCP.PingRequest): {} { + return {}; + } + + /** + * Send a response to a roots/list request + */ + private handleRootsList(_request: MCP.ListRootsRequest): MCP.ListRootsResult { + this._hasAnnouncedRoots = true; + return { roots: this._roots }; + } + + public override dispose(): void { + this._pendingRequests.forEach(pending => pending.promise.cancel()); + this._pendingRequests.clear(); + super.dispose(); + } + + /** + * Send an initialize request + */ + initialize(params: MCP.InitializeRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'initialize', params }, token); + } + + /** + * List available resources + */ + listResources(params?: MCP.ListResourcesRequest['params'], token?: CancellationToken): Promise { + return this.sendRequestPaginated('resources/list', result => result.resources, params, token); + } + + /** + * Read a specific resource + */ + readResource(params: MCP.ReadResourceRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'resources/read', params }, token); + } + + /** + * List available resource templates + */ + listResourceTemplates(params?: MCP.ListResourceTemplatesRequest['params'], token?: CancellationToken): Promise { + return this.sendRequestPaginated('resources/templates/list', result => result.resourceTemplates, params, token); + } + + /** + * Subscribe to resource updates + */ + subscribe(params: MCP.SubscribeRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'resources/subscribe', params }, token); + } + + /** + * Unsubscribe from resource updates + */ + unsubscribe(params: MCP.UnsubscribeRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'resources/unsubscribe', params }, token); + } + + /** + * List available prompts + */ + listPrompts(params?: MCP.ListPromptsRequest['params'], token?: CancellationToken): Promise { + return this.sendRequestPaginated('prompts/list', result => result.prompts, params, token); + } + + /** + * Get a specific prompt + */ + getPrompt(params: MCP.GetPromptRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'prompts/get', params }, token); + } + + /** + * List available tools + */ + listTools(params?: MCP.ListToolsRequest['params'], token?: CancellationToken): Promise { + return this.sendRequestPaginated('tools/list', result => result.tools, params, token); + } + + /** + * Call a specific tool + */ + callTool(params: MCP.CallToolRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'tools/call', params }, token); + } + + /** + * Set the logging level + */ + setLevel(params: MCP.SetLevelRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'logging/setLevel', params }, token); + } + + /** + * Find completions for an argument + */ + complete(params: MCP.CompleteRequest['params'], token?: CancellationToken): Promise { + return this.sendRequest({ method: 'completion/complete', params }, token); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts new file mode 100644 index 00000000000..49fd4ee79ab --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IMcpRegistry } from './mcpRegistryTypes.js'; +import { McpServer, McpServerMetadataCache } from './mcpServer.js'; +import { IMcpServer, IMcpService, McpCollectionDefinition, McpServerDefinition } from './mcpTypes.js'; + +export class McpService extends Disposable implements IMcpService { + private readonly _servers = observableValue(this, []); + public readonly servers: IObservable = this._servers; + + private readonly userCache: McpServerMetadataCache; + private readonly workspaceCache: McpServerMetadataCache; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry + ) { + super(); + + this.userCache = instantiationService.createInstance(McpServerMetadataCache, StorageScope.PROFILE); + this.workspaceCache = instantiationService.createInstance(McpServerMetadataCache, StorageScope.WORKSPACE); + + const definitionsObservable = derived(reader => { + const collections = this._mcpRegistry.collections.read(reader); + return collections.flatMap(collectionDefinition => collectionDefinition.serverDefinitions.read(reader).map(serverDefinition => ({ + serverDefinition, + collectionDefinition, + }))); + }); + + const updateThrottle = this._store.add(new RunOnceScheduler(() => { + const definitions = definitionsObservable.get(); + + const nextDefinitions = new Set(definitions); + const currentServers = this._servers.get(); + const nextServers: IMcpServer[] = []; + for (const server of currentServers) { + const match = definitions.find(d => defsEqual(server, d)); + if (match) { + nextDefinitions.delete(match); + nextServers.push(server); + } else { + server.dispose(); + } + } + for (const def of nextDefinitions) { + nextServers.push(instantiationService.createInstance(McpServer, def.collectionDefinition, def.serverDefinition, def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache)); + } + + this._servers.set(nextServers, undefined); + }, 500)); + + // Throttle changes so that if a collection is changed, or a server is + // unregistered/registered, we don't stop servers unnecessarily. + this._register(autorun(reader => { + definitionsObservable.read(reader); + updateThrottle.schedule(500); + })); + } + + public override dispose(): void { + this._servers.get().forEach(server => server.dispose()); + super.dispose(); + } +} + +function defsEqual(server: IMcpServer, def: { serverDefinition: McpServerDefinition; collectionDefinition: McpCollectionDefinition }) { + return McpCollectionDefinition.equals(server.collection, def.collectionDefinition) && + McpServerDefinition.equals(server.definition, def.serverDefinition); +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts new file mode 100644 index 00000000000..2b34e23e5cc --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from '../../../../base/common/assert.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { equals as objectsEqual } from '../../../../base/common/objects.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; +import { MCP } from './modelContextProtocol.js'; + +/** + * An McpCollection contains McpServers. There may be multiple collections for + * different locations servers are discovered. + */ +export interface McpCollectionDefinition { + /** Origin authority from which this collection was discovered. */ + readonly remoteAuthority: string | null; + /** Globally-unique, stable ID for this definition */ + readonly id: string; + /** Human-readable label for the definition */ + readonly label: string; + /** Definitions this collection contains. */ + readonly serverDefinitions: IObservable; + /** If 'false', consent is required before any MCP servers in this collection are automatically launched. */ + readonly isTrustedByDefault: boolean; + /** Scope where associated collection info should be stored. */ + readonly scope: StorageScope; +} + +export namespace McpCollectionDefinition { + export function equals(a: McpCollectionDefinition, b: McpCollectionDefinition): boolean { + return a.id === b.id + && a.remoteAuthority === b.remoteAuthority + && a.label === b.label + && a.isTrustedByDefault === b.isTrustedByDefault; + } +} + +export interface McpServerDefinition { + /** Globally-unique, stable ID for this definition */ + readonly id: string; + /** Human-readable label for the definition */ + readonly label: string; + /** Descriptor defining how the configuration should be launched. */ + readonly launch: McpServerLaunch; + /** If set, allows configuration variables to be resolved in the {@link launch} with the given context */ + readonly variableReplacement?: { + section?: string; // e.g. 'mcp' + folder?: IWorkspaceFolder; + target?: ConfigurationTarget; + }; +} + +export namespace McpServerDefinition { + export function equals(a: McpServerDefinition, b: McpServerDefinition): boolean { + return a.id === b.id + && a.label === b.label + && objectsEqual(a.launch, b.launch) + && objectsEqual(a.variableReplacement, b.variableReplacement); + } +} + +export interface IMcpService { + readonly servers: IObservable; +} + +export const IMcpService = createDecorator('IMcpService'); + +export interface IMcpServer extends IDisposable { + readonly collection: McpCollectionDefinition; + readonly definition: McpServerDefinition; + readonly state: IObservable; + showOutput(): void; + start(): Promise; + stop(): Promise; + + readonly tools: IObservable; +} + + +export interface IMcpTool { + readonly definition: MCP.Tool; + /** + * Calls a tool + * @throws {@link MpcResponseError} if the tool fails to execute + * @throws {@link McpConnectionFailedError} if the connection to the server fails + */ + call(params: Record, token?: CancellationToken): Promise; +} + +export const enum McpServerTransportType { + /** A command-line MCP server communicating over standard in/out */ + Stdio = 1 << 0, + /** An MCP server that uses Server-Sent Events */ + SSE = 1 << 1, +} + +/** + * MCP server launched on the command line which communicated over stdio. + * https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio + */ +export interface McpServerTransportStdio { + readonly type: McpServerTransportType.Stdio; + readonly cwd: URI; + readonly command: string; + readonly args: readonly string[]; + readonly env: Record; +} + +/** + * MCP server launched on the command line which communicated over server-sent-events. + * https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#http-with-sse + */ +export interface McpServerTransportSSE { + readonly type: McpServerTransportType.SSE; + readonly url: string; +} + +export type McpServerLaunch = + | McpServerTransportStdio + | McpServerTransportSSE; + +/** + * An instance that manages a connection to an MCP server. It can be started, + * stopped, and restarted. Once started and in a running state, it will + * eventually build a {@link IMcpServerConnection.handler}. + */ +export interface IMcpServerConnection extends IDisposable { + readonly definition: McpServerDefinition; + readonly state: IObservable; + readonly handler: IObservable; + + /** + * Shows the current server output. + */ + showOutput(): void; + + /** + * Starts the server if it's stopped. Returns a promise that resolves once + * server exits a 'starting' state. + */ + start(): Promise; + + /** + * Stops the server. + */ + stop(): Promise; +} + +/** + * McpConnectionState is the state of the underlying connection and is + * communicated e.g. from the extension host to the renderer. + */ +export namespace McpConnectionState { + export const enum Kind { + Stopped, + Starting, + Running, + Error, + } + + export const toString = (s: McpConnectionState): string => { + switch (s.state) { + case Kind.Stopped: + return localize('mcpstate.stopped', 'Stopped'); + case Kind.Starting: + return localize('mcpstate.starting', 'Starting'); + case Kind.Running: + return localize('mcpstate.running', 'Running'); + case Kind.Error: + return localize('mcpstate.error', 'Error {0}', s.message); + default: + assertNever(s); + } + }; + + /** Returns if the MCP state is one where starting a new server is valid */ + export const canBeStarted = (s: Kind) => s === Kind.Error || s === Kind.Stopped; + + export interface Stopped { + readonly state: Kind.Stopped; + } + + export interface Starting { + readonly state: Kind.Starting; + } + + export interface Running { + readonly state: Kind.Running; + } + + export interface Error { + readonly state: Kind.Error; + readonly message: string; + } +} + +export type McpConnectionState = + | McpConnectionState.Stopped + | McpConnectionState.Starting + | McpConnectionState.Running + | McpConnectionState.Error; + +export class MpcResponseError extends Error { + constructor(message: string, public readonly code: number, public readonly data: unknown) { + super(`MPC ${code}: ${message}`); + } +} + +export class McpConnectionFailedError extends Error { } diff --git a/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts new file mode 100644 index 00000000000..582e69eaf93 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts @@ -0,0 +1,1138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable @stylistic/ts/member-delimiter-style */ +/* eslint-disable local/code-no-unexternalized-strings */ + +/** + * Schema updated from the Model Context Protocol repository at + * https://github.com/modelcontextprotocol/specification/tree/main/schema + * + * ⚠️ Do not edit within `namespace` manually except to update schema versions ⚠️ + */ +export namespace MCP { + /* JSON-RPC types */ + export type JSONRPCMessage = + | JSONRPCRequest + | JSONRPCNotification + | JSONRPCResponse + | JSONRPCError; + + export const LATEST_PROTOCOL_VERSION = "2024-11-05"; + export const JSONRPC_VERSION = "2.0"; + + /** + * A progress token, used to associate progress notifications with the original request. + */ + export type ProgressToken = string | number; + + /** + * An opaque token used to represent a cursor for pagination. + */ + export type Cursor = string; + + export interface Request { + method: string; + params?: { + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + }; + [key: string]: unknown; + }; + } + + export interface Notification { + method: string; + params?: { + /** + * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; + }; + } + + export interface Result { + /** + * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; + } + + /** + * A uniquely identifying ID for a request in JSON-RPC. + */ + export type RequestId = string | number; + + /** + * A request that expects a response. + */ + export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + } + + /** + * A notification which does not expect a response. + */ + export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; + } + + /** + * A successful (non-error) response to a request. + */ + export interface JSONRPCResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; + } + + // Standard JSON-RPC error codes + export const PARSE_ERROR = -32700; + export const INVALID_REQUEST = -32600; + export const METHOD_NOT_FOUND = -32601; + export const INVALID_PARAMS = -32602; + export const INTERNAL_ERROR = -32603; + + /** + * A response to a request that indicates an error occurred. + */ + export interface JSONRPCError { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + error: { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; + }; + } + + /* Empty result */ + /** + * A response that indicates success but carries no data. + */ + export type EmptyResult = Result; + + /* Cancellation */ + /** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + */ + export interface CancelledNotification extends Notification { + method: "notifications/cancelled"; + params: { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; + }; + } + + /* Initialization */ + /** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ + export interface InitializeRequest extends Request { + method: "initialize"; + params: { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; + }; + } + + /** + * After receiving an initialize request from the client, the server sends this response. + */ + export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; + } + + /** + * This notification is sent from the client to the server after initialization has finished. + */ + export interface InitializedNotification extends Notification { + method: "notifications/initialized"; + } + + /** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + */ + export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: object; + } + + /** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + */ + export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + } + + /** + * Describes the name and version of an MCP implementation. + */ + export interface Implementation { + name: string; + version: string; + } + + /* Ping */ + /** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + */ + export interface PingRequest extends Request { + method: "ping"; + } + + /* Progress notifications */ + /** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + */ + export interface ProgressNotification extends Notification { + method: "notifications/progress"; + params: { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + }; + } + + /* Pagination */ + export interface PaginatedRequest extends Request { + params?: { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; + }; + } + + export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; + } + + /* Resources */ + /** + * Sent from the client to request a list of resources the server has. + */ + export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; + } + + /** + * The server's response to a resources/list request from the client. + */ + export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; + } + + /** + * Sent from the client to request a list of resource templates the server has. + */ + export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; + } + + /** + * The server's response to a resources/templates/list request from the client. + */ + export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; + } + + /** + * Sent from the client to the server, to read a specific resource URI. + */ + export interface ReadResourceRequest extends Request { + method: "resources/read"; + params: { + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; + } + + /** + * The server's response to a resources/read request from the client. + */ + export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; + } + + /** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + */ + export interface ResourceListChangedNotification extends Notification { + method: "notifications/resources/list_changed"; + } + + /** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + */ + export interface SubscribeRequest extends Request { + method: "resources/subscribe"; + params: { + /** + * The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; + } + + /** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + */ + export interface UnsubscribeRequest extends Request { + method: "resources/unsubscribe"; + params: { + /** + * The URI of the resource to unsubscribe from. + * + * @format uri + */ + uri: string; + }; + } + + /** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + */ + export interface ResourceUpdatedNotification extends Notification { + method: "notifications/resources/updated"; + params: { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; + }; + } + + /** + * A known resource that the server is capable of reading. + */ + export interface Resource extends Annotated { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A human-readable name for this resource. + * + * This can be used by clients to populate UI elements. + */ + name: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + } + + /** + * A template description for resources available on the server. + */ + export interface ResourceTemplate extends Annotated { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A human-readable name for the type of resource this template refers to. + * + * This can be used by clients to populate UI elements. + */ + name: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + } + + /** + * The contents of a specific resource or sub-resource. + */ + export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + } + + export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; + } + + export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; + } + + /* Prompts */ + /** + * Sent from the client to request a list of prompts and prompt templates the server has. + */ + export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; + } + + /** + * The server's response to a prompts/list request from the client. + */ + export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; + } + + /** + * Used by the client to get a prompt provided by the server. + */ + export interface GetPromptRequest extends Request { + method: "prompts/get"; + params: { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; + }; + } + + /** + * The server's response to a prompts/get request from the client. + */ + export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; + } + + /** + * A prompt or prompt template that the server offers. + */ + export interface Prompt { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * An optional description of what this prompt provides + */ + description?: string; + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + } + + /** + * Describes an argument that a prompt can accept. + */ + export interface PromptArgument { + /** + * The name of the argument. + */ + name: string; + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; + } + + /** + * The sender or recipient of messages and data in a conversation. + */ + export type Role = "user" | "assistant"; + + /** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + */ + export interface PromptMessage { + role: Role; + content: TextContent | ImageContent | EmbeddedResource; + } + + /** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + */ + export interface EmbeddedResource extends Annotated { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + } + + /** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + */ + export interface PromptListChangedNotification extends Notification { + method: "notifications/prompts/list_changed"; + } + + /* Tools */ + /** + * Sent from the client to request a list of tools the server has. + */ + export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; + } + + /** + * The server's response to a tools/list request from the client. + */ + export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; + } + + /** + * The server's response to a tool call. + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + export interface CallToolResult extends Result { + content: (TextContent | ImageContent | EmbeddedResource)[]; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError?: boolean; + } + + /** + * Used by the client to invoke a tool provided by the server. + */ + export interface CallToolRequest extends Request { + method: "tools/call"; + params: { + name: string; + arguments?: { [key: string]: unknown }; + }; + } + + /** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + */ + export interface ToolListChangedNotification extends Notification { + method: "notifications/tools/list_changed"; + } + + /** + * Definition for a tool the client can call. + */ + export interface Tool { + /** + * The name of the tool. + */ + name: string; + /** + * A human-readable description of the tool. + */ + description?: string; + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + } + + /* Logging */ + /** + * A request from the client to the server, to enable or adjust logging. + */ + export interface SetLevelRequest extends Request { + method: "logging/setLevel"; + params: { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; + }; + } + + /** + * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + */ + export interface LoggingMessageNotification extends Notification { + method: "notifications/message"; + params: { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; + }; + } + + /** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + */ + export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + + /* Sampling */ + /** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + */ + export interface CreateMessageRequest extends Request { + method: "sampling/createMessage"; + params: { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + }; + } + + /** + * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + */ + export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + /** + * The reason why sampling stopped, if known. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | string; + } + + /** + * Describes a message issued to or received from an LLM API. + */ + export interface SamplingMessage { + role: Role; + content: TextContent | ImageContent; + } + + /** + * Base for objects that include optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ + export interface Annotated { + annotations?: { + /** + * Describes who the intended customer of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + } + } + + /** + * Text provided to or from an LLM. + */ + export interface TextContent extends Annotated { + type: "text"; + /** + * The text content of the message. + */ + text: string; + } + + /** + * An image provided to or from an LLM. + */ + export interface ImageContent extends Annotated { + type: "image"; + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + } + + /** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas-some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + */ + export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; + } + + /** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + */ + export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; + } + + /* Autocomplete */ + /** + * A request from the client to the server, to ask for completion options. + */ + export interface CompleteRequest extends Request { + method: "completion/complete"; + params: { + ref: PromptReference | ResourceReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + }; + } + + /** + * The server's response to a completion/complete request + */ + export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; + } + + /** + * A reference to a resource or resource template definition. + */ + export interface ResourceReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; + } + + /** + * Identifies a prompt. + */ + export interface PromptReference { + type: "ref/prompt"; + /** + * The name of the prompt or prompt template + */ + name: string; + } + + /* Roots */ + /** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + */ + export interface ListRootsRequest extends Request { + method: "roots/list"; + } + + /** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + */ + export interface ListRootsResult extends Result { + roots: Root[]; + } + + /** + * Represents a root directory or file that the server can operate on. + */ + export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + } + + /** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + */ + export interface RootsListChangedNotification extends Notification { + method: "notifications/roots/list_changed"; + } + + /* Client messages */ + export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest; + + export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification; + + export type ClientResult = EmptyResult | CreateMessageResult | ListRootsResult; + + /* Server messages */ + export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest; + + export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification; + + export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult; +} diff --git a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index 5c06bad50b5..af059baa74b 100644 --- a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts +++ b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts @@ -202,9 +202,10 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR if (this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && section) { const overrides: IConfigurationOverrides = folder ? { resource: folder.uri } : {}; const result = this.configurationService.inspect(section, overrides); - if (result && (result.userValue || result.workspaceValue || result.workspaceFolderValue)) { + if (result && (result.userValue || result.workspaceValue || result.workspaceFolderValue || result.userRemoteValue)) { switch (target) { case ConfigurationTarget.USER: inputs = (result.userValue)?.inputs; break; + case ConfigurationTarget.USER_REMOTE: inputs = (result.userRemoteValue)?.inputs; break; case ConfigurationTarget.WORKSPACE: inputs = (result.workspaceValue)?.inputs; break; default: inputs = (result.workspaceFolderValue)?.inputs; } diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index f43004533d7..3863717be26 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -191,6 +191,7 @@ import './contrib/speech/browser/speech.contribution.js'; // Chat import './contrib/chat/browser/chat.contribution.js'; import './contrib/inlineChat/browser/inlineChat.contribution.js'; +import './contrib/mcp/browser/mcp.contribution.js'; // Interactive import './contrib/interactive/browser/interactive.contribution.js'; From c2676f75e5e4345eeae2718cae20d3b16cea9270 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 10 Mar 2025 18:11:28 +0100 Subject: [PATCH 003/255] - show mcp servers in ui (WIP) - register mcp server tools as tools - add `IMcpTool#id` - make sure `callOn` awaits result before disposing fyi @connor4312 --- .../contrib/chat/browser/chatInputPart.ts | 24 +++++++ .../contrib/chat/browser/media/chat.css | 3 +- .../workbench/contrib/mcp/common/mcpServer.ts | 11 ++- .../contrib/mcp/common/mcpService.ts | 72 ++++++++++++++++++- .../workbench/contrib/mcp/common/mcpTypes.ts | 4 ++ 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 72d27bf4d43..e6485222077 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -23,6 +23,7 @@ import { HistoryNavigator2 } from '../../../../base/common/history.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; +import { autorun } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; @@ -74,6 +75,7 @@ import { AccessibilityVerbositySettingId } from '../../accessibility/browser/acc import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js'; +import { IMcpService } from '../../mcp/common/mcpTypes.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatEditingSession, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../common/chatEditingService.js'; @@ -200,6 +202,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge contextArr .push(...this.instructionAttachmentsPart.chatAttachments); + + // // TODO@jrieken use only selected servers + // for (const server of this.mcpService.servers.get()) { + // for (const { id, definition } of server.tools.get()) { + // contextArr.push({ + // isTool: true, + // id, + // name: definition.name, + // value: undefined + // }); + // } + // } + return contextArr; } @@ -353,6 +368,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @ILabelService private readonly labelService: ILabelService, @IChatVariablesService private readonly variableService: IChatVariablesService, @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IMcpService private readonly mcpService: IMcpService ) { super(); @@ -723,6 +739,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attachment-toolbar@attachmentToolbar'), dom.h('.chat-attached-context@attachedContextContainer'), dom.h('.chat-related-files@relatedFilesContainer'), + dom.h('.chat-mcp@mcpContainer'), ]), dom.h('.interactive-input-followups@followupsContainer'), ]) @@ -737,6 +754,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attachment-toolbar@attachmentToolbar'), dom.h('.chat-related-files@relatedFilesContainer'), dom.h('.chat-attached-context@attachedContextContainer'), + dom.h('.chat-mcp@mcpContainer'), ]), dom.h('.chat-editor-container@editorContainer'), dom.h('.chat-input-toolbars@inputToolbars'), @@ -762,6 +780,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(this._implicitContext.onDidChangeValue(() => this._handleAttachedContextChange())); } + this._register(autorun(r => { + const servers = this.mcpService.servers.read(r); + const contents = renderLabelWithIcons(localize('serverpattern', "{0} {1}", '$(tools)', servers.length)); + dom.reset(elements.mcpContainer, ...contents); + })); + this.renderAttachedContext(); this._register(this._attachmentModel.onDidChangeContext(() => this._handleAttachedContextChange())); this.renderChatEditingSessionState(null, widget); diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 727fe8694c0..95a7168004c 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -1122,7 +1122,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .action-item.chat-attached-context-attachment.chat-add-files .action-label, -.interactive-session .chat-attached-context .chat-attached-context-attachment { +.interactive-session .chat-attached-context .chat-attached-context-attachment, +.interactive-session .chat-mcp { display: flex; gap: 2px; overflow: hidden; diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 8d8c2c294b0..31b3fc17719 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -177,12 +177,12 @@ export class McpServer extends Disposable implements IMcpServer { * Helper function to call the function on the handler once it's online. The * connection started if it is not already. */ - public callOn(fn: (handler: McpServerRequestHandler) => Promise, token?: CancellationToken): Promise { + public async callOn(fn: (handler: McpServerRequestHandler) => Promise, token?: CancellationToken): Promise { const store = new DisposableStore(); this.start(); // idempotent try { - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { if (token) { store.add(token.onCancellationRequested(() => { reject(new CancellationError()); @@ -215,10 +215,15 @@ export class McpServer extends Disposable implements IMcpServer { } export class McpTool implements IMcpTool { + + readonly id: string; + constructor( private readonly _server: McpServer, public readonly definition: MCP.Tool, - ) { } + ) { + this.id = `${_server.definition.id}_${definition.name}`.replaceAll('.', '_'); + } call(params: Record, token?: CancellationToken): Promise { return this._server.callOn(h => h.callTool({ name: this.definition.name, arguments: params }), token); diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 49fd4ee79ab..ed78ed34fd4 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -4,15 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ILanguageModelToolsService, IToolResult } from '../../chat/common/languageModelToolsService.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServer, McpServerMetadataCache } from './mcpServer.js'; import { IMcpServer, IMcpService, McpCollectionDefinition, McpServerDefinition } from './mcpTypes.js'; export class McpService extends Disposable implements IMcpService { + + declare _serviceBrand: undefined; + private readonly _servers = observableValue(this, []); public readonly servers: IObservable = this._servers; @@ -21,7 +26,8 @@ export class McpService extends Disposable implements IMcpService { constructor( @IInstantiationService instantiationService: IInstantiationService, - @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @ILanguageModelToolsService toolsService: ILanguageModelToolsService ) { super(); @@ -64,6 +70,68 @@ export class McpService extends Disposable implements IMcpService { definitionsObservable.read(reader); updateThrottle.schedule(500); })); + + + const tools = this._register(new MutableDisposable()); + + this._register(autorun(reader => { + + const servers = this._servers.read(reader); + + // TODO@jrieken wasteful, needs some diff'ing/change-info + const newStore = new DisposableStore(); + + tools.clear(); + + for (const server of servers) { + + for (const tool of server.tools.read(reader)) { + + newStore.add(toolsService.registerToolData({ + id: tool.id, + displayName: tool.definition.name, + modelDescription: tool.definition.description ?? '', + inputSchema: tool.definition.inputSchema, + tags: ['mcp', 'vscode_editing'] + })); + newStore.add(toolsService.registerToolImplementation(tool.id, { + + async prepareToolInvocation(parameters, token) { + return { + confirmationMessages: { + title: localize('aaa', "Run tool from {0}", server.definition.label), + message: localize('ddd', "Do you allow to run `{0}` from `{1}`?", tool.definition.name, server.definition.label) + } + }; + }, + + async invoke(invocation, countTokens, token) { + + const result: IToolResult = { + content: [] + }; + + const callResult = await tool.call(invocation.parameters as Record, token); + for (const item of callResult.content) { + if (item.type === 'text') { + result.content.push({ + kind: 'text', + value: item.text + }); + } else { + // TODO@jrieken handle different item types + } + } + + return result; + }, + })); + } + } + + tools.value = newStore; + + })); } public override dispose(): void { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 2b34e23e5cc..d2d3e7fb10d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -70,6 +70,7 @@ export namespace McpServerDefinition { } export interface IMcpService { + _serviceBrand: undefined; readonly servers: IObservable; } @@ -88,6 +89,9 @@ export interface IMcpServer extends IDisposable { export interface IMcpTool { + + readonly id: string; + readonly definition: MCP.Tool; /** * Calls a tool From 1418a96b8e7acdf7609921fcff54eb0069ede5e5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 10 Mar 2025 15:36:40 -0700 Subject: [PATCH 004/255] mcp: add tests and clean up some raciness in MCP handling --- .../contrib/mcp/common/mcpRegistry.ts | 4 +- .../contrib/mcp/common/mcpServerConnection.ts | 37 +- .../mcp/common/mcpServerRequestHandler.ts | 21 +- .../mcp/test/common/mcpRegistry.test.ts | 276 ++++++++++++ .../mcp/test/common/mcpRegistryTypes.ts | 107 +++++ .../test/common/mcpServerConnection.test.ts | 425 ++++++++++++++++++ .../common/mcpServerRequestHandler.test.ts | 392 ++++++++++++++++ 7 files changed, 1248 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index 417599b5d87..fb96aa95cdd 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -59,7 +59,7 @@ export class McpRegistry extends Disposable implements IMcpRegistry { public hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean { const stored = this.getInputStorageData(collection, definition); - return !!stored && !isEmptyObject(stored.map); + return !!stored && !!stored.map && !isEmptyObject(stored.map); } public clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition) { @@ -84,7 +84,7 @@ export class McpRegistry extends Disposable implements IMcpRegistry { if (definition.variableReplacement && storage) { const { folder, section, target } = definition.variableReplacement; // based on _configurationResolverService.resolveWithInteractionReplace - launch = await this._configurationResolverService.resolveAnyAsync(folder, section); + launch = await this._configurationResolverService.resolveAnyAsync(folder, launch); const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, storage.map, target); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts index ee65077e9da..5016ebe7f53 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -24,12 +24,13 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect private readonly _loggerId: string; private readonly _logger: ILogger; + private _launchId = 0; constructor( private readonly _collection: McpCollectionDefinition, public readonly definition: McpServerDefinition, private readonly _delegate: IMcpHostDelegate, - private readonly _launchDefinition: McpServerLaunch, + public readonly launchDefinition: McpServerLaunch, @ILoggerService private readonly _loggerService: ILoggerService, @IOutputService private readonly _outputService: IOutputService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -52,13 +53,14 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); } + const launchId = ++this._launchId; this._launch.value = undefined; this._state.set({ state: McpConnectionState.Kind.Starting }, undefined); this._logger.info(localize('mcpServer.starting', 'Starting server {0}', this.definition.label)); try { - const launch = this._delegate.start(this._collection, this.definition, this._launchDefinition); - this._launch.value = this.adoptLaunch(launch); + const launch = this._delegate.start(this._collection, this.definition, this.launchDefinition); + this._launch.value = this.adoptLaunch(launch, launchId); return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); } catch (e) { const errorState: McpConnectionState = { @@ -70,7 +72,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } } - private adoptLaunch(launch: IMcpMessageTransport): IReference { + private adoptLaunch(launch: IMcpMessageTransport, launchId: number): IReference { const store = new DisposableStore(); const cts = new CancellationTokenSource(); @@ -79,18 +81,29 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect store.add(launch.onDidLog(msg => { this._logger.info(msg); })); + + let didStart = false; store.add(autorun(reader => { const state = launch.state.read(reader); this._state.set(state, undefined); this._logger.info(localize('mcpServer.state', 'Connection state: {0}', McpConnectionState.toString(state))); - if (state.state === McpConnectionState.Kind.Running) { + if (state.state === McpConnectionState.Kind.Running && !didStart) { + didStart = true; McpServerRequestHandler.create(this._instantiationService, launch, this._logger, cts.token).then( - handler => this._requestHandler.set(handler, undefined), + handler => { + if (this._launchId === launchId) { + this._requestHandler.set(handler, undefined); + } else { + handler.dispose(); + } + }, err => { store.dispose(); - this._logger.error(err); - this._state.set({ state: McpConnectionState.Kind.Error, message: `Could not initialize MCP server: ${err.message}` }, undefined); + if (this._launchId === launchId) { + this._logger.error(err); + this._state.set({ state: McpConnectionState.Kind.Error, message: `Could not initialize MCP server: ${err.message}` }, undefined); + } }, ); } @@ -100,17 +113,25 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } public async stop(): Promise { + this._launchId = -1; this._logger.info(localize('mcpServer.stopping', 'Stopping server {0}', this.definition.label)); this._launch.value?.object.stop(); await this._waitForState(McpConnectionState.Kind.Stopped, McpConnectionState.Kind.Error); } public override dispose(): void { + this._launchId = -1; + this._requestHandler.get()?.dispose(); super.dispose(); this._state.set({ state: McpConnectionState.Kind.Stopped }, undefined); } private _waitForState(...kinds: McpConnectionState.Kind[]): Promise { + const current = this._state.get(); + if (kinds.includes(current.state)) { + return Promise.resolve(current); + } + return new Promise(resolve => { const disposable = autorun(reader => { const state = this._state.read(reader); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 572c4ef2e15..992c71ec4b5 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -8,11 +8,12 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogger } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IMcpMessageTransport } from './mcpRegistryTypes.js'; -import { MpcResponseError } from './mcpTypes.js'; +import { McpConnectionState, MpcResponseError } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; /** @@ -113,6 +114,14 @@ export class McpServerRequestHandler extends Disposable { ) { super(); this._register(launch.onDidReceiveMessage(message => this.handleMessage(message))); + this._register(autorun(reader => { + const state = launch.state.read(reader).state; + // the handler will get disposed when the launch stops, but if we're still + // create()'ing we need to make sure to cancel the initialize request. + if (state === McpConnectionState.Kind.Error || state === McpConnectionState.Kind.Stopped) { + this.cancelAllRequests(); + } + })); } /** @@ -151,12 +160,12 @@ export class McpServerRequestHandler extends Disposable { // Send the request this.launch.send(jsonRpcRequest); - promise.p.finally(() => { + const ret = promise.p.finally(() => { cancelListener.dispose(); this._pendingRequests.delete(id); }); - return promise.p as Promise; + return ret as Promise; } /** @@ -352,9 +361,13 @@ export class McpServerRequestHandler extends Disposable { return { roots: this._roots }; } - public override dispose(): void { + private cancelAllRequests() { this._pendingRequests.forEach(pending => pending.promise.cancel()); this._pendingRequests.clear(); + } + + public override dispose(): void { + this.cancelAllRequests(); super.dispose(); } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts new file mode 100644 index 00000000000..19465290098 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { cloneAndChange } from '../../../../../base/common/objects.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { upcast } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILoggerService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; +import { TestLoggerService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { McpRegistry } from '../../common/mcpRegistry.js'; +import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; +import { McpServerConnection } from '../../common/mcpServerConnection.js'; +import { McpCollectionDefinition, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; +import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; + +class TestConfigurationResolverService implements Partial { + declare readonly _serviceBrand: undefined; + + private interactiveCounter = 0; + + // Used to simulate stored/resolved variables + private readonly resolvedVariables = new Map(); + + constructor() { + // Add some test variables + this.resolvedVariables.set('workspaceFolder', '/test/workspace'); + this.resolvedVariables.set('fileBasename', 'test.txt'); + } + + resolveAsync(folder: any, value: any): Promise { + if (typeof value === 'string') { + return Promise.resolve(this.replaceVariables(value)); + } else if (Array.isArray(value)) { + return Promise.resolve(value.map(v => typeof v === 'string' ? this.replaceVariables(v) : v)); + } else { + const result: Record = {}; + for (const key in value) { + if (typeof value[key] === 'string') { + result[key] = this.replaceVariables(value[key]); + } else { + result[key] = value[key]; + } + } + return Promise.resolve(result); + } + } + + private replaceVariables(value: string): string { + let result = value; + for (const [key, val] of this.resolvedVariables.entries()) { + result = result.replace(`\${${key}}`, val); + } + return result; + } + + resolveAnyAsync(folder: any, config: any, commandValueMapping?: Record): Promise { + // Use cloneAndChange to recursively replace variables in the config + const newConfig = cloneAndChange(config, (value) => { + if (typeof value === 'string') { + // Replace any ${variable} with its value + let result = value; + for (const [key, val] of this.resolvedVariables.entries()) { + result = result.replace(`\${${key}}`, val); + } + + // If a commandValueMapping is provided, use it for additional replacements + if (commandValueMapping) { + for (const [key, val] of Object.entries(commandValueMapping)) { + result = result.replace(`\${${key}}`, val); + } + } + + return result === value ? undefined : result; + } + return undefined; + }); + + return Promise.resolve(newConfig); + } + + resolveWithInteraction(folder: any, config: any, section?: string, variables?: Record, target?: ConfigurationTarget): Promise | undefined> { + // For testing, we simulate interaction by returning a map with some variables + const result = new Map(); + result.set('input:testInteractive', `interactiveValue${this.interactiveCounter++}`); + result.set('command:testCommand', `commandOutput${this.interactiveCounter++}}`); + + // If variables are provided, include those too + if (variables) { + Object.entries(variables).forEach(([key, value]) => { + result.set(key, value); + }); + } + + return Promise.resolve(result); + } +} + +class TestMcpHostDelegate implements IMcpHostDelegate { + canStart(): boolean { + return true; + } + + start(): IMcpMessageTransport { + return new TestMcpMessageTransport(); + } +} + +suite('Workbench - MCP - Registry', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let registry: McpRegistry; + let testStorageService: TestStorageService; + let testConfigResolverService: TestConfigurationResolverService; + let testCollection: McpCollectionDefinition; + let baseDefinition: McpServerDefinition; + + setup(() => { + testConfigResolverService = new TestConfigurationResolverService(); + testStorageService = store.add(new TestStorageService()); + + const services = new ServiceCollection( + [IConfigurationResolverService, testConfigResolverService], + [IStorageService, testStorageService], + [ILoggerService, store.add(new TestLoggerService())], + [IOutputService, upcast({ showChannel: () => { } })], + ); + + const instaService = store.add(new TestInstantiationService(services)); + registry = store.add(instaService.createInstance(McpRegistry)); + + // Create test collection that can be reused + testCollection = { + id: 'test-collection', + label: 'Test Collection', + remoteAuthority: null, + serverDefinitions: observableValue('serverDefs', []), + isTrustedByDefault: true, + scope: StorageScope.APPLICATION + }; + + // Create base definition that can be reused + baseDefinition = { + id: 'test-server', + label: 'Test Server', + launch: { + type: McpServerTransportType.Stdio, + command: 'test-command', + args: [], + env: {}, + cwd: URI.parse('file:///test') + } + }; + }); + + test('registerCollection adds collection to registry', () => { + const disposable = registry.registerCollection(testCollection); + store.add(disposable); + + assert.strictEqual(registry.collections.get().length, 1); + assert.strictEqual(registry.collections.get()[0], testCollection); + + disposable.dispose(); + assert.strictEqual(registry.collections.get().length, 0); + }); + + test('registerDelegate adds delegate to registry', () => { + const delegate = new TestMcpHostDelegate(); + const disposable = registry.registerDelegate(delegate); + store.add(disposable); + + assert.strictEqual(registry.delegates.length, 1); + assert.strictEqual(registry.delegates[0], delegate); + + disposable.dispose(); + assert.strictEqual(registry.delegates.length, 0); + }); + + test('hasSavedInputs returns false when no inputs are saved', () => { + assert.strictEqual(registry.hasSavedInputs(testCollection, baseDefinition), false); + }); + + test('clearSavedInputs removes stored inputs', () => { + const definition: McpServerDefinition = { + ...baseDefinition, + variableReplacement: { + section: 'mcp' + } + }; + + // Save some mock inputs + const key = `mcpConfig.${testCollection.id}.${definition.id}`; + testStorageService.store(key, JSON.stringify({ 'input:foo': 'bar' }), StorageScope.APPLICATION, StorageTarget.MACHINE); + + assert.strictEqual(registry.hasSavedInputs(testCollection, definition), true); + registry.clearSavedInputs(testCollection, definition); + assert.strictEqual(registry.hasSavedInputs(testCollection, definition), false); + }); + + test('resolveConnection creates connection with resolved variables and memorizes them', async () => { + const definition: McpServerDefinition = { + ...baseDefinition, + launch: { + type: McpServerTransportType.Stdio, + command: '${workspaceFolder}/cmd', + args: ['--file', '${fileBasename}'], + env: { + PATH: '${input:testInteractive}' + }, + cwd: URI.parse('file:///test') + }, + variableReplacement: { + section: 'mcp' + } + }; + + // Register a delegate that can handle the connection + const delegate = new TestMcpHostDelegate(); + const disposable = registry.registerDelegate(delegate); + store.add(disposable); + + const connection = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + + assert.ok(connection); + assert.strictEqual(connection.definition, definition); + assert.strictEqual((connection.launchDefinition as any).command, '/test/workspace/cmd'); + assert.strictEqual((connection.launchDefinition as any).env.PATH, 'interactiveValue0'); + connection.dispose(); + + const connection2 = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + + assert.ok(connection2); + assert.strictEqual((connection2.launchDefinition as any).env.PATH, 'interactiveValue0'); + connection2.dispose(); + }); + + test('resolveConnection with stored variables resolves them', async () => { + const definition: McpServerDefinition = { + ...baseDefinition, + launch: { + type: McpServerTransportType.Stdio, + command: '${storedVar}', + args: [], + env: {}, + cwd: URI.parse('file:///test') + }, + variableReplacement: { + section: 'mcp' + } + }; + + // Save some mock inputs + const key = `mcpConfig.${testCollection.id}.${definition.id}`; + testStorageService.store(key, { 'storedVar': 'resolved-value' }, StorageScope.APPLICATION, StorageTarget.MACHINE); + + // Register a delegate that can handle the connection + const delegate = new TestMcpHostDelegate(); + const disposable = registry.registerDelegate(delegate); + store.add(disposable); + + const connection = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + + assert.ok(connection); + assert.strictEqual((connection.launchDefinition as any).command, 'resolved-value'); + connection.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts new file mode 100644 index 00000000000..567c613329d --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; +import { McpConnectionState } from '../../common/mcpTypes.js'; +import { MCP } from '../../common/modelContextProtocol.js'; + +/** + * Implementation of IMcpMessageTransport for testing purposes. + * Allows tests to easily send/receive messages and control the connection state. + */ +export class TestMcpMessageTransport extends Disposable implements IMcpMessageTransport { + private readonly _onDidLog = this._register(new Emitter()); + public readonly onDidLog = this._onDidLog.event; + + private readonly _onDidReceiveMessage = this._register(new Emitter()); + public readonly onDidReceiveMessage = this._onDidReceiveMessage.event; + + private readonly _stateValue = observableValue('testTransportState', { state: McpConnectionState.Kind.Starting }); + public readonly state = this._stateValue; + + private readonly _sentMessages: MCP.JSONRPCMessage[] = []; + + constructor() { + super(); + } + + /** + * Send a message through the transport. + */ + public send(message: MCP.JSONRPCMessage): void { + this._sentMessages.push(message); + } + + /** + * Stop the transport. + */ + public stop(): void { + this._stateValue.set({ state: McpConnectionState.Kind.Stopped }, undefined); + } + + // Test Helper Methods + + /** + * Simulate receiving a message from the server. + */ + public simulateReceiveMessage(message: MCP.JSONRPCMessage): void { + this._onDidReceiveMessage.fire(message); + } + + /** + * Simulates a reply to an 'initialized' request. + */ + public simulateInitialized() { + if (!this._sentMessages.length) { + throw new Error('initialize was not called yet'); + } + + this.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: (this.getSentMessages()[0] as MCP.JSONRPCRequest).id, + result: { + protocolVersion: MCP.LATEST_PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'Test Server', + version: '1.0.0' + }, + } satisfies MCP.InitializeResult + }); + } + + /** + * Simulate a log event. + */ + public simulateLog(message: string): void { + this._onDidLog.fire(message); + } + + /** + * Set the connection state. + */ + public setConnectionState(state: McpConnectionState): void { + this._stateValue.set(state, undefined); + } + + /** + * Get all messages that have been sent. + */ + public getSentMessages(): readonly MCP.JSONRPCMessage[] { + return [...this._sentMessages]; + } + + /** + * Clear the sent messages history. + */ + public clearSentMessages(): void { + this._sentMessages.length = 0; + } +} diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts new file mode 100644 index 00000000000..dea177b77ff --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -0,0 +1,425 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import { upcast } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogger, ILoggerService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; +import { TestLoggerService, TestProductService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; +import { McpServerConnection } from '../../common/mcpServerConnection.js'; +import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; +import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; + +class TestMcpHostDelegate extends Disposable implements IMcpHostDelegate { + private readonly _transport: TestMcpMessageTransport; + private _canStartValue = true; + + constructor() { + super(); + this._transport = this._register(new TestMcpMessageTransport()); + } + + canStart(): boolean { + return this._canStartValue; + } + + start(): IMcpMessageTransport { + if (!this._canStartValue) { + throw new Error('Cannot start server'); + } + return this._transport; + } + + getTransport(): TestMcpMessageTransport { + return this._transport; + } + + setCanStart(value: boolean): void { + this._canStartValue = value; + } +} + +suite('Workbench - MCP - ServerConnection', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let delegate: TestMcpHostDelegate; + let transport: TestMcpMessageTransport; + let collection: McpCollectionDefinition; + let serverDefinition: McpServerDefinition; + + setup(() => { + delegate = store.add(new TestMcpHostDelegate()); + transport = delegate.getTransport(); + + // Setup test services + const services = new ServiceCollection( + [ILoggerService, store.add(new TestLoggerService())], + [IOutputService, upcast({ showChannel: () => { } })], + [IStorageService, store.add(new TestStorageService())], + [IProductService, TestProductService], + ); + + instantiationService = store.add(new TestInstantiationService(services)); + + // Create test collection + collection = { + id: 'test-collection', + label: 'Test Collection', + remoteAuthority: null, + serverDefinitions: observableValue('serverDefs', []), + isTrustedByDefault: true, + scope: StorageScope.APPLICATION + }; + + // Create server definition + serverDefinition = { + id: 'test-server', + label: 'Test Server', + launch: { + type: McpServerTransportType.Stdio, + command: 'test-command', + args: [], + env: {}, + cwd: URI.parse('file:///test') + } + }; + }); + + function waitForHandler(cnx: McpServerConnection) { + const handler = cnx.handler.get(); + if (handler) { + return Promise.resolve(handler); + } + + return new Promise(resolve => { + const disposable = autorun(reader => { + const handler = cnx.handler.read(reader); + if (handler) { + disposable.dispose(); + resolve(handler); + } + }); + }); + } + + test('should start and set state to Running when transport succeeds', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise = connection.start(); + + // Simulate successful connection + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + const state = await startPromise; + assert.strictEqual(state.state, McpConnectionState.Kind.Running); + + transport.simulateInitialized(); + assert.ok(await waitForHandler(connection)); + }); + + test('should handle errors during start', async () => { + // Setup delegate to fail on start + delegate.setCanStart(false); + + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const state = await connection.start(); + + assert.strictEqual(state.state, McpConnectionState.Kind.Error); + assert.ok(state.message); + }); + + test('should handle transport errors', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise = connection.start(); + + // Simulate error in transport + transport.setConnectionState({ + state: McpConnectionState.Kind.Error, + message: 'Test error message' + }); + + const state = await startPromise; + assert.strictEqual(state.state, McpConnectionState.Kind.Error); + assert.strictEqual(state.message, 'Test error message'); + }); + + test('should stop and set state to Stopped', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise = connection.start(); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + await startPromise; + + // Stop the connection + const stopPromise = connection.stop(); + await stopPromise; + + assert.strictEqual(connection.state.get().state, McpConnectionState.Kind.Stopped); + }); + + test('should not restart if already starting', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise1 = connection.start(); + + // Try to start again while starting + const startPromise2 = connection.start(); + + // Simulate successful connection + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + const state1 = await startPromise1; + const state2 = await startPromise2; + + // Both promises should resolve to the same state + assert.strictEqual(state1.state, McpConnectionState.Kind.Running); + assert.strictEqual(state2.state, McpConnectionState.Kind.Running); + + transport.simulateInitialized(); + assert.ok(await waitForHandler(connection)); + }); + + test('should clean up when disposed', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + + // Start the connection + const startPromise = connection.start(); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + await startPromise; + + // Dispose the connection + connection.dispose(); + + assert.strictEqual(connection.state.get().state, McpConnectionState.Kind.Stopped); + }); + + test('showOutput should call logger and output services', () => { + let channelShown = false; + const outputService = { + showChannel: (id: string) => { + assert.strictEqual(id, `mcpServer/${serverDefinition.id}`); + channelShown = true; + } + }; + + let loggerVisible = false; + const loggerService = new class extends TestLoggerService { + override setVisibility(id: string, visible: boolean): void { + assert.strictEqual(id, `mcpServer/${serverDefinition.id}`); + assert.strictEqual(visible, true); + loggerVisible = true; + } + }; + + // Override services + const services = new ServiceCollection( + [ILoggerService, store.add(loggerService)], + [IOutputService, upcast(outputService)], + [IStorageService, store.add(new TestStorageService())] + ); + + const localInstantiationService = store.add(new TestInstantiationService(services)); + + // Create server connection + const connection = localInstantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Show output + connection.showOutput(); + + assert.strictEqual(channelShown, true); + assert.strictEqual(loggerVisible, true); + }); + + test('should log transport messages', async () => { + // Track logged messages + const loggedMessages: string[] = []; + const loggerService = new class extends TestLoggerService { + override createLogger(id: string) { + return { + info: (message: string) => { + loggedMessages.push(message); + }, + error: () => { }, + dispose: () => { } + } as Partial as ILogger; + } + }; + + // Override services + const services = new ServiceCollection( + [ILoggerService, store.add(loggerService)], + [IOutputService, upcast({ showChannel: () => { } })], + [IStorageService, store.add(new TestStorageService())] + ); + + const localInstantiationService = store.add(new TestInstantiationService(services)); + + // Create server connection + const connection = localInstantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise = connection.start(); + + // Simulate log message from transport + transport.simulateLog('Test log message'); + + // Set connection to running + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + await startPromise; + + // Check that the message was logged + assert.ok(loggedMessages.some(msg => msg === 'Test log message')); + }); + + test('should correctly handle transitions to and from error state', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // Start the connection + const startPromise = connection.start(); + + // Transition to error state + const errorState: McpConnectionState = { + state: McpConnectionState.Kind.Error, + message: 'Temporary error' + }; + transport.setConnectionState(errorState); + + let state = await startPromise; + assert.equal(state, errorState); + + + transport.setConnectionState({ state: McpConnectionState.Kind.Stopped }); + + // Transition back to running state + const startPromise2 = connection.start(); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + state = await startPromise2; + assert.deepStrictEqual(state, { state: McpConnectionState.Kind.Running }); + + connection.dispose(); + await timeout(10); + }); + + test('should handle multiple start/stop cycles', async () => { + // Create server connection + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + serverDefinition, + delegate, + serverDefinition.launch + ); + store.add(connection); + + // First cycle + let startPromise = connection.start(); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + await startPromise; + + await connection.stop(); + assert.deepStrictEqual(connection.state.get(), { state: McpConnectionState.Kind.Stopped }); + + // Second cycle + startPromise = connection.start(); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + await startPromise; + + assert.deepStrictEqual(connection.state.get(), { state: McpConnectionState.Kind.Running }); + + await connection.stop(); + + assert.deepStrictEqual(connection.state.get(), { state: McpConnectionState.Kind.Stopped }); + + connection.dispose(); + await timeout(10); + }); +}); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts new file mode 100644 index 00000000000..f78bf427271 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -0,0 +1,392 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { upcast } from '../../../../../base/common/types.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILoggerService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { TestLoggerService, TestProductService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IMcpHostDelegate } from '../../common/mcpRegistryTypes.js'; +import { McpServerRequestHandler } from '../../common/mcpServerRequestHandler.js'; +import { McpConnectionState } from '../../common/mcpTypes.js'; +import { MCP } from '../../common/modelContextProtocol.js'; +import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; + +class TestMcpHostDelegate extends Disposable implements IMcpHostDelegate { + private readonly _transport: TestMcpMessageTransport; + + constructor() { + super(); + this._transport = this._register(new TestMcpMessageTransport()); + } + + canStart(): boolean { + return true; + } + + start(): TestMcpMessageTransport { + return this._transport; + } + + getTransport(): TestMcpMessageTransport { + return this._transport; + } +} + +suite('Workbench - MCP - ServerRequestHandler', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let delegate: TestMcpHostDelegate; + let transport: TestMcpMessageTransport; + let handler: McpServerRequestHandler; + let cts: CancellationTokenSource; + + setup(async () => { + delegate = store.add(new TestMcpHostDelegate()); + transport = delegate.getTransport(); + cts = store.add(new CancellationTokenSource()); + + // Setup test services + const services = new ServiceCollection( + [ILoggerService, store.add(new TestLoggerService())], + [IOutputService, upcast({ showChannel: () => { } })], + [IStorageService, store.add(new TestStorageService())], + [IProductService, TestProductService], + ); + + instantiationService = store.add(new TestInstantiationService(services)); + + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + // Manually create the handler since we need the transport already set up + const logger = store.add((instantiationService.get(ILoggerService) as TestLoggerService) + .createLogger('mcpServerTest', { hidden: true, name: 'MCP Test' })); + + // Start the handler creation + const handlerPromise = McpServerRequestHandler.create(instantiationService, transport, logger, cts.token); + + // Simulate successful initialization + // We need to respond to the initialize request that the handler will make + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: 1, // The handler uses 1 for the first request + result: { + protocolVersion: MCP.LATEST_PROTOCOL_VERSION, + serverInfo: { + name: 'Test MCP Server', + version: '1.0.0', + }, + capabilities: { + resources: { + supportedTypes: ['text/plain'], + }, + tools: { + supportsCancellation: true, + } + } + } + }); + + handler = await handlerPromise; + store.add(handler); + }); + + test('should send and receive JSON-RPC requests', async () => { + // Setup request + const requestPromise = handler.listResources(); + + // Get the sent message and verify it + const sentMessages = transport.getSentMessages(); + assert.strictEqual(sentMessages.length, 2); // initialize + listResources + + // Verify listResources request format + const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + assert.strictEqual(listResourcesRequest.method, 'resources/list'); + assert.strictEqual(listResourcesRequest.jsonrpc, MCP.JSONRPC_VERSION); + assert.ok(typeof listResourcesRequest.id === 'number'); + + // Simulate server response with mock resources that match the expected Resource interface + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: listResourcesRequest.id, + result: { + resources: [ + { uri: 'resource1', type: 'text/plain', name: 'Test Resource 1' }, + { uri: 'resource2', type: 'text/plain', name: 'Test Resource 2' } + ] + } + }); + + // Verify the result + const resources = await requestPromise; + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].uri, 'resource1'); + assert.strictEqual(resources[1].name, 'Test Resource 2'); + }); + + test('should handle paginated requests', async () => { + // Setup request + const requestPromise = handler.listResources(); + + // Get the first request and respond with pagination + const sentMessages = transport.getSentMessages(); + const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + + // Send first page with nextCursor + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: listResourcesRequest.id, + result: { + resources: [ + { uri: 'resource1', type: 'text/plain', name: 'Test Resource 1' } + ], + nextCursor: 'page2' + } + }); + + // Clear the sent messages to only capture the next page request + transport.clearSentMessages(); + + // Wait a bit to allow the handler to process and send the next request + await new Promise(resolve => setTimeout(resolve, 0)); + + // Get the second request and verify cursor is included + const sentMessages2 = transport.getSentMessages(); + assert.strictEqual(sentMessages2.length, 1); + + const listResourcesRequest2 = sentMessages2[0] as MCP.JSONRPCRequest; + assert.strictEqual(listResourcesRequest2.method, 'resources/list'); + assert.deepStrictEqual(listResourcesRequest2.params, { cursor: 'page2' }); + + // Send final page with no nextCursor + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: listResourcesRequest2.id, + result: { + resources: [ + { uri: 'resource2', type: 'text/plain', name: 'Test Resource 2' } + ] + } + }); + + // Verify the combined result + const resources = await requestPromise; + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].uri, 'resource1'); + assert.strictEqual(resources[1].uri, 'resource2'); + }); + + test('should handle error responses', async () => { + // Setup request + const requestPromise = handler.readResource({ uri: 'non-existent' }); + + // Get the sent message + const sentMessages = transport.getSentMessages(); + const readResourceRequest = sentMessages[1] as MCP.JSONRPCRequest; // [0] is initialize + + // Simulate error response + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: readResourceRequest.id, + error: { + code: MCP.METHOD_NOT_FOUND, + message: 'Resource not found' + } + }); + + // Verify the error is thrown correctly + try { + await requestPromise; + assert.fail('Expected error was not thrown'); + } catch (e: any) { + assert.strictEqual(e.message, 'MPC -32601: Resource not found'); + assert.strictEqual(e.code, MCP.METHOD_NOT_FOUND); + } + }); + + test('should handle server requests', async () => { + // Simulate ping request from server + const pingRequest: MCP.JSONRPCRequest & MCP.PingRequest = { + jsonrpc: MCP.JSONRPC_VERSION, + id: 100, + method: 'ping' + }; + + transport.simulateReceiveMessage(pingRequest); + + // The handler should have sent a response + const sentMessages = transport.getSentMessages(); + const pingResponse = sentMessages.find(m => + 'id' in m && m.id === pingRequest.id && 'result' in m + ) as MCP.JSONRPCResponse; + + assert.ok(pingResponse, 'No ping response was sent'); + assert.deepStrictEqual(pingResponse.result, {}); + }); + + test('should handle roots list requests', async () => { + // Set roots + handler.roots = [ + { uri: 'file:///test/root1', name: 'Root 1' }, + { uri: 'file:///test/root2', name: 'Root 2' } + ]; + + // Simulate roots/list request from server + const rootsRequest: MCP.JSONRPCRequest & MCP.ListRootsRequest = { + jsonrpc: MCP.JSONRPC_VERSION, + id: 101, + method: 'roots/list' + }; + + transport.simulateReceiveMessage(rootsRequest); + + // The handler should have sent a response + const sentMessages = transport.getSentMessages(); + const rootsResponse = sentMessages.find(m => + 'id' in m && m.id === rootsRequest.id && 'result' in m + ) as MCP.JSONRPCResponse; + + assert.ok(rootsResponse, 'No roots/list response was sent'); + assert.strictEqual((rootsResponse.result as MCP.ListRootsResult).roots.length, 2); + assert.strictEqual((rootsResponse.result as MCP.ListRootsResult).roots[0].uri, 'file:///test/root1'); + }); + + test('should handle server notifications', async () => { + let progressNotificationReceived = false; + store.add(handler.onDidReceiveProgressNotification(notification => { + progressNotificationReceived = true; + assert.strictEqual(notification.method, 'notifications/progress'); + assert.strictEqual(notification.params.progressToken, 'token1'); + assert.strictEqual(notification.params.progress, 50); + })); + + // Simulate progress notification with correct format + const progressNotification: MCP.JSONRPCNotification & MCP.ProgressNotification = { + jsonrpc: MCP.JSONRPC_VERSION, + method: 'notifications/progress', + params: { + progressToken: 'token1', + progress: 50, + total: 100 + } + }; + + transport.simulateReceiveMessage(progressNotification); + assert.strictEqual(progressNotificationReceived, true); + }); + + test('should handle cancellation', async () => { + // Setup a new cancellation token source for this specific test + const testCts = store.add(new CancellationTokenSource()); + const requestPromise = handler.listResources(undefined, testCts.token); + + // Get the request ID + const sentMessages = transport.getSentMessages(); + const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const requestId = listResourcesRequest.id; + + // Cancel the request + testCts.cancel(); + + // Check that a cancellation notification was sent + const cancelNotification = transport.getSentMessages().find(m => + !('id' in m) && + 'method' in m && + m.method === 'notifications/cancelled' && + 'params' in m && + m.params && m.params.requestId === requestId + ); + + assert.ok(cancelNotification, 'No cancellation notification was sent'); + + // Verify the promise was cancelled + try { + await requestPromise; + assert.fail('Promise should have been cancelled'); + } catch (e) { + assert.strictEqual(e.name, 'Canceled'); + } + }); + + test('should handle cancelled notification from server', async () => { + // Setup request + const requestPromise = handler.listResources(); + + // Get the request ID + const sentMessages = transport.getSentMessages(); + const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const requestId = listResourcesRequest.id; + + // Simulate cancelled notification from server + const cancelledNotification: MCP.JSONRPCNotification & MCP.CancelledNotification = { + jsonrpc: MCP.JSONRPC_VERSION, + method: 'notifications/cancelled', + params: { + requestId + } + }; + + transport.simulateReceiveMessage(cancelledNotification); + + // Verify the promise was cancelled + try { + await requestPromise; + assert.fail('Promise should have been cancelled'); + } catch (e) { + assert.strictEqual(e.name, 'Canceled'); + } + }); + + test('should dispose properly and cancel pending requests', async () => { + // Setup multiple requests + const request1 = handler.listResources(); + const request2 = handler.listTools(); + + // Dispose the handler + handler.dispose(); + + // Verify all promises were cancelled + try { + await request1; + assert.fail('Promise 1 should have been cancelled'); + } catch (e) { + assert.strictEqual(e.name, 'Canceled'); + } + + try { + await request2; + assert.fail('Promise 2 should have been cancelled'); + } catch (e) { + assert.strictEqual(e.name, 'Canceled'); + } + }); + + test('should handle connection error by cancelling requests', async () => { + // Setup request + const requestPromise = handler.listResources(); + + // Simulate connection error + transport.setConnectionState({ + state: McpConnectionState.Kind.Error, + message: 'Connection lost' + }); + + // Verify the promise was cancelled + try { + await requestPromise; + assert.fail('Promise should have been cancelled'); + } catch (e) { + assert.strictEqual(e.name, 'Canceled'); + } + }); +}); From 81775e7ade6b9a65893dc6b1f00009a3b0a8e969 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 10 Mar 2025 23:06:29 -0700 Subject: [PATCH 005/255] mcp: make discovery good - There's an mcpDiscoveryRegistry that allows components to register in how they discover MCP servers. - Config discovery is one of these. Figured out all the bits for a standalone file config shebang. Duplication there that could be cleaned up but it works. - The others are remote and local filesystem discovery. I ended up making another message channel for the main process/remote server to get a couple environment variables we need since I didn't see anything generic for this already(?) ![](https://memes.peet.io/img/25-03-29927218-daa9-4206-8cef-29992850d9ba.mp4) --- extensions/configuration-editing/package.json | 5 + src/vs/code/electron-main/app.ts | 5 + .../diagnostics/node/diagnosticsService.ts | 1 + .../mcp/common/nativeMcpDiscoveryHelper.ts | 26 ++++ .../node/nativeMcpDiscoveryHelperChannel.ts | 47 +++++++ src/vs/server/node/serverServices.ts | 4 + src/vs/workbench/api/browser/mainThreadMcp.ts | 63 +++++++-- .../api/common/configurationExtensionPoint.ts | 16 ++- .../workbench/api/common/extHost.protocol.ts | 4 +- src/vs/workbench/api/node/extHostMpcNode.ts | 3 +- .../contrib/chat/browser/chat.contribution.ts | 11 ++ .../contrib/mcp/browser/mcp.contribution.ts | 16 ++- .../contrib/mcp/browser/mcpCommands.ts | 29 ++-- .../contrib/mcp/browser/mcpDiscovery.ts | 74 ++-------- .../common/discovery/configMcpDiscovery.ts | 131 ++++++++++++++++++ .../mcp/common/discovery/mcpDiscovery.ts | 29 ++++ .../discovery/nativeMcpDiscoveryAbstract.ts | 103 ++++++++++++++ .../discovery/nativeMcpDiscoveryAdapters.ts | 69 +++++++++ .../discovery/nativeMcpRemoteDiscovery.ts | 48 +++++++ .../contrib/mcp/common/mcpConfiguration.ts | 79 +++++++++++ .../workbench/contrib/mcp/common/mcpTypes.ts | 23 ++- .../mcp/electron-sandbox/mcp.contribution.ts | 10 ++ .../electron-sandbox/nativeMpcDiscovery.ts | 39 ++++++ .../configuration/browser/configuration.ts | 4 +- .../configuration/common/configuration.ts | 4 + .../common/configurationEditing.ts | 14 +- .../baseConfigurationResolverService.ts | 12 +- .../common/configurationResolver.ts | 24 ++-- .../common/variableResolver.ts | 40 +++--- src/vs/workbench/workbench.desktop.main.ts | 3 + 30 files changed, 803 insertions(+), 133 deletions(-) create mode 100644 src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts create mode 100644 src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/mcpDiscovery.ts create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts create mode 100644 src/vs/workbench/contrib/mcp/electron-sandbox/mcp.contribution.ts create mode 100644 src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts diff --git a/extensions/configuration-editing/package.json b/extensions/configuration-editing/package.json index 224ea33c02a..531ba21fe06 100644 --- a/extensions/configuration-editing/package.json +++ b/extensions/configuration-editing/package.json @@ -49,6 +49,7 @@ "settings.json", "launch.json", "tasks.json", + "mcp.json", "keybindings.json", "extensions.json", "argv.json", @@ -117,6 +118,10 @@ "fileMatch": "/.vscode/tasks.json", "url": "vscode://schemas/tasks" }, + { + "fileMatch": "/.vscode/mcp.json", + "url": "vscode://schemas/mcp" + }, { "fileMatch": "%APP_SETTINGS_HOME%/tasks.json", "url": "vscode://schemas/tasks" diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 21a297a12d8..22b09835a9c 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -118,6 +118,8 @@ import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/ele import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.js'; import { normalizeNFC } from '../../base/common/normalization.js'; import { ICSSDevelopmentService, CSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; +import { NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; /** * The main VS Code application. There will only ever be one instance, @@ -1222,6 +1224,9 @@ export class CodeApplication extends Disposable { const externalTerminalChannel = ProxyChannel.fromService(accessor.get(IExternalTerminalMainService), disposables); mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel); + // MCP + mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, new NativeMcpDiscoveryHelperChannel(undefined)); + // Logger const loggerChannel = new LoggerChannel(accessor.get(ILoggerMainService),); mainProcessElectronServer.registerChannel('logger', loggerChannel); diff --git a/src/vs/platform/diagnostics/node/diagnosticsService.ts b/src/vs/platform/diagnostics/node/diagnosticsService.ts index 5f6efd51dd4..e523217c108 100644 --- a/src/vs/platform/diagnostics/node/diagnosticsService.ts +++ b/src/vs/platform/diagnostics/node/diagnosticsService.ts @@ -46,6 +46,7 @@ export async function collectWorkspaceStats(folder: string, filter: string[]): P { tag: 'eslint.json', filePattern: /^eslint\.json$/i }, { tag: 'tasks.json', filePattern: /^tasks\.json$/i }, { tag: 'launch.json', filePattern: /^launch\.json$/i }, + { tag: 'mcp.json', filePattern: /^mcp\.json$/i }, { tag: 'settings.json', filePattern: /^settings\.json$/i }, { tag: 'webpack.config.js', filePattern: /^webpack\.config\.js$/i }, { tag: 'project.json', filePattern: /^project\.json$/i }, diff --git a/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts new file mode 100644 index 00000000000..31fc9e2604a --- /dev/null +++ b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Platform } from '../../../base/common/platform.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const INativeMcpDiscoveryHelperService = createDecorator('INativeMcpDiscoveryHelperService'); + +export const NativeMcpDiscoveryHelperChannelName = 'NativeMcpDiscoveryHelper'; + +export interface INativeMcpDiscoveryData { + // platform and homedir are duplicated by the remote/native environment, but here for convenience + platform: Platform; + homedir: URI; + winAppData?: URI; + xdgHome?: URI; +} + +export interface INativeMcpDiscoveryHelperService { + readonly _serviceBrand: undefined; + + load(): Promise; +} diff --git a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts new file mode 100644 index 00000000000..875788b3695 --- /dev/null +++ b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { homedir } from 'os'; +import { Event } from '../../../base/common/event.js'; +import { URI } from '../../../base/common/uri.js'; +import { IURITransformer, transformOutgoingURIs } from '../../../base/common/uriIpc.js'; +import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { INativeMcpDiscoveryData } from '../common/nativeMcpDiscoveryHelper.js'; +import { platform } from '../../../base/common/platform.js'; + +export class NativeMcpDiscoveryHelperChannel implements IServerChannel { + + constructor(private getUriTransformer: undefined | ((requestContext: any) => IURITransformer)) { } + + listen(context: any, event: string): Event { + throw new Error('Invalid listen'); + } + + async call(context: any, command: string, args?: any): Promise { + const uriTransformer = this.getUriTransformer?.(context); + switch (command) { + case 'load': { + const result: INativeMcpDiscoveryData = { + platform, + homedir: URI.file(homedir()), + winAppData: this.uriFromEnvVariable('APPDATA'), + xdgHome: this.uriFromEnvVariable('XDG_CONFIG_HOME'), + }; + + return uriTransformer ? transformOutgoingURIs(result, uriTransformer) : result; + } + } + throw new Error('Invalid call'); + } + + private uriFromEnvVariable(varName: string) { + const envVar = process.env[varName]; + if (!envVar) { + return undefined; + } + return URI.file(envVar); + } +} + diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index d66209b4ad6..cb2757d4104 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -80,6 +80,8 @@ import { NodePtyHostStarter } from '../../platform/terminal/node/nodePtyHostStar import { CSSDevelopmentService, ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryLogAppender.js'; +import { NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; const eventPrefix = 'monacoworkbench'; @@ -224,6 +226,8 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); + socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, new NativeMcpDiscoveryHelperChannel((ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); + const remoteFileSystemChannel = disposables.add(new RemoteAgentFileSystemProviderChannel(logService, environmentService, configurationService)); socketServer.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, remoteFileSystemChannel); diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index fd6b783011f..357bb34c031 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { observableValue } from '../../../base/common/observable.js'; -import { IMcpRegistry, IMcpMessageTransport } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { McpConnectionState, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; +import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostContext, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadMcp) @@ -19,20 +21,25 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { private _serverIdCounter = 0; private readonly _servers: Map = new Map(); + private readonly _collectionDefinitions = this._register(new DisposableMap; + dispose(): void; + }>()); constructor( - extHostContext: IExtHostContext, + private readonly _extHostContext: IExtHostContext, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, ) { super(); - const proxy = extHostContext.getProxy(ExtHostContext.ExtHostMcp); + const proxy = _extHostContext.getProxy(ExtHostContext.ExtHostMcp); this._register(this._mcpRegistry.registerDelegate({ canStart(collection, serverDefinition) { // todo: SSE MPC servers without a remote authority could be served from the renderer - if (collection.remoteAuthority !== extHostContext.remoteAuthority) { + if (collection.remoteAuthority !== _extHostContext.remoteAuthority) { return false; } - if (serverDefinition.launch.type === McpServerTransportType.Stdio && extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker) { + if (serverDefinition.launch.type === McpServerTransportType.Stdio && _extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker) { return false; } return true; @@ -52,6 +59,44 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { })); } + $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, serversDto: Dto[]): void { + const servers = serversDto.map((s): McpServerDefinition => ({ + ...s, + launch: s.launch.type === McpServerTransportType.Stdio + ? { ...s.launch, cwd: URI.revive(s.launch.cwd) } + : s.launch, + variableReplacement: s.variableReplacement && { + ...s.variableReplacement, + folder: s.variableReplacement.folder && { + ...s.variableReplacement.folder, + uri: URI.revive(s.variableReplacement.folder.uri), + }, + }, + })); + + const existing = this._collectionDefinitions.get(collection.id); + if (existing) { + existing.servers.set(servers, undefined); + } else { + const serverDefinitions = observableValue('mcpServers', servers); + const handle = this._mcpRegistry.registerCollection({ + ...collection, + remoteAuthority: this._extHostContext.remoteAuthority, + serverDefinitions, + }); + + this._collectionDefinitions.set(collection.id, { + fromExtHost: collection, + servers: serverDefinitions, + dispose: () => handle.dispose(), + }); + } + } + + $deleteMcpCollection(collectionId: string): void { + this._collectionDefinitions.deleteAndDispose(collectionId); + } + $onDidChangeState(id: number, update: McpConnectionState): void { this._servers.get(id)?.state.set(update, undefined); @@ -59,9 +104,11 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { this._servers.delete(id); } } + $onDidPublishLog(id: number, log: string): void { this._servers.get(id)?.pushLog(log); } + $onDidReceiveMessage(id: number, message: string): void { this._servers.get(id)?.pushMessage(message); } diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 8b54b9e5a1d..d96bc0160c2 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -10,7 +10,7 @@ import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { ExtensionsRegistry, IExtensionPointUser } from '../../services/extensions/common/extensionsRegistry.js'; import { IConfigurationNode, IConfigurationRegistry, Extensions, validateProperty, ConfigurationScope, OVERRIDE_PROPERTY_REGEX, IConfigurationDefaults, configurationDefaultsSchemaId, IConfigurationDelta, getDefaultValue, getAllConfigurationProperties, parseScope } from '../../../platform/configuration/common/configurationRegistry.js'; import { IJSONContributionRegistry, Extensions as JSONExtensions } from '../../../platform/jsonschemas/common/jsonContributionRegistry.js'; -import { workspaceSettingsSchemaId, launchSchemaId, tasksSchemaId } from '../../services/configuration/common/configuration.js'; +import { workspaceSettingsSchemaId, launchSchemaId, tasksSchemaId, mcpSchemaId } from '../../services/configuration/common/configuration.js'; import { isObject, isUndefined } from '../../../base/common/types.js'; import { ExtensionIdentifierMap, IExtensionManifest } from '../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../base/common/collections.js'; @@ -367,6 +367,20 @@ jsonRegistry.registerSchema('vscode://schemas/workspaceConfig', { description: nls.localize('workspaceConfig.tasks.description', "Workspace task configurations"), $ref: tasksSchemaId }, + 'mcp': { + type: 'object', + default: { + inputs: [], + servers: { + 'mcp-server-time': { + command: 'python', + args: ['-m', 'mcp_server_time', '--local-timezone=America/Los_Angeles'] + } + } + }, + description: nls.localize('workspaceConfig.mcp.description', "Model Context Protocol server configurations"), + $ref: mcpSchemaId + }, 'extensions': { type: 'object', default: {}, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b4b30908019..0d1b41e637a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -60,7 +60,7 @@ import { IChatRequestVariableValue } from '../../contrib/chat/common/chatVariabl import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsChangeEvent } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IToolData, IToolInvocation, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; -import { McpServerLaunch, McpConnectionState } from '../../contrib/mcp/common/mcpTypes.js'; +import { McpServerLaunch, McpConnectionState, McpCollectionDefinition, McpServerDefinition } from '../../contrib/mcp/common/mcpTypes.js'; import * as notebookCommon from '../../contrib/notebook/common/notebookCommon.js'; import { CellExecutionUpdateType } from '../../contrib/notebook/common/notebookExecutionService.js'; import { ICellExecutionComplete, ICellExecutionStateUpdate } from '../../contrib/notebook/common/notebookExecutionStateService.js'; @@ -2978,6 +2978,8 @@ export interface MainThreadMcpShape { $onDidChangeState(id: number, state: McpConnectionState): void; $onDidPublishLog(id: number, log: string): void; $onDidReceiveMessage(id: number, message: string): void; + $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: Dto[]): void; + $deleteMcpCollection(collectionId: string): void; } export interface ExtHostLocalizationShape { diff --git a/src/vs/workbench/api/node/extHostMpcNode.ts b/src/vs/workbench/api/node/extHostMpcNode.ts index 1277212df65..45cacedc7c2 100644 --- a/src/vs/workbench/api/node/extHostMpcNode.ts +++ b/src/vs/workbench/api/node/extHostMpcNode.ts @@ -10,6 +10,7 @@ import { StreamSplitter } from '../../../base/node/nodeStreams.js'; import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { ExtHostMcpService } from '../common/extHostMcp.js'; import { IExtHostRpcService } from '../common/extHostRpcService.js'; +import { homedir } from 'os'; export class NodeExtHostMpcService extends ExtHostMcpService { constructor( @@ -61,7 +62,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService { try { child = spawn(launch.command, launch.args, { stdio: 'pipe', - cwd: URI.revive(launch.cwd).fsPath, + cwd: launch.cwd ? URI.revive(launch.cwd).fsPath : homedir(), signal: abortCtrl.signal, env: { ...process.env, diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e951101ecb3..005c3152255 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -100,6 +100,8 @@ import { ChatStatusBarEntry } from './chatStatus.js'; import product from '../../../../platform/product/common/product.js'; import { Event } from '../../../../base/common/event.js'; import { ChatEditingNotebookFileSystemProviderContrib } from './chatEditing/notebook/chatEditingNotebookFileSystemProvider.js'; +import { mcpConfigurationSection, mcpSchemaExampleServers } from '../../mcp/common/mcpConfiguration.js'; +import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -199,6 +201,15 @@ configurationRegistry.registerConfiguration({ default: product.quality !== 'stable', tags: ['experimental', 'onExp'] }, + [mcpConfigurationSection]: { + type: 'object', + default: { + inputs: [], + servers: mcpSchemaExampleServers, + }, + description: nls.localize('workspaceConfig.mcp.description', "Model Context Protocol server configurations"), + $ref: mcpSchemaId + }, [PromptsConfig.CONFIG_KEY]: { type: 'boolean', title: nls.localize( diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index 0e31680a731..3a800f0665f 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -3,17 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import * as jsonContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; +import { ConfigMcpDiscovery } from '../common/discovery/configMcpDiscovery.js'; +import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; +import { RemoteNativeMpcDiscovery } from '../common/discovery/nativeMcpRemoteDiscovery.js'; +import { mcpServerSchema } from '../common/mcpConfiguration.js'; import { McpRegistry } from '../common/mcpRegistry.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; import { McpService } from '../common/mcpService.js'; import { IMcpService } from '../common/mcpTypes.js'; +import { McpDiscovery } from './mcpDiscovery.js'; import './mcpCommands.js'; -import { McpDiscovery } from './mcpDiscovery.js'; registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); +mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); +mcpDiscoveryRegistry.register(new SyncDescriptor(ConfigMcpDiscovery)); + registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); + +const jsonRegistry = Registry.as(jsonContributionRegistry.Extensions.JSONContribution); +jsonRegistry.registerSchema(mcpSchemaId, mcpServerSchema); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index af168e5d07a..727aaae21b6 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { groupBy } from '../../../../base/common/collections.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { ILocalizedString, localize, localize2 } from '../../../../nls.js'; @@ -37,26 +38,31 @@ class ListMcpServerCommand extends Action2 { type ItemType = { id: string } & IQuickPickItem; const store = new DisposableStore(); - const pick = quickInput.createQuickPick(); + const pick = quickInput.createQuickPick({ useSeparators: true }); store.add(pick); store.add(autorun(reader => { - const servers = mcpService.servers.read(reader); - pick.items = servers.map(server => ({ - id: server.definition.id, - label: server.definition.label, - description: McpConnectionState.toString(server.state.read(reader)), - })); + const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.order || 0) - (b.collection.order || 0)), s => s.collection.id); + pick.items = Object.values(servers).flatMap(servers => { + return [ + { type: 'separator', label: servers[0].collection.label, id: servers[0].collection.id }, + ...servers.map(server => ({ + id: server.definition.id, + label: server.definition.label, + description: McpConnectionState.toString(server.state.read(reader)), + })), + ]; + }); })); const picked = await new Promise(resolve => { - pick.onDidAccept(() => { + store.add(pick.onDidAccept(() => { resolve(pick.activeItems[0]); - }); - pick.onDidHide(() => { + })); + store.add(pick.onDidHide(() => { resolve(undefined); - }); + })); pick.show(); }); @@ -130,6 +136,7 @@ class McpServerOptionsCommand extends Action2 { switch (pick.action) { case 'start': await server.start(); + server.showOutput(); break; case 'stop': await server.stop(); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts index 87b575e5a1a..f9d339b7962 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts @@ -3,77 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { observableValue } from '../../../../base/common/observable.js'; -import { Platform, platform } from '../../../../base/common/platform.js'; -import { URI } from '../../../../base/common/uri.js'; -import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { McpServerDefinition, McpServerTransportType } from '../common/mcpTypes.js'; +import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; -export class McpDiscovery implements IWorkbenchContribution { +export class McpDiscovery extends Disposable implements IWorkbenchContribution { public static readonly ID = 'workbench.contrib.mcp.discovery'; constructor( - @INativeEnvironmentService environmentService: INativeEnvironmentService, - @IFileService fileService: IFileService, - @IMcpRegistry mcpRegistry: IMcpRegistry + @IInstantiationService instantiationService: IInstantiationService, ) { - - const homeDir = environmentService.userHome; - //#region hacked in reading for claude desktop config - let configPath: URI; - if (platform === Platform.Windows) { - const appData = /* process.env.APPDATA ||*/ URI.joinPath(environmentService.userHome, 'AppData', 'Roaming'); - configPath = URI.joinPath(appData, 'Claude', 'claude_desktop_config.json'); - } else if (platform === Platform.Mac) { - configPath = URI.joinPath(environmentService.userHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); - } else { - const configDir = /*process.env.XDG_CONFIG_HOME || */URI.joinPath(environmentService.userHome, '.config'); - configPath = URI.joinPath(configDir, 'Claude', 'claude_desktop_config.json'); + super(); + for (const discovery of mcpDiscoveryRegistry.getAll()) { + const inst = this._register(instantiationService.createInstance(discovery)); + inst.start(); } - - - fileService.readFile(configPath).then((content) => { - let parsed: { - mcpServers: Record; - }>; - }; - - try { - parsed = JSON.parse(content.value.toString()); - } catch { - return; - } - const definitions = Object.entries(parsed.mcpServers).map(([name, server]): McpServerDefinition => { - return { - id: `claude_desktop_config.${name}`, - label: name, - launch: { - type: McpServerTransportType.Stdio, - args: server.args || [], - command: server.command, - env: server.env || {}, - cwd: homeDir, - } - }; - }); - - mcpRegistry.registerCollection({ - id: 'claude_desktop_config', - label: 'Claude Desktop', - isTrustedByDefault: false, - remoteAuthority: null, - scope: StorageScope.APPLICATION, - serverDefinitions: observableValue(this, definitions), - }); - }, () => { }); - - //#endregion } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts new file mode 100644 index 00000000000..aea862d7fb1 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { equals as arrayEquals } from '../../../../../base/common/arrays.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { localize } from '../../../../../nls.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { IMcpConfiguration, mcpConfigurationSection } from '../mcpConfiguration.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../mcpTypes.js'; +import { IMcpDiscovery } from './mcpDiscovery.js'; + + +/** + * Discovers MCP servers based on various config sources. + */ +export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { + private readonly configSources: { + key: 'userLocalValue' | 'userRemoteValue' | 'workspaceValue'; + label: string; + serverDefinitions: ISettableObservable; + scope: StorageScope; + target: ConfigurationTarget; + disposable: MutableDisposable; + order: number; + remoteAuthority?: string; + }[]; + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IProductService productService: IProductService, + @ILabelService labelService: ILabelService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + ) { + super(); + const remoteLabel = environmentService.remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, environmentService.remoteAuthority) : 'Remote'; + this.configSources = [ + { + key: 'userLocalValue', + target: ConfigurationTarget.USER_LOCAL, + label: localize('mcp.configuration.userLocalValue', 'Global in {0}', productService.nameShort), + serverDefinitions: observableValue(this, []), + scope: StorageScope.PROFILE, + disposable: this._register(new MutableDisposable()), + order: McpCollectionSortOrder.User, + }, + { + key: 'userRemoteValue', + target: ConfigurationTarget.USER_REMOTE, + label: localize('mcp.configuration.userRemoteValue', 'From {0}', remoteLabel), + serverDefinitions: observableValue(this, []), + scope: StorageScope.PROFILE, + disposable: this._register(new MutableDisposable()), + remoteAuthority: environmentService.remoteAuthority, + order: McpCollectionSortOrder.User + McpCollectionSortOrder.RemotePenalty, + }, + { + key: 'workspaceValue', + target: ConfigurationTarget.WORKSPACE, + label: localize('mcp.configuration.workspaceValue', 'From your workspace'), + serverDefinitions: observableValue(this, []), + scope: StorageScope.WORKSPACE, + disposable: this._register(new MutableDisposable()), + order: McpCollectionSortOrder.Workspace, + + }, + ]; + } + + public start() { + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(mcpConfigurationSection)) { + this.sync(); + } + })); + + this.sync(); + } + + private sync() { + const configurationKey = this._configurationService.inspect(mcpConfigurationSection); + + for (const src of this.configSources) { + const collectionId = `mcp.config.${src.key}`; + const value = configurationKey[src.key]; + const nextDefinitions = Object.entries(value?.servers || {}).map(([name, value]): McpServerDefinition => ({ + id: `${collectionId}.${name}`, + label: name, + launch: { + type: McpServerTransportType.Stdio, + args: value.args, + command: value.command, + env: value.env, + cwd: undefined, + }, + variableReplacement: { + section: mcpConfigurationSection, + target: src.target, + } + })); + + if (arrayEquals(nextDefinitions, src.serverDefinitions.get(), McpServerDefinition.equals)) { + continue; + } + + if (!nextDefinitions.length) { + src.disposable.clear(); + } else { + src.serverDefinitions.set(nextDefinitions, undefined); + src.disposable.value ??= this._mcpRegistry.registerCollection({ + id: collectionId, + label: src.label, + order: src.order, + remoteAuthority: src.remoteAuthority || null, + serverDefinitions: src.serverDefinitions, + isTrustedByDefault: true, + scope: src.scope, + }); + } + } + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/mcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/mcpDiscovery.ts new file mode 100644 index 00000000000..a6a091cfaa7 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/mcpDiscovery.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { SyncDescriptor0 } from '../../../../../platform/instantiation/common/descriptors.js'; + + +export interface IMcpDiscovery extends IDisposable { + start(): void; +} + +class McpDiscoveryRegistry { + private readonly _discovery: SyncDescriptor0[] = []; + + register(discovery: SyncDescriptor0): void { + this._discovery.push(discovery); + } + + getAll(): readonly SyncDescriptor0[] { + return this._discovery; + } +} + +export const mcpDiscoveryRegistry = new McpDiscoveryRegistry(); + + + diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts new file mode 100644 index 00000000000..3e291cedeb9 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition } from '../mcpTypes.js'; +import { IMcpDiscovery } from './mcpDiscovery.js'; +import { ClaudeDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; + +/** + * Base class that discovers MCP servers on a filesystem, outside of the ones + * defined in VS Code settings. + */ +export abstract class FilesystemMpcDiscovery extends Disposable implements IMcpDiscovery { + private readonly adapters: readonly NativeMpcDiscoveryAdapter[]; + private suffix = ''; + + constructor( + remoteAuthority: string | null, + @ILabelService labelService: ILabelService, + @IFileService private readonly fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, + ) { + super(); + if (remoteAuthority) { + this.suffix = ' ' + localize('onRemoteLabel', ' on {0}', labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority)); + } + + this.adapters = [ + instantiationService.createInstance(ClaudeDesktopMpcDiscoveryAdapter, remoteAuthority) + ]; + } + + public abstract start(): void; + + protected setDetails(detailsDto: Dto | undefined) { + if (!detailsDto) { + return; + } + + const details: INativeMcpDiscoveryData = { + ...detailsDto, + homedir: URI.revive(detailsDto.homedir), + xdgHome: detailsDto.xdgHome ? URI.revive(detailsDto.xdgHome) : undefined, + winAppData: detailsDto.winAppData ? URI.revive(detailsDto.winAppData) : undefined, + }; + + for (const adapter of this.adapters) { + const file = adapter.getFilePath(details); + if (!file) { + continue; + } + + const collection = { + id: adapter.id, + label: adapter.label + this.suffix, + remoteAuthority: adapter.remoteAuthority, + scope: StorageScope.PROFILE, + isTrustedByDefault: false, + serverDefinitions: observableValue(this, []), + order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemotePenalty : 0) + } satisfies McpCollectionDefinition; + + const collectionRegistration = this._register(new MutableDisposable()); + const updateFile = async () => { + let definitions: McpServerDefinition[] = []; + try { + const contents = await this.fileService.readFile(file); + definitions = adapter.adaptFile(contents.value, details) || []; + } catch { + // ignored + } + if (!definitions.length) { + collectionRegistration.clear(); + } else { + collection.serverDefinitions.set(definitions, undefined); + if (!collectionRegistration.value) { + collectionRegistration.value = this.mcpRegistry.registerCollection(collection); + } + } + }; + + const watcher = this._register(this.fileService.createWatcher(file, { recursive: false, excludes: [] })); + const throttler = this._register(new RunOnceScheduler(updateFile, 500)); + this._register(watcher.onDidChange(() => throttler.schedule())); + updateFile(); + } + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts new file mode 100644 index 00000000000..fddd0b699f5 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Platform } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../mcpTypes.js'; + +export interface NativeMpcDiscoveryAdapter { + readonly remoteAuthority: string | null; + readonly id: string; + readonly label: string; + readonly order: number; + + getFilePath(details: INativeMcpDiscoveryData): URI | undefined; + adaptFile(contents: VSBuffer, details: INativeMcpDiscoveryData): McpServerDefinition[] | undefined; +} + +export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapter { + public readonly id: string = `claude-desktop.${this.remoteAuthority}`; + public readonly label: string = 'Claude Desktop'; + public readonly order = McpCollectionSortOrder.Filesystem; + + constructor(public readonly remoteAuthority: string | null) { } + + getFilePath({ platform, winAppData, homedir }: INativeMcpDiscoveryData): URI | undefined { + if (platform === Platform.Windows) { + const appData = winAppData || URI.joinPath(homedir, 'AppData', 'Roaming'); + return URI.joinPath(appData, 'Claude', 'claude_desktop_config.json'); + } else if (platform === Platform.Mac) { + return URI.joinPath(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } else { + const configDir = /*process.env.XDG_CONFIG_HOME || */URI.joinPath(homedir, '.config'); + return URI.joinPath(configDir, 'Claude', 'claude_desktop_config.json'); + } + } + + adaptFile(contents: VSBuffer, { homedir }: INativeMcpDiscoveryData): McpServerDefinition[] | undefined { + let parsed: { + mcpServers: Record; + }>; + }; + + try { + parsed = JSON.parse(contents.toString()); + } catch { + return; + } + return Object.entries(parsed.mcpServers).map(([name, server]): McpServerDefinition => { + return { + id: `claude_desktop_config.${name}`, + label: name, + launch: { + type: McpServerTransportType.Stdio, + args: server.args || [], + command: server.command, + env: server.env || {}, + cwd: homedir, + } + }; + }); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts new file mode 100644 index 00000000000..0eacf9c4184 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INativeMcpDiscoveryData, NativeMcpDiscoveryHelperChannelName } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { FilesystemMpcDiscovery } from './nativeMcpDiscoveryAbstract.js'; + +/** + * Discovers MCP servers on the remote filesystem, if any. + */ +export class RemoteNativeMpcDiscovery extends FilesystemMpcDiscovery { + constructor( + @IRemoteAgentService private readonly remoteAgent: IRemoteAgentService, + @ILogService private readonly logService: ILogService, + @ILabelService labelService: ILabelService, + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @IMcpRegistry mcpRegistry: IMcpRegistry, + ) { + super(remoteAgent.getConnection()?.remoteAuthority || null, labelService, fileService, instantiationService, mcpRegistry); + } + + public override async start() { + const connection = this.remoteAgent.getConnection(); + if (!connection) { + return this.setDetails(undefined); + } + + connection.withChannel(NativeMcpDiscoveryHelperChannelName, channel => + channel.call>('load', undefined)) + + .then( + data => this.setDetails(data), + err => { + this.logService.warn('Error getting remote process MCP environment', err); + this.setDetails(undefined); + } + ); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts new file mode 100644 index 00000000000..78fb48d097c --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { localize } from '../../../../nls.js'; +import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; +import { inputsSchema } from '../../../services/configurationResolver/common/configurationResolverSchema.js'; + + +const mcpSchemaExampleServer = { + command: 'node', + args: ['my-mcp-server.js'], + env: {}, +}; + +export const mcpConfigurationSection = 'mcp'; + +export interface IMcpConfiguration { + inputs: unknown[]; + servers: Record; +} + +export interface IMcpConfigurationServer { + command: string; + args: readonly string[]; + env: Record; +} + +export const mcpSchemaExampleServers = { + 'mcp-server-time': { + command: 'python', + args: ['-m', 'mcp_server_time', '--local-timezone=America/Los_Angeles'], + env: {}, + } +}; + +export const mcpServerSchema: IJSONSchema = { + id: mcpSchemaId, + type: 'object', + title: localize('app.mcp.json.title', "Model Context Protocol Servers"), + allowTrailingCommas: true, + allowComments: true, + properties: { + servers: { + examples: [mcpSchemaExampleServers], + additionalProperties: { + type: 'object', + additionalProperties: false, + examples: [mcpSchemaExampleServer], + properties: { + command: { + type: 'string', + description: localize('app.mcp.json.command', "The command to run the server.") + }, + args: { + type: 'array', + description: localize('app.mcp.args.command', "Arguments passed to the server."), + items: { + type: 'string' + }, + }, + env: { + description: localize('app.mcp.env.command', "Environment variables passed to the server."), + additionalProperties: { + anyOf: [ + { type: 'null' }, + { type: 'string' }, + { type: 'number' }, + ] + } + }, + } + } + }, + inputs: inputsSchema.definitions!.inputs + } +}; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index d2d3e7fb10d..568b51ad018 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -13,7 +13,7 @@ import { localize } from '../../../../nls.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { MCP } from './modelContextProtocol.js'; @@ -34,9 +34,26 @@ export interface McpCollectionDefinition { readonly isTrustedByDefault: boolean; /** Scope where associated collection info should be stored. */ readonly scope: StorageScope; + /** Sort order of the collection. */ + readonly order?: number; +} + +export const enum McpCollectionSortOrder { + Workspace = 0, + User = 100, + Filesystem = 200, + + RemotePenalty = 50, } export namespace McpCollectionDefinition { + export interface FromExtHost { + readonly id: string; + readonly label: string; + readonly isTrustedByDefault: boolean; + readonly scope: StorageScope; + } + export function equals(a: McpCollectionDefinition, b: McpCollectionDefinition): boolean { return a.id === b.id && a.remoteAuthority === b.remoteAuthority @@ -55,7 +72,7 @@ export interface McpServerDefinition { /** If set, allows configuration variables to be resolved in the {@link launch} with the given context */ readonly variableReplacement?: { section?: string; // e.g. 'mcp' - folder?: IWorkspaceFolder; + folder?: IWorkspaceFolderData; target?: ConfigurationTarget; }; } @@ -114,7 +131,7 @@ export const enum McpServerTransportType { */ export interface McpServerTransportStdio { readonly type: McpServerTransportType.Stdio; - readonly cwd: URI; + readonly cwd: URI | undefined; readonly command: string; readonly args: readonly string[]; readonly env: Record; diff --git a/src/vs/workbench/contrib/mcp/electron-sandbox/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/electron-sandbox/mcp.contribution.ts new file mode 100644 index 00000000000..efdf3ee192d --- /dev/null +++ b/src/vs/workbench/contrib/mcp/electron-sandbox/mcp.contribution.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; +import { NativeMcpDiscovery } from './nativeMpcDiscovery.js'; + +mcpDiscoveryRegistry.register(new SyncDescriptor(NativeMcpDiscovery)); diff --git a/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts new file mode 100644 index 00000000000..7f623f83643 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INativeMcpDiscoveryData, NativeMcpDiscoveryHelperChannelName } from '../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; +import { FilesystemMpcDiscovery } from '../common/discovery/nativeMcpDiscoveryAbstract.js'; +import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; + +export class NativeMcpDiscovery extends FilesystemMpcDiscovery { + constructor( + @IMainProcessService private readonly mainProcess: IMainProcessService, + @ILogService private readonly logService: ILogService, + @ILabelService labelService: ILabelService, + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @IMcpRegistry mcpRegistry: IMcpRegistry, + ) { + super(null, labelService, fileService, instantiationService, mcpRegistry); + } + + public override start(): void { + this.mainProcess.getChannel(NativeMcpDiscoveryHelperChannelName) + .call>('load', undefined) + .then( + data => this.setDetails(data), + err => { + this.logService.warn('Error getting main process MCP environment', err); + this.setDetails(undefined); + } + ); + } +} diff --git a/src/vs/workbench/services/configuration/browser/configuration.ts b/src/vs/workbench/services/configuration/browser/configuration.ts index a3d2d34f58c..1ee88812ed5 100644 --- a/src/vs/workbench/services/configuration/browser/configuration.ts +++ b/src/vs/workbench/services/configuration/browser/configuration.ts @@ -11,7 +11,7 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { FileChangeType, FileChangesEvent, IFileService, whenProviderRegistered, FileOperationError, FileOperationResult, FileOperation, FileOperationEvent } from '../../../../platform/files/common/files.js'; import { ConfigurationModel, ConfigurationModelParser, ConfigurationParseOptions, UserSettings } from '../../../../platform/configuration/common/configurationModels.js'; import { WorkspaceConfigurationModelParser, StandaloneConfigurationModelParser } from '../common/configurationModels.js'; -import { TASKS_CONFIGURATION_KEY, FOLDER_SETTINGS_NAME, LAUNCH_CONFIGURATION_KEY, IConfigurationCache, ConfigurationKey, REMOTE_MACHINE_SCOPES, FOLDER_SCOPES, WORKSPACE_SCOPES, APPLY_ALL_PROFILES_SETTING, APPLICATION_SCOPES } from '../common/configuration.js'; +import { TASKS_CONFIGURATION_KEY, FOLDER_SETTINGS_NAME, LAUNCH_CONFIGURATION_KEY, IConfigurationCache, ConfigurationKey, REMOTE_MACHINE_SCOPES, FOLDER_SCOPES, WORKSPACE_SCOPES, APPLY_ALL_PROFILES_SETTING, APPLICATION_SCOPES, MCP_CONFIGURATION_KEY } from '../common/configuration.js'; import { IStoredWorkspaceFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { WorkbenchState, IWorkspaceFolder, IWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { ConfigurationScope, Extensions, IConfigurationRegistry, OVERRIDE_PROPERTY_REGEX } from '../../../../platform/configuration/common/configurationRegistry.js'; @@ -1068,7 +1068,7 @@ export class FolderConfiguration extends Disposable { private createFileServiceBasedConfiguration(fileService: IFileService, uriIdentityService: IUriIdentityService, logService: ILogService) { const settingsResource = uriIdentityService.extUri.joinPath(this.configurationFolder, `${FOLDER_SETTINGS_NAME}.json`); - const standAloneConfigurationResources: [string, URI][] = [TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY].map(name => ([name, uriIdentityService.extUri.joinPath(this.configurationFolder, `${name}.json`)])); + const standAloneConfigurationResources: [string, URI][] = [TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY, MCP_CONFIGURATION_KEY].map(name => ([name, uriIdentityService.extUri.joinPath(this.configurationFolder, `${name}.json`)])); return new FileServiceBasedConfiguration(this.configurationFolder.toString(), settingsResource, standAloneConfigurationResources, { scopes: this.scopes, skipRestricted: this.isUntrusted() }, fileService, uriIdentityService, logService); } diff --git a/src/vs/workbench/services/configuration/common/configuration.ts b/src/vs/workbench/services/configuration/common/configuration.ts index 12e011d3977..56155f1b1b0 100644 --- a/src/vs/workbench/services/configuration/common/configuration.ts +++ b/src/vs/workbench/services/configuration/common/configuration.ts @@ -23,6 +23,7 @@ export const workspaceSettingsSchemaId = 'vscode://schemas/settings/workspace'; export const folderSettingsSchemaId = 'vscode://schemas/settings/folder'; export const launchSchemaId = 'vscode://schemas/launch'; export const tasksSchemaId = 'vscode://schemas/tasks'; +export const mcpSchemaId = 'vscode://schemas/mcp'; export const APPLICATION_SCOPES = [ConfigurationScope.APPLICATION, ConfigurationScope.APPLICATION_MACHINE]; export const PROFILE_SCOPES = [ConfigurationScope.MACHINE, ConfigurationScope.WINDOW, ConfigurationScope.RESOURCE, ConfigurationScope.LANGUAGE_OVERRIDABLE, ConfigurationScope.MACHINE_OVERRIDABLE]; @@ -34,12 +35,15 @@ export const FOLDER_SCOPES = [ConfigurationScope.RESOURCE, ConfigurationScope.LA export const TASKS_CONFIGURATION_KEY = 'tasks'; export const LAUNCH_CONFIGURATION_KEY = 'launch'; +export const MCP_CONFIGURATION_KEY = 'mcp'; export const WORKSPACE_STANDALONE_CONFIGURATIONS = Object.create(null); WORKSPACE_STANDALONE_CONFIGURATIONS[TASKS_CONFIGURATION_KEY] = `${FOLDER_CONFIG_FOLDER_NAME}/${TASKS_CONFIGURATION_KEY}.json`; WORKSPACE_STANDALONE_CONFIGURATIONS[LAUNCH_CONFIGURATION_KEY] = `${FOLDER_CONFIG_FOLDER_NAME}/${LAUNCH_CONFIGURATION_KEY}.json`; +WORKSPACE_STANDALONE_CONFIGURATIONS[MCP_CONFIGURATION_KEY] = `${FOLDER_CONFIG_FOLDER_NAME}/${MCP_CONFIGURATION_KEY}.json`; export const USER_STANDALONE_CONFIGURATIONS = Object.create(null); USER_STANDALONE_CONFIGURATIONS[TASKS_CONFIGURATION_KEY] = `${TASKS_CONFIGURATION_KEY}.json`; +USER_STANDALONE_CONFIGURATIONS[MCP_CONFIGURATION_KEY] = `${MCP_CONFIGURATION_KEY}.json`; export type ConfigurationKey = { type: 'defaults' | 'user' | 'workspaces' | 'folder'; key: string }; diff --git a/src/vs/workbench/services/configuration/common/configurationEditing.ts b/src/vs/workbench/services/configuration/common/configurationEditing.ts index cd732b3ef0d..2ecea183965 100644 --- a/src/vs/workbench/services/configuration/common/configurationEditing.ts +++ b/src/vs/workbench/services/configuration/common/configurationEditing.ts @@ -13,7 +13,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITextFileService } from '../../textfile/common/textfiles.js'; import { IConfigurationUpdateOptions, IConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; -import { FOLDER_SETTINGS_PATH, WORKSPACE_STANDALONE_CONFIGURATIONS, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY, USER_STANDALONE_CONFIGURATIONS, TASKS_DEFAULT, FOLDER_SCOPES, IWorkbenchConfigurationService, APPLICATION_SCOPES } from './configuration.js'; +import { FOLDER_SETTINGS_PATH, WORKSPACE_STANDALONE_CONFIGURATIONS, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY, USER_STANDALONE_CONFIGURATIONS, TASKS_DEFAULT, FOLDER_SCOPES, IWorkbenchConfigurationService, APPLICATION_SCOPES, MCP_CONFIGURATION_KEY } from './configuration.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../platform/files/common/files.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope, keyFromOverrideIdentifiers, OVERRIDE_PROPERTY_REGEX } from '../../../../platform/configuration/common/configurationRegistry.js'; @@ -279,7 +279,8 @@ export class ConfigurationEditing { private onInvalidConfigurationError(error: ConfigurationEditingError, operation: IConfigurationEditOperation,): void { const openStandAloneConfigurationActionLabel = operation.workspaceStandAloneConfigurationKey === TASKS_CONFIGURATION_KEY ? nls.localize('openTasksConfiguration', "Open Tasks Configuration") : operation.workspaceStandAloneConfigurationKey === LAUNCH_CONFIGURATION_KEY ? nls.localize('openLaunchConfiguration', "Open Launch Configuration") - : null; + : operation.workspaceStandAloneConfigurationKey === MCP_CONFIGURATION_KEY ? nls.localize('openMcpConfiguration', "Open MCP Configuration") + : null; if (openStandAloneConfigurationActionLabel) { this.notificationService.prompt(Severity.Error, error.message, [{ @@ -384,6 +385,9 @@ export class ConfigurationEditing { if (operation.workspaceStandAloneConfigurationKey === LAUNCH_CONFIGURATION_KEY) { return nls.localize('errorInvalidLaunchConfiguration', "Unable to write into the launch configuration file. Please open it to correct errors/warnings in it and try again."); } + if (operation.workspaceStandAloneConfigurationKey === MCP_CONFIGURATION_KEY) { + return nls.localize('errorInvalidMCPConfiguration', "Unable to write into the MCP configuration file. Please open it to correct errors/warnings in it and try again."); + } switch (target) { case EditableConfigurationTarget.USER_LOCAL: return nls.localize('errorInvalidConfiguration', "Unable to write into user settings. Please open the user settings to correct errors/warnings in it and try again."); @@ -412,6 +416,9 @@ export class ConfigurationEditing { if (operation.workspaceStandAloneConfigurationKey === LAUNCH_CONFIGURATION_KEY) { return nls.localize('errorLaunchConfigurationFileDirty', "Unable to write into launch configuration file because the file has unsaved changes. Please save it first and then try again."); } + if (operation.workspaceStandAloneConfigurationKey === MCP_CONFIGURATION_KEY) { + return nls.localize('errorMCPConfigurationFileDirty', "Unable to write into MCP configuration file because the file has unsaved changes. Please save it first and then try again."); + } switch (target) { case EditableConfigurationTarget.USER_LOCAL: return nls.localize('errorConfigurationFileDirty', "Unable to write into user settings because the file has unsaved changes. Please save the user settings file first and then try again."); @@ -440,6 +447,9 @@ export class ConfigurationEditing { if (operation.workspaceStandAloneConfigurationKey === LAUNCH_CONFIGURATION_KEY) { return nls.localize('errorLaunchConfigurationFileModifiedSince', "Unable to write into launch configuration file because the content of the file is newer."); } + if (operation.workspaceStandAloneConfigurationKey === MCP_CONFIGURATION_KEY) { + return nls.localize('errorMCPConfigurationFileModifiedSince', "Unable to write into MCP configuration file because the content of the file is newer."); + } switch (target) { case EditableConfigurationTarget.USER_LOCAL: return nls.localize('errorConfigurationFileModifiedSince', "Unable to write into user settings because the content of the file is newer."); diff --git a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index af059baa74b..fde5e04ed5d 100644 --- a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts +++ b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts @@ -16,13 +16,13 @@ import { ConfigurationTarget, IConfigurationOverrides, IConfigurationService } f import { ILabelService } from '../../../../platform/label/common/label.js'; import { IInputOptions, IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, IWorkspaceFolderData, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js'; -import { ConfiguredInput } from '../common/configurationResolver.js'; -import { AbstractVariableResolverService } from '../common/variableResolver.js'; import { IEditorService } from '../../editor/common/editorService.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IPathService } from '../../path/common/pathService.js'; +import { ConfiguredInput } from '../common/configurationResolver.js'; +import { AbstractVariableResolverService } from '../common/variableResolver.js'; const LAST_INPUT_STORAGE_KEY = 'configResolveInputLru'; const LAST_INPUT_CACHE_SIZE = 5; @@ -138,7 +138,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR }, labelService, pathService.userHome().then(home => home.path), envVariablesPromise); } - public override async resolveWithInteractionReplace(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { + public override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { // resolve any non-interactive variables and any contributed variables config = await this.resolveAnyAsync(folder, config); @@ -155,7 +155,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR }); } - public override async resolveWithInteraction(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined> { + public override async resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined> { // resolve any non-interactive variables and any contributed variables const resolved = await this.resolveAnyMap(folder, config); config = resolved.newConfig; @@ -191,7 +191,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR * * @param variableToCommandMap Aliases for commands */ - private async resolveWithInputAndCommands(folder: IWorkspaceFolder | undefined, configuration: any, variableToCommandMap?: IStringDictionary, section?: string, target?: ConfigurationTarget): Promise | undefined> { + private async resolveWithInputAndCommands(folder: IWorkspaceFolderData | undefined, configuration: any, variableToCommandMap?: IStringDictionary, section?: string, target?: ConfigurationTarget): Promise | undefined> { if (!configuration) { return Promise.resolve(undefined); diff --git a/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts b/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts index e7486544888..ea661b6c21d 100644 --- a/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts +++ b/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts @@ -4,35 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import { IStringDictionary } from '../../../../base/common/collections.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; -import { IProcessEnvironment } from '../../../../base/common/platform.js'; import { ErrorNoTelemetry } from '../../../../base/common/errors.js'; +import { IProcessEnvironment } from '../../../../base/common/platform.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; export const IConfigurationResolverService = createDecorator('configurationResolverService'); export interface IConfigurationResolverService { readonly _serviceBrand: undefined; - resolveWithEnvironment(environment: IProcessEnvironment, folder: IWorkspaceFolder | undefined, value: string): Promise; + resolveWithEnvironment(environment: IProcessEnvironment, folder: IWorkspaceFolderData | undefined, value: string): Promise; - resolveAsync(folder: IWorkspaceFolder | undefined, value: string): Promise; - resolveAsync(folder: IWorkspaceFolder | undefined, value: string[]): Promise; - resolveAsync(folder: IWorkspaceFolder | undefined, value: IStringDictionary): Promise>; + resolveAsync(folder: IWorkspaceFolderData | undefined, value: string): Promise; + resolveAsync(folder: IWorkspaceFolderData | undefined, value: string[]): Promise; + resolveAsync(folder: IWorkspaceFolderData | undefined, value: IStringDictionary): Promise>; /** * Recursively resolves all variables in the given config and returns a copy of it with substituted values. * Command variables are only substituted if a "commandValueMapping" dictionary is given and if it contains an entry for the command. */ - resolveAnyAsync(folder: IWorkspaceFolder | undefined, config: any, commandValueMapping?: IStringDictionary): Promise; + resolveAnyAsync(folder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise; /** * Recursively resolves all variables in the given config. * Returns a copy of it with substituted values and a map of variables and their resolution. * Keys in the map will be of the format input:variableName or command:variableName. */ - resolveAnyMap(folder: IWorkspaceFolder | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }>; + resolveAnyMap(folder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }>; /** * Recursively resolves all variables (including commands and user input) in the given config and returns a copy of it with substituted values. @@ -41,13 +41,13 @@ export interface IConfigurationResolverService { * @param section For example, 'tasks' or 'debug'. Used for resolving inputs. * @param variables Aliases for commands. */ - resolveWithInteractionReplace(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise; + resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise; /** * Similar to resolveWithInteractionReplace, except without the replace. Returns a map of variables and their resolution. * Keys in the map will be of the format input:variableName or command:variableName. */ - resolveWithInteraction(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined>; + resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined>; /** * Contributes a variable that can be resolved later. Consumers that use resolveAny, resolveWithInteraction, diff --git a/src/vs/workbench/services/configurationResolver/common/variableResolver.ts b/src/vs/workbench/services/configurationResolver/common/variableResolver.ts index b67a8fe1401..2c906b6a8cb 100644 --- a/src/vs/workbench/services/configurationResolver/common/variableResolver.ts +++ b/src/vs/workbench/services/configurationResolver/common/variableResolver.ts @@ -3,19 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as paths from '../../../../base/common/path.js'; -import * as process from '../../../../base/common/process.js'; -import * as types from '../../../../base/common/types.js'; -import * as objects from '../../../../base/common/objects.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; -import { IProcessEnvironment, isWindows, isMacintosh, isLinux } from '../../../../base/common/platform.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import { localize } from '../../../../nls.js'; -import { URI as uri } from '../../../../base/common/uri.js'; -import { IConfigurationResolverService, VariableError, VariableKind } from './configurationResolver.js'; -import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; +import * as objects from '../../../../base/common/objects.js'; +import * as paths from '../../../../base/common/path.js'; +import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; +import * as process from '../../../../base/common/process.js'; import { replaceAsync } from '../../../../base/common/strings.js'; +import * as types from '../../../../base/common/types.js'; +import { URI as uri } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { IConfigurationResolverService, VariableError, VariableKind } from './configurationResolver.js'; interface IVariableResolveContext { getFolderUri(folderName: string): uri | undefined; @@ -69,14 +69,14 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe return envVariables; } - public resolveWithEnvironment(environment: IProcessEnvironment, root: IWorkspaceFolder | undefined, value: string): Promise { + public resolveWithEnvironment(environment: IProcessEnvironment, root: IWorkspaceFolderData | undefined, value: string): Promise { return this.recursiveResolve({ env: this.prepareEnv(environment), userHome: undefined }, root ? root.uri : undefined, value); } - public async resolveAsync(root: IWorkspaceFolder | undefined, value: string): Promise; - public async resolveAsync(root: IWorkspaceFolder | undefined, value: string[]): Promise; - public async resolveAsync(root: IWorkspaceFolder | undefined, value: IStringDictionary): Promise>; - public async resolveAsync(root: IWorkspaceFolder | undefined, value: any): Promise { + public async resolveAsync(root: IWorkspaceFolderData | undefined, value: string): Promise; + public async resolveAsync(root: IWorkspaceFolderData | undefined, value: string[]): Promise; + public async resolveAsync(root: IWorkspaceFolderData | undefined, value: IStringDictionary): Promise>; + public async resolveAsync(root: IWorkspaceFolderData | undefined, value: any): Promise { const environment: Environment = { env: await this._envVariablesPromise, userHome: await this._userHomePromise @@ -84,7 +84,7 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe return this.recursiveResolve(environment, root ? root.uri : undefined, value); } - private async resolveAnyBase(workspaceFolder: IWorkspaceFolder | undefined, config: any, commandValueMapping?: IStringDictionary, resolvedVariables?: Map): Promise { + private async resolveAnyBase(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary, resolvedVariables?: Map): Promise { const result = objects.deepClone(config); @@ -110,21 +110,21 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe return this.recursiveResolve(environmentPromises, workspaceFolder ? workspaceFolder.uri : undefined, result, commandValueMapping, resolvedVariables); } - public async resolveAnyAsync(workspaceFolder: IWorkspaceFolder | undefined, config: any, commandValueMapping?: IStringDictionary): Promise { + public async resolveAnyAsync(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise { return this.resolveAnyBase(workspaceFolder, config, commandValueMapping); } - public async resolveAnyMap(workspaceFolder: IWorkspaceFolder | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }> { + public async resolveAnyMap(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }> { const resolvedVariables = new Map(); const newConfig = await this.resolveAnyBase(workspaceFolder, config, commandValueMapping, resolvedVariables); return { newConfig, resolvedVariables }; } - public resolveWithInteractionReplace(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary): Promise { + public resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary): Promise { throw new Error('resolveWithInteractionReplace not implemented.'); } - public resolveWithInteraction(folder: IWorkspaceFolder | undefined, config: any, section?: string, variables?: IStringDictionary): Promise | undefined> { + public resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary): Promise | undefined> { throw new Error('resolveWithInteraction not implemented.'); } diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 6cb01c72874..a71db4c88e5 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -175,6 +175,9 @@ import './contrib/encryption/electron-sandbox/encryption.contribution.js'; // Emergency Alert import './contrib/emergencyAlert/electron-sandbox/emergencyAlert.contribution.js'; +// MCP +import './contrib/mcp/electron-sandbox/mcp.contribution.js'; + //#endregion From c4318dc750d19f09eda6b4bc4ff692aad0bd7aeb Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 11 Mar 2025 10:22:51 +0100 Subject: [PATCH 006/255] more fixes in `McpServer#callOn` --- .../workbench/contrib/mcp/common/mcpServer.ts | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 31b3fc17719..af5adb110b6 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Sequencer } from '../../../../base/common/async.js'; +import { raceCancellationError, Sequencer } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../base/common/errors.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { autorun, autorunWithStore, derived, disposableObservableValue, IObservable, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -177,40 +176,42 @@ export class McpServer extends Disposable implements IMcpServer { * Helper function to call the function on the handler once it's online. The * connection started if it is not already. */ - public async callOn(fn: (handler: McpServerRequestHandler) => Promise, token?: CancellationToken): Promise { - const store = new DisposableStore(); - this.start(); // idempotent + public async callOn(fn: (handler: McpServerRequestHandler) => Promise, token: CancellationToken = CancellationToken.None): Promise { - try { - return await new Promise((resolve, reject) => { - if (token) { - store.add(token.onCancellationRequested(() => { - reject(new CancellationError()); - })); + await this.start(); // idempotent + + let ranOnce = false; + let d: IDisposable; + + const callPromise = new Promise((resolve, reject) => { + + d = autorun(reader => { + const connection = this._connection.read(reader); + if (!connection || ranOnce) { + return; } - store.add(autorun(reader => { - const connection = this._connection.read(reader); - if (!connection) { + + const handler = connection.handler.read(reader); + if (!handler) { + const state = connection.state.read(reader); + if (state.state === McpConnectionState.Kind.Error) { + reject(new McpConnectionFailedError(`MCP server could not be started: ${state.message}`)); + return; + } else if (state.state === McpConnectionState.Kind.Stopped) { + reject(new McpConnectionFailedError('MCP server has stopped')); + return; + } else { + // keep waiting for handler return; } + } - const handler = connection.handler.read(reader); - if (handler) { - resolve(fn(handler)); - store.dispose(); // aggressive dispose to prevent multiple racey calls - } else { - const state = connection.state.read(reader); - if (state.state === McpConnectionState.Kind.Error) { - reject(new McpConnectionFailedError(`MCP server could not be started: ${state.message}`)); - } else { - reject(new McpConnectionFailedError('MCP server has stopped')); - } - } - })); + resolve(fn(handler)); + ranOnce = true; // aggressive prevent multiple racey calls, don't dispose because autorun is sync }); - } finally { - store.dispose(); - } + }); + + return raceCancellationError(callPromise, token).finally(() => d.dispose()); } } From ef0f96c1756611f80977dffb87b89f8ef4c627dd Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 11 Mar 2025 12:19:04 +0100 Subject: [PATCH 007/255] allow to enable/disable MCP tools from chat input --- .../browser/menuEntryActionViewItem.ts | 2 +- .../contrib/chat/browser/chatInputPart.ts | 11 -- .../contrib/chat/browser/media/chat.css | 11 +- .../contrib/mcp/browser/mcp.contribution.ts | 9 +- .../contrib/mcp/browser/mcpCommands.ts | 174 ++++++++++++++++-- .../workbench/contrib/mcp/common/mcpServer.ts | 8 + .../contrib/mcp/common/mcpService.ts | 18 +- .../workbench/contrib/mcp/common/mcpTypes.ts | 5 + 8 files changed, 206 insertions(+), 32 deletions(-) diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 14ef06a50a5..2e86c3bcdea 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -167,7 +167,7 @@ function fillInActions( export interface IMenuEntryActionViewItemOptions { draggable?: boolean; - keybinding?: string; + keybinding?: string | null; hoverDelegate?: IHoverDelegate; keybindingNotRenderedWithLabel?: boolean; } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index a1b75caba23..707265a92e0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -21,7 +21,6 @@ import { HistoryNavigator2 } from '../../../../base/common/history.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; -import { autorun } from '../../../../base/common/observable.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; @@ -68,7 +67,6 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/edit import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { IMcpService } from '../../mcp/common/mcpTypes.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatEditingSession } from '../common/chatEditingService.js'; @@ -364,7 +362,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @ILabelService private readonly labelService: ILabelService, @IChatVariablesService private readonly variableService: IChatVariablesService, @IChatAgentService private readonly chatAgentService: IChatAgentService, - @IMcpService private readonly mcpService: IMcpService ) { super(); @@ -739,7 +736,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attachment-toolbar@attachmentToolbar'), dom.h('.chat-attached-context@attachedContextContainer'), dom.h('.chat-related-files@relatedFilesContainer'), - dom.h('.chat-mcp@mcpContainer'), ]), dom.h('.interactive-input-followups@followupsContainer'), ]) @@ -754,7 +750,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attachment-toolbar@attachmentToolbar'), dom.h('.chat-related-files@relatedFilesContainer'), dom.h('.chat-attached-context@attachedContextContainer'), - dom.h('.chat-mcp@mcpContainer'), ]), dom.h('.chat-editor-container@editorContainer'), dom.h('.chat-input-toolbars@inputToolbars'), @@ -780,12 +775,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(this._implicitContext.onDidChangeValue(() => this._handleAttachedContextChange())); } - this._register(autorun(r => { - const servers = this.mcpService.servers.read(r); - const contents = renderLabelWithIcons(localize('serverpattern', "{0} {1}", '$(tools)', servers.length)); - dom.reset(elements.mcpContainer, ...contents); - })); - this.renderAttachedContext(); this._register(this._attachmentModel.onDidChangeContext(() => this._handleAttachedContextChange())); this.renderChatEditingSessionState(null); diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index dc34c1cfc16..68deb3b7c60 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -1115,9 +1115,12 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 8px 0 0 0 } +.chat-attachment-toolbar .action-item:not(:last-child) { + margin-right: 4px; +} + .action-item.chat-attached-context-attachment.chat-add-files { height: 20px; - gap: 0px; color: var(--vscode-descriptionForeground); } @@ -1125,9 +1128,9 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +.action-item.chat-mcp .action-label, .action-item.chat-attached-context-attachment.chat-add-files .action-label, -.interactive-session .chat-attached-context .chat-attached-context-attachment, -.interactive-session .chat-mcp { +.interactive-session .chat-attached-context .chat-attached-context-attachment { display: flex; gap: 2px; overflow: hidden; @@ -1139,7 +1142,7 @@ have to be updated for changes to the rules above, or to support more deeply nes max-width: 100%; width: fit-content; } - +.action-item.chat-mcp .action-label, .action-item.chat-attached-context-attachment.chat-add-files .action-label { color: var(--vscode-descriptionForeground); font-family: unset; diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index 3a800f0665f..987334853c1 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -19,7 +19,9 @@ import { McpService } from '../common/mcpService.js'; import { IMcpService } from '../common/mcpTypes.js'; import { McpDiscovery } from './mcpDiscovery.js'; -import './mcpCommands.js'; +import { AttachMCPToolsAction, AttachMCPToolsActionRendering, ListMcpServerCommand, McpServerOptionsCommand } from './mcpCommands.js'; + +import { registerAction2 } from '../../../../platform/actions/common/actions.js'; registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); @@ -29,5 +31,10 @@ mcpDiscoveryRegistry.register(new SyncDescriptor(ConfigMcpDiscovery)); registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); +registerAction2(ListMcpServerCommand); +registerAction2(McpServerOptionsCommand); +registerAction2(AttachMCPToolsAction); +registerWorkbenchContribution2('mcpActionRendering', AttachMCPToolsActionRendering, WorkbenchPhase.BlockRestore); + const jsonRegistry = Registry.as(jsonContributionRegistry.Extensions.JSONContribution); jsonRegistry.registerSchema(mcpSchemaId, mcpServerSchema); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 727aaae21b6..002e54c518b 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -3,15 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { reset } from '../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { groupBy } from '../../../../base/common/collections.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { Event } from '../../../../base/common/event.js'; +import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, derived } from '../../../../base/common/observable.js'; +import { assertType } from '../../../../base/common/types.js'; import { ILocalizedString, localize, localize2 } from '../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; -import { IMcpService, McpConnectionState } from '../common/mcpTypes.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { CHAT_CATEGORY } from '../../chat/browser/actions/chatActions.js'; +import { ChatAgentLocation } from '../../chat/common/chatAgents.js'; +import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; +import { IMcpService, IMcpTool, McpConnectionState } from '../common/mcpTypes.js'; // acroynms do not get localized const category: ILocalizedString = { @@ -19,7 +33,7 @@ const category: ILocalizedString = { value: 'MCP', }; -class ListMcpServerCommand extends Action2 { +export class ListMcpServerCommand extends Action2 { public static readonly id = 'workbench.mcp.listServer'; constructor() { super({ @@ -73,11 +87,11 @@ class ListMcpServerCommand extends Action2 { } } } -registerAction2(ListMcpServerCommand); -class McpServerOptionsCommand extends Action2 { - public static readonly id = 'workbench.mcp.serverOptions'; +export class McpServerOptionsCommand extends Action2 { + + static readonly id = 'workbench.mcp.serverOptions'; constructor() { super({ @@ -151,4 +165,142 @@ class McpServerOptionsCommand extends Action2 { } } } -registerAction2(McpServerOptionsCommand); + + +export class AttachMCPToolsAction extends Action2 { + + static readonly id = 'workbench.action.chat.mcp.attachMcpTools'; + + constructor() { + super({ + id: AttachMCPToolsAction.id, + title: localize2('workbench.action.chat.editing.attachContext.shortLabel', "Select Tools..."), + icon: Codicon.tools, + f1: false, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + menu: { + when: ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + id: MenuId.ChatInputAttachmentToolbar, + group: 'navigation' + }, + keybinding: { + when: ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession)), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Slash, + weight: KeybindingWeight.EditorContrib + } + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + + const quickPickService = accessor.get(IQuickInputService); + const mcpService = accessor.get(IMcpService); + + type IToolPick = IQuickPickItem & { tool: IMcpTool }; + const picks: (IToolPick | IQuickPickSeparator)[] = []; + + for (const server of mcpService.servers.get()) { + + picks.push({ + type: 'separator', + label: server.definition.label + }); + + for (const tool of server.tools.get()) { + picks.push({ + type: 'item', + label: tool.definition.name, + detail: tool.definition.description, + tooltip: tool.definition.description, + picked: tool.enabled.get(), + tool, + }); + } + } + + const result = await quickPickService.pick(picks, { + placeHolder: localize('placeholder', "Select tools that are available to chat"), + canPickMany: true + }); + + if (!result) { + return; + } + + const seen = new Set(); + for (const item of result) { + item.tool.updateEnablement(true); + seen.add(item.tool); + } + + for (const pick of picks) { + if (pick.type === 'item' && !seen.has(pick.tool)) { + pick.tool.updateEnablement(false); + } + } + } +} + +export class AttachMCPToolsActionRendering extends Disposable implements IWorkbenchContribution { + public static readonly ID = 'workbench.contrib.mcp.discovery'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IMcpService mcpService: IMcpService, + @IInstantiationService instaService: IInstantiationService + ) { + super(); + + + const toolsCount = derived(r => { + let count = 0; + let enabled = 0; + const servers = mcpService.servers.read(r); + for (const server of servers) { + for (const tool of server.tools.read(r)) { + count += 1; + enabled += tool.enabled.read(r) ? 1 : 0; + } + } + return { count, enabled }; + }); + + + this._store.add(actionViewItemService.register(MenuId.ChatInputAttachmentToolbar, AttachMCPToolsAction.id, (action, options) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + + return instaService.createInstance(class extends MenuEntryActionViewItem { + + override render(container: HTMLElement): void { + this.options.icon = false; + this.options.label = true; + container.classList.add('chat-mcp'); + super.render(container); + } + + protected override updateLabel(): void { + this._store.add(autorun(r => { + assertType(this.label); + + const { enabled, count } = toolsCount.read(r); + + if (count === 0) { + super.updateLabel(); + return; + } + + const message = enabled !== count + ? localize('tool.1', "{0} {1} of {2}", '$(tools)', enabled, count) + : localize('tool.0', "{0} {1}", '$(tools)', count); + reset(this.label, ...renderLabelWithIcons(message)); + })); + } + + }, action, { ...options, keybindingNotRenderedWithLabel: true }); + + }, Event.fromObservable(toolsCount))); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index af5adb110b6..f884386642d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -219,6 +219,10 @@ export class McpTool implements IMcpTool { readonly id: string; + private _enabled = observableValue(this, true); + + readonly enabled: IObservable = this._enabled; + constructor( private readonly _server: McpServer, public readonly definition: MCP.Tool, @@ -226,6 +230,10 @@ export class McpTool implements IMcpTool { this.id = `${_server.definition.id}_${definition.name}`.replaceAll('.', '_'); } + updateEnablement(value: boolean): void { + this._enabled.set(value, undefined); + } + call(params: Record, token?: CancellationToken): Promise { return this._server.callOn(h => h.callTool({ name: this.definition.name, arguments: params }), token); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index ed78ed34fd4..f46e7fd57b5 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, autorunWithStore, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { ILanguageModelToolsService, IToolResult } from '../../chat/common/languageModelToolsService.js'; @@ -27,7 +28,8 @@ export class McpService extends Disposable implements IMcpService { constructor( @IInstantiationService instantiationService: IInstantiationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, - @ILanguageModelToolsService toolsService: ILanguageModelToolsService + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IContextKeyService contextKeyService: IContextKeyService ) { super(); @@ -74,7 +76,7 @@ export class McpService extends Disposable implements IMcpService { const tools = this._register(new MutableDisposable()); - this._register(autorun(reader => { + this._register(autorunWithStore((reader, store) => { const servers = this._servers.read(reader); @@ -87,11 +89,19 @@ export class McpService extends Disposable implements IMcpService { for (const tool of server.tools.read(reader)) { + const ctxKey = new RawContextKey(`mcp.tool.${tool.id}.enabled`, true); + const ctxInst = contextKeyService.createKey(ctxKey.key, true); + store.add(toDisposable(() => ctxInst.reset())); + store.add(autorun(reader => { + ctxInst.set(tool.enabled.read(reader)); + })); + newStore.add(toolsService.registerToolData({ id: tool.id, displayName: tool.definition.name, modelDescription: tool.definition.description ?? '', inputSchema: tool.definition.inputSchema, + when: ctxKey.isEqualTo(true), tags: ['mcp', 'vscode_editing'] })); newStore.add(toolsService.registerToolImplementation(tool.id, { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 568b51ad018..5baf2ca137d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -110,6 +110,11 @@ export interface IMcpTool { readonly id: string; readonly definition: MCP.Tool; + + readonly enabled: IObservable; + + updateEnablement(value: boolean): void; + /** * Calls a tool * @throws {@link MpcResponseError} if the tool fails to execute From 7db007682b2b28557d044c6a107bc3c0d4a2ed39 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 11 Mar 2025 16:11:02 +0100 Subject: [PATCH 008/255] forward `stdin|out` errors --- src/vs/workbench/api/node/extHostMpcNode.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/api/node/extHostMpcNode.ts b/src/vs/workbench/api/node/extHostMpcNode.ts index 45cacedc7c2..73dcccf17e7 100644 --- a/src/vs/workbench/api/node/extHostMpcNode.ts +++ b/src/vs/workbench/api/node/extHostMpcNode.ts @@ -79,6 +79,9 @@ export class NodeExtHostMpcService extends ExtHostMcpService { child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); + child.stdin.on('error', onError); + child.stdout.on('error', onError); + // Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177 // Just treat it as generic log data for now child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, line.toString())); From 87e6cd178f02909a3791c7a7f83bfccdfcbe41b0 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 11 Mar 2025 16:28:57 +0100 Subject: [PATCH 009/255] add full ipc/stdio logging, make sure to send `notifications/initialized` after init-response has been received --- src/vs/workbench/api/node/extHostMpcNode.ts | 10 +++++++++- .../contrib/mcp/common/mcpServerRequestHandler.ts | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/node/extHostMpcNode.ts b/src/vs/workbench/api/node/extHostMpcNode.ts index 73dcccf17e7..0ad54d3c5d1 100644 --- a/src/vs/workbench/api/node/extHostMpcNode.ts +++ b/src/vs/workbench/api/node/extHostMpcNode.ts @@ -11,6 +11,7 @@ import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServer import { ExtHostMcpService } from '../common/extHostMcp.js'; import { IExtHostRpcService } from '../common/extHostRpcService.js'; import { homedir } from 'os'; +import { PassThrough } from 'stream'; export class NodeExtHostMpcService extends ExtHostMcpService { constructor( @@ -45,6 +46,8 @@ export class NodeExtHostMpcService extends ExtHostMcpService { override $sendMessage(id: number, message: string): void { const nodeServer = this.nodeServers.get(id); if (nodeServer) { + this._proxy.$onDidPublishLog(id, '[Client Says] ' + message.toString()); + nodeServer.child.stdin.write(message + '\n'); } else { super.$sendMessage(id, message); @@ -77,7 +80,12 @@ export class NodeExtHostMpcService extends ExtHostMcpService { this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting }); - child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); + const debug = new PassThrough(); + debug.on('data', line => { + this._proxy.$onDidPublishLog(id, '[Server Says] ' + line.toString()); + }); + + child.stdout.pipe(new StreamSplitter('\n')).pipe(debug).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); child.stdin.on('error', onError); child.stdout.on('error', onError); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 992c71ec4b5..651cb01d8cd 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -99,6 +99,10 @@ export class McpServerRequestHandler extends Disposable { }, token); mcp._serverInit = initialized; + + mcp.sendNotification({ + method: 'notifications/initialized' + }); }); return mcp; From b3d3aad6b5e24950e89536e79c89b5ba3fea34c0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 09:20:25 -0700 Subject: [PATCH 010/255] fix new lint --- .../mcp/common/discovery/nativeMcpDiscoveryAdapters.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index fddd0b699f5..73476c78b6a 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -20,11 +20,13 @@ export interface NativeMpcDiscoveryAdapter { } export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapter { - public readonly id: string = `claude-desktop.${this.remoteAuthority}`; + public readonly id: string; public readonly label: string = 'Claude Desktop'; public readonly order = McpCollectionSortOrder.Filesystem; - constructor(public readonly remoteAuthority: string | null) { } + constructor(public readonly remoteAuthority: string | null) { + this.id = `claude-desktop.${this.remoteAuthority}`; + } getFilePath({ platform, winAppData, homedir }: INativeMcpDiscoveryData): URI | undefined { if (platform === Platform.Windows) { From 67e6e173f850c0f3935e38ccb3a4bddc26f65782 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 09:58:27 -0700 Subject: [PATCH 011/255] fix xdg home not being read for posix --- .../common/discovery/nativeMcpDiscoveryAdapters.ts | 4 ++-- .../mcp/test/common/mcpServerRequestHandler.test.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index 73476c78b6a..8eb01b9c722 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -28,14 +28,14 @@ export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapt this.id = `claude-desktop.${this.remoteAuthority}`; } - getFilePath({ platform, winAppData, homedir }: INativeMcpDiscoveryData): URI | undefined { + getFilePath({ platform, winAppData, xdgHome, homedir }: INativeMcpDiscoveryData): URI | undefined { if (platform === Platform.Windows) { const appData = winAppData || URI.joinPath(homedir, 'AppData', 'Roaming'); return URI.joinPath(appData, 'Claude', 'claude_desktop_config.json'); } else if (platform === Platform.Mac) { return URI.joinPath(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); } else { - const configDir = /*process.env.XDG_CONFIG_HOME || */URI.joinPath(homedir, '.config'); + const configDir = xdgHome || URI.joinPath(homedir, '.config'); return URI.joinPath(configDir, 'Claude', 'claude_desktop_config.json'); } } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index f78bf427271..1da6d3d8868 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -107,10 +107,10 @@ suite('Workbench - MCP - ServerRequestHandler', () => { // Get the sent message and verify it const sentMessages = transport.getSentMessages(); - assert.strictEqual(sentMessages.length, 2); // initialize + listResources + assert.strictEqual(sentMessages.length, 3); // initialize + listResources // Verify listResources request format - const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest; assert.strictEqual(listResourcesRequest.method, 'resources/list'); assert.strictEqual(listResourcesRequest.jsonrpc, MCP.JSONRPC_VERSION); assert.ok(typeof listResourcesRequest.id === 'number'); @@ -140,7 +140,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { // Get the first request and respond with pagination const sentMessages = transport.getSentMessages(); - const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest; // Send first page with nextCursor transport.simulateReceiveMessage({ @@ -192,7 +192,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { // Get the sent message const sentMessages = transport.getSentMessages(); - const readResourceRequest = sentMessages[1] as MCP.JSONRPCRequest; // [0] is initialize + const readResourceRequest = sentMessages[2] as MCP.JSONRPCRequest; // [0] is initialize // Simulate error response transport.simulateReceiveMessage({ @@ -292,7 +292,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { // Get the request ID const sentMessages = transport.getSentMessages(); - const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest; const requestId = listResourcesRequest.id; // Cancel the request @@ -324,7 +324,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { // Get the request ID const sentMessages = transport.getSentMessages(); - const listResourcesRequest = sentMessages[1] as MCP.JSONRPCRequest; + const listResourcesRequest = sentMessages[2] as MCP.JSONRPCRequest; const requestId = listResourcesRequest.id; // Simulate cancelled notification from server From 0e01b1cc94680791f9c92dd4c02fbcbfaa044a1b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 10:53:25 -0700 Subject: [PATCH 012/255] refactor: adopt proxychannel for calling INativeMcpDiscoveryHelperService --- src/vs/code/electron-main/app.ts | 11 +++++-- .../node/nativeMcpDiscoveryHelperChannel.ts | 26 ++++----------- .../node/nativeMcpDiscoveryHelperService.ts | 33 +++++++++++++++++++ src/vs/server/node/serverServices.ts | 6 ++-- .../discovery/nativeMcpRemoteDiscovery.ts | 11 ++++--- .../electron-sandbox/nativeMpcDiscovery.ts | 23 ++++++------- 6 files changed, 69 insertions(+), 41 deletions(-) create mode 100644 src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 22b09835a9c..5bcfbd5b654 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -118,8 +118,8 @@ import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/ele import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.js'; import { normalizeNFC } from '../../base/common/normalization.js'; import { ICSSDevelopmentService, CSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; -import { NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; +import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; /** * The main VS Code application. There will only ever be one instance, @@ -1121,6 +1121,10 @@ export class CodeApplication extends Disposable { // Proxy Auth services.set(IProxyAuthService, new SyncDescriptor(ProxyAuthService)); + // MCP + services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); + + // Dev Only: CSS service (for ESM) services.set(ICSSDevelopmentService, new SyncDescriptor(CSSDevelopmentService, undefined, true)); @@ -1225,7 +1229,8 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel); // MCP - mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, new NativeMcpDiscoveryHelperChannel(undefined)); + const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); + mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); // Logger const loggerChannel = new LoggerChannel(accessor.get(ILoggerMainService),); diff --git a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts index 875788b3695..b09f36ba58e 100644 --- a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts +++ b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperChannel.ts @@ -3,17 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { homedir } from 'os'; import { Event } from '../../../base/common/event.js'; -import { URI } from '../../../base/common/uri.js'; import { IURITransformer, transformOutgoingURIs } from '../../../base/common/uriIpc.js'; import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { INativeMcpDiscoveryData } from '../common/nativeMcpDiscoveryHelper.js'; -import { platform } from '../../../base/common/platform.js'; +import { INativeMcpDiscoveryHelperService } from '../common/nativeMcpDiscoveryHelper.js'; export class NativeMcpDiscoveryHelperChannel implements IServerChannel { - constructor(private getUriTransformer: undefined | ((requestContext: any) => IURITransformer)) { } + constructor( + private getUriTransformer: undefined | ((requestContext: any) => IURITransformer), + @INativeMcpDiscoveryHelperService private nativeMcpDiscoveryHelperService: INativeMcpDiscoveryHelperService + ) { } listen(context: any, event: string): Event { throw new Error('Invalid listen'); @@ -23,25 +23,11 @@ export class NativeMcpDiscoveryHelperChannel implements IServerChannel { const uriTransformer = this.getUriTransformer?.(context); switch (command) { case 'load': { - const result: INativeMcpDiscoveryData = { - platform, - homedir: URI.file(homedir()), - winAppData: this.uriFromEnvVariable('APPDATA'), - xdgHome: this.uriFromEnvVariable('XDG_CONFIG_HOME'), - }; - + const result = await this.nativeMcpDiscoveryHelperService.load(); return uriTransformer ? transformOutgoingURIs(result, uriTransformer) : result; } } throw new Error('Invalid call'); } - - private uriFromEnvVariable(varName: string) { - const envVar = process.env[varName]; - if (!envVar) { - return undefined; - } - return URI.file(envVar); - } } diff --git a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts new file mode 100644 index 00000000000..987f25f9a7c --- /dev/null +++ b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { homedir } from 'os'; +import { platform } from '../../../base/common/platform.js'; +import { URI } from '../../../base/common/uri.js'; +import { INativeMcpDiscoveryData, INativeMcpDiscoveryHelperService } from '../common/nativeMcpDiscoveryHelper.js'; + +export class NativeMcpDiscoveryHelperService implements INativeMcpDiscoveryHelperService { + declare readonly _serviceBrand: undefined; + + constructor() { } + + load(): Promise { + return Promise.resolve({ + platform, + homedir: URI.file(homedir()), + winAppData: this.uriFromEnvVariable('APPDATA'), + xdgHome: this.uriFromEnvVariable('XDG_CONFIG_HOME'), + }); + } + + private uriFromEnvVariable(varName: string) { + const envVar = process.env[varName]; + if (!envVar) { + return undefined; + } + return URI.file(envVar); + } +} + diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index cb2757d4104..edb08e14d6d 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -80,8 +80,9 @@ import { NodePtyHostStarter } from '../../platform/terminal/node/nodePtyHostStar import { CSSDevelopmentService, ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryLogAppender.js'; -import { NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; +import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; const eventPrefix = 'monacoworkbench'; @@ -195,6 +196,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken services.set(IExtensionSignatureVerificationService, new SyncDescriptor(ExtensionSignatureVerificationService)); services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService)); + services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); const instantiationService: IInstantiationService = new InstantiationService(services); services.set(ILanguagePackService, instantiationService.createInstance(NativeLanguagePackService)); @@ -226,7 +228,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); - socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, new NativeMcpDiscoveryHelperChannel((ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); + socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, instantiationService.createInstance(NativeMcpDiscoveryHelperChannel, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); const remoteFileSystemChannel = disposables.add(new RemoteAgentFileSystemProviderChannel(logService, environmentService, configurationService)); socketServer.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, remoteFileSystemChannel); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts index 0eacf9c4184..44e2ed08b5b 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ProxyChannel } from '../../../../../base/parts/ipc/common/ipc.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { INativeMcpDiscoveryData, NativeMcpDiscoveryHelperChannelName } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; +import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; import { FilesystemMpcDiscovery } from './nativeMcpDiscoveryAbstract.js'; @@ -34,15 +34,16 @@ export class RemoteNativeMpcDiscovery extends FilesystemMpcDiscovery { return this.setDetails(undefined); } - connection.withChannel(NativeMcpDiscoveryHelperChannelName, channel => - channel.call>('load', undefined)) + await connection.withChannel(NativeMcpDiscoveryHelperChannelName, async channel => { + const service = ProxyChannel.toService(channel); - .then( + service.load().then( data => this.setDetails(data), err => { this.logService.warn('Error getting remote process MCP environment', err); this.setDetails(undefined); } ); + }); } } diff --git a/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts index 7f623f83643..3a05e75b7a0 100644 --- a/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INativeMcpDiscoveryData, NativeMcpDiscoveryHelperChannelName } from '../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; +import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { FilesystemMpcDiscovery } from '../common/discovery/nativeMcpDiscoveryAbstract.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; @@ -26,14 +26,15 @@ export class NativeMcpDiscovery extends FilesystemMpcDiscovery { } public override start(): void { - this.mainProcess.getChannel(NativeMcpDiscoveryHelperChannelName) - .call>('load', undefined) - .then( - data => this.setDetails(data), - err => { - this.logService.warn('Error getting main process MCP environment', err); - this.setDetails(undefined); - } - ); + const service = ProxyChannel.toService( + this.mainProcess.getChannel(NativeMcpDiscoveryHelperChannelName)); + + service.load().then( + data => this.setDetails(data), + err => { + this.logService.warn('Error getting main process MCP environment', err); + this.setDetails(undefined); + } + ); } } From 7d2f5bb1aee0ce20c6259d96b0d81f0aa9f9a0db Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 11 Mar 2025 20:15:46 +0100 Subject: [PATCH 013/255] add context keys and control tools command placement based on MCP servers being around --- .../platform/contextkey/common/contextkey.ts | 4 +++ .../contrib/mcp/browser/mcp.contribution.ts | 2 ++ .../contrib/mcp/browser/mcpCommands.ts | 11 ++++-- .../contrib/mcp/common/mcpContextKeys.ts | 36 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts diff --git a/src/vs/platform/contextkey/common/contextkey.ts b/src/vs/platform/contextkey/common/contextkey.ts index ef011cc6b2c..57be3923e57 100644 --- a/src/vs/platform/contextkey/common/contextkey.ts +++ b/src/vs/platform/contextkey/common/contextkey.ts @@ -2005,6 +2005,10 @@ export class RawContextKey extends ContextKeyDefinedE public notEqualsTo(value: any): ContextKeyExpression { return ContextKeyNotEqualsExpr.create(this.key, value); } + + public greater(value: any): ContextKeyExpression { + return ContextKeyGreaterExpr.create(this.key, value); + } } export type ContextKeyValue = null | undefined | boolean | number | string diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index 987334853c1..ce7d2c0b0ca 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -22,6 +22,7 @@ import { McpDiscovery } from './mcpDiscovery.js'; import { AttachMCPToolsAction, AttachMCPToolsActionRendering, ListMcpServerCommand, McpServerOptionsCommand } from './mcpCommands.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { McpContextKeysController } from '../common/mcpContextKeys.js'; registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); @@ -30,6 +31,7 @@ mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(ConfigMcpDiscovery)); registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2('mcpContextKeys', McpContextKeysController, WorkbenchPhase.BlockRestore); registerAction2(ListMcpServerCommand); registerAction2(McpServerOptionsCommand); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 002e54c518b..364c5c2b4b4 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -25,6 +25,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { CHAT_CATEGORY } from '../../chat/browser/actions/chatActions.js'; import { ChatAgentLocation } from '../../chat/common/chatAgents.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; +import { McpContextKeys } from '../common/mcpContextKeys.js'; import { IMcpService, IMcpTool, McpConnectionState } from '../common/mcpTypes.js'; // acroynms do not get localized @@ -178,9 +179,15 @@ export class AttachMCPToolsAction extends Action2 { icon: Codicon.tools, f1: false, category: CHAT_CATEGORY, - precondition: ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + precondition: ContextKeyExpr.and( + McpContextKeys.serverCount.greater(0), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) + ), menu: { - when: ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + when: ContextKeyExpr.and( + McpContextKeys.serverCount.greater(0), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) + ), id: MenuId.ChatInputAttachmentToolbar, group: 'navigation' }, diff --git a/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts new file mode 100644 index 00000000000..c49fd058f55 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IMcpService } from './mcpTypes.js'; + + +export namespace McpContextKeys { + + export const serverCount = new RawContextKey('mcp.serverCount', undefined, { type: 'number', description: localize('mcp.serverCount.description', "Context key that has the number of registered MCP servers") }); +} + + +export class McpContextKeysController extends Disposable implements IWorkbenchContribution { + public static readonly ID = 'workbench.contrib.mcp.contextKey'; + + constructor( + @IMcpService mcpService: IMcpService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(); + + const ctxServerCount = McpContextKeys.serverCount.bindTo(contextKeyService); + + this._store.add(autorun(r => { + ctxServerCount.set(mcpService.servers.read(r).length); + })); + } +} From eeebd7a1f889691b853f0c2b0a2830600f12de85 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:20:28 -0700 Subject: [PATCH 014/255] Evict execution from active executions Part of #242195 --- .../workbench/api/common/extHostTerminalShellIntegration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index cc5fccb9a8d..fbbe2863ea4 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -238,7 +238,10 @@ class InternalTerminalShellIntegration extends Disposable { // command detection let currentExecution: InternalTerminalShellExecution | undefined; if (commandLine.confidence === TerminalShellExecutionCommandLineConfidence.High) { - currentExecution = this._activeExecutions.find(e => e.value.commandLine.value === commandLine.value); + const index = this._activeExecutions.findIndex(e => e.value.commandLine.value === commandLine.value); + if (index !== -1) { + currentExecution = this._activeExecutions.splice(index, 1)[0]; + } } else { currentExecution = this._activeExecutions.shift(); } From 462a6ff92db09f7ea3f18ec39c592a9556f20c8c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 15:20:36 -0700 Subject: [PATCH 015/255] mcp: securely store mcp inputs This just treats everything as a secret for now, I want to do some work on the IConfigurationResolverService so we can differentiate password versus non-password input, among other work Fixes #243226 --- .../test/common/testSecretStorageService.ts | 36 ++++ .../contrib/mcp/common/mcpRegistry.ts | 56 ++--- .../mcp/common/mcpRegistryInputStorage.ts | 192 ++++++++++++++++++ .../contrib/mcp/common/mcpRegistryTypes.ts | 2 - .../mcp/test/common/mcpRegistry.test.ts | 24 +-- .../common/mcpRegistryInputStorage.test.ts | 178 ++++++++++++++++ 6 files changed, 428 insertions(+), 60 deletions(-) create mode 100644 src/vs/platform/secrets/test/common/testSecretStorageService.ts create mode 100644 src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts diff --git a/src/vs/platform/secrets/test/common/testSecretStorageService.ts b/src/vs/platform/secrets/test/common/testSecretStorageService.ts new file mode 100644 index 00000000000..ec7482e72bb --- /dev/null +++ b/src/vs/platform/secrets/test/common/testSecretStorageService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { ISecretStorageService } from '../../common/secrets.js'; + +export class TestSecretStorageService implements ISecretStorageService { + declare readonly _serviceBrand: undefined; + + private readonly _storage = new Map(); + private readonly _onDidChangeSecretEmitter = new Emitter(); + readonly onDidChangeSecret = this._onDidChangeSecretEmitter.event; + + type = 'in-memory' as const; + + async get(key: string): Promise { + return this._storage.get(key); + } + + async set(key: string, value: string): Promise { + this._storage.set(key, value); + this._onDidChangeSecretEmitter.fire(key); + } + + async delete(key: string): Promise { + this._storage.delete(key); + this._onDidChangeSecretEmitter.fire(key); + } + + // Helper method for tests to clear all secrets + clear(): void { + this._storage.clear(); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index fb96aa95cdd..b6a317949e0 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; -import { isEmptyObject } from '../../../../base/common/types.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; +import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js'; import { IMcpHostDelegate, IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServerConnection } from './mcpServerConnection.js'; -import { McpCollectionDefinition, IMcpServerConnection, McpServerDefinition } from './mcpTypes.js'; +import { IMcpServerConnection, McpCollectionDefinition, McpServerDefinition } from './mcpTypes.js'; export class McpRegistry extends Disposable implements IMcpRegistry { declare public readonly _serviceBrand: undefined; @@ -21,6 +22,9 @@ export class McpRegistry extends Disposable implements IMcpRegistry { public readonly collections: IObservable = this._collections; + private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER))); + private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER))); + public get delegates(): readonly IMcpHostDelegate[] { return this._delegates; } @@ -28,7 +32,6 @@ export class McpRegistry extends Disposable implements IMcpRegistry { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, - @IStorageService private readonly _storageService: IStorageService, ) { super(); } @@ -57,16 +60,9 @@ export class McpRegistry extends Disposable implements IMcpRegistry { }; } - public hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean { - const stored = this.getInputStorageData(collection, definition); - return !!stored && !!stored.map && !isEmptyObject(stored.map); - } - - public clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition) { - const stored = this.getInputStorageData(collection, definition); - if (stored) { - this._storageService.remove(stored.key, stored.scope); - } + public clearSavedInputs() { + this._profileStorage.value.clearAll(); + this._workspaceStorage.value.clearAll(); } public async resolveConnection( @@ -80,17 +76,21 @@ export class McpRegistry extends Disposable implements IMcpRegistry { let launch = definition.launch; - const storage = this.getInputStorageData(collection, definition); - if (definition.variableReplacement && storage) { + if (definition.variableReplacement) { + const inputStorage = definition.variableReplacement.folder ? this._workspaceStorage.value : this._profileStorage.value; + const previouslyStored = await inputStorage.getMap(); + const { folder, section, target } = definition.variableReplacement; + // based on _configurationResolverService.resolveWithInteractionReplace launch = await this._configurationResolverService.resolveAnyAsync(folder, launch); - const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, storage.map, target); + const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, previouslyStored, target); if (newVariables?.size) { - launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, Object.fromEntries(newVariables)); - this._storageService.store(storage.key, JSON.stringify(Object.fromEntries(newVariables)), storage.scope, StorageTarget.MACHINE); + const completeVariables = { ...previouslyStored, ...Object.fromEntries(newVariables) }; + launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, completeVariables); + await inputStorage.setSecrets(completeVariables); } } @@ -102,23 +102,5 @@ export class McpRegistry extends Disposable implements IMcpRegistry { launch, ); } - - private getInputStorageData(collection: McpCollectionDefinition, definition: McpServerDefinition) { - if (!definition.variableReplacement) { - return undefined; - } - - const key = `mcpConfig.${collection.id}.${definition.id}`; - const scope = definition.variableReplacement.folder ? StorageScope.WORKSPACE : StorageScope.APPLICATION; - - let map: Record | undefined; - try { - map = this._storageService.getObject(key, scope); - } catch { - // ignord - } - - return { key, scope, map }; - } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts new file mode 100644 index 00000000000..6e23a059998 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../base/common/async.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEmptyObject } from '../../../../base/common/types.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const MCP_ENCRYPTION_KEY_NAME = 'mcpEncryptionKey'; +const MCP_ENCRYPTION_KEY_ALGORITHM = 'AES-GCM'; +const MCP_ENCRYPTION_KEY_LEN = 256; +const MCP_ENCRYPTION_IV_LENGTH = 12; // 96 bits +const MCP_DATA_STORED_VERSION = 1; +const MCP_DATA_STORED_KEY = 'mcpInputs'; + +interface IStoredData { + version: number; + values: Record; + secrets?: { value: string; iv: string }; // base64, encrypted +} + +interface IHydratedData extends IStoredData { + unsealedSecrets?: Record; +} + +export class McpRegistryInputStorage extends Disposable { + private static secretSequencer = new Sequencer(); + private readonly _secretsSealerSequencer = new Sequencer(); + + private readonly _getEncryptionKey = new Lazy(() => { + return McpRegistryInputStorage.secretSequencer.queue(async () => { + const existing = await this._secretStorageService.get(MCP_ENCRYPTION_KEY_NAME); + if (existing) { + try { + const parsed: JsonWebKey = JSON.parse(existing); + return await crypto.subtle.importKey('jwk', parsed, MCP_ENCRYPTION_KEY_ALGORITHM, false, ['encrypt', 'decrypt']); + } catch { + // fall through + } + } + + const key = await crypto.subtle.generateKey( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, length: MCP_ENCRYPTION_KEY_LEN }, + true, + ['encrypt', 'decrypt'], + ); + + const exported = await crypto.subtle.exportKey('jwk', key); + await this._secretStorageService.set(MCP_ENCRYPTION_KEY_NAME, JSON.stringify(exported)); + return key; + }); + }); + + private _didChange = false; + + private _record = new Lazy(() => { + const stored = this._storageService.getObject(MCP_DATA_STORED_KEY, this._scope); + return stored?.version === MCP_DATA_STORED_VERSION ? { ...stored } : { version: MCP_DATA_STORED_VERSION, values: {} }; + }); + + + constructor( + private readonly _scope: StorageScope, + _target: StorageTarget, + @IStorageService private readonly _storageService: IStorageService, + @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(_storageService.onWillSaveState(() => { + if (this._didChange) { + this._storageService.store(MCP_DATA_STORED_KEY, { + version: MCP_DATA_STORED_VERSION, + values: this._record.value.values, + secrets: this._record.value.secrets, + } satisfies IStoredData, this._scope, _target); + this._didChange = false; + } + })); + } + + /** Deletes all collection data from storage. */ + public clearAll() { + this._record.value.values = {}; + this._record.value.secrets = undefined; + this._record.value.unsealedSecrets = undefined; + this._didChange = true; + } + + /** Delete a single collection data from the storage. */ + public async clear(inputKey: string) { + const secrets = await this._unsealSecrets(); + delete this._record.value.values[inputKey]; + this._didChange = true; + + if (secrets.hasOwnProperty(inputKey)) { + delete secrets[inputKey]; + await this._sealSecrets(); + } + } + + /** Gets a mapping of saved input data. */ + public async getMap() { + const secrets = await this._unsealSecrets(); + return { ...this._record.value.values, ...secrets }; + } + + /** Updates the input data mapping. */ + public async setPlainText(values: Record) { + Object.assign(this._record.value.values, values); + this._didChange = true; + } + + /** Updates the input secrets mapping. */ + public async setSecrets(values: Record) { + const unsealed = await this._unsealSecrets(); + Object.assign(unsealed, values); + await this._sealSecrets(); + } + + private async _sealSecrets() { + return this._secretsSealerSequencer.queue(async () => { + if (!this._record.value.unsealedSecrets || isEmptyObject(this._record.value.unsealedSecrets)) { + this._record.value.secrets = undefined; + return; + } + + if (!this._record.value.secrets) { + const iv = crypto.getRandomValues(new Uint8Array(MCP_ENCRYPTION_IV_LENGTH)); + this._record.value.secrets = { + value: '', + iv: encodeBase64(VSBuffer.wrap(iv)), + }; + } + + const toSeal = JSON.stringify(this._record.value.unsealedSecrets); + const iv = decodeBase64(this._record.value.secrets.iv); + const key = await this._getEncryptionKey.value; + const encrypted = await crypto.subtle.encrypt( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, iv: iv.buffer }, + key, + new TextEncoder().encode(toSeal).buffer, + ); + + const enc = encodeBase64(VSBuffer.wrap(new Uint8Array(encrypted))); + if (this._record.value.secrets.value === enc) { + return; + } + + this._record.value.secrets.value = enc; + this._didChange = true; + }); + } + + private async _unsealSecrets(): Promise> { + if (!this._record.value.secrets) { + return this._record.value.unsealedSecrets ??= {}; + } + + if (this._record.value.unsealedSecrets) { + return this._record.value.unsealedSecrets; + } + + try { + const key = await this._getEncryptionKey.value; + const iv = decodeBase64(this._record.value.secrets.iv); + const encrypted = decodeBase64(this._record.value.secrets.value); + + const decrypted = await crypto.subtle.decrypt( + { name: MCP_ENCRYPTION_KEY_ALGORITHM, iv: iv.buffer }, + key, + encrypted.buffer, + ); + + const unsealedSecrets = JSON.parse(new TextDecoder().decode(decrypted)); + this._record.value.unsealedSecrets = unsealedSecrets; + return unsealedSecrets; + } catch (e) { + this._logService.warn('Error unsealing MCP secrets', e); + this._record.value.secrets = undefined; + } + + return {}; + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts index 9659bc986da..12f5123a803 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts @@ -35,8 +35,6 @@ export interface IMcpRegistry { registerDelegate(delegate: IMcpHostDelegate): IDisposable; registerCollection(collection: McpCollectionDefinition): IDisposable; - /** Gets whether there are saved inputs used to resolve the connection */ - hasSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): boolean; /** Resets any saved inputs for the connection. */ clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): void; /** Createse a connection for the collection and definition. */ diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 19465290098..d7493cdccaa 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -22,6 +22,8 @@ import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistry import { McpServerConnection } from '../../common/mcpServerConnection.js'; import { McpCollectionDefinition, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; +import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; class TestConfigurationResolverService implements Partial { declare readonly _serviceBrand: undefined; @@ -131,6 +133,7 @@ suite('Workbench - MCP - Registry', () => { const services = new ServiceCollection( [IConfigurationResolverService, testConfigResolverService], [IStorageService, testStorageService], + [ISecretStorageService, new TestSecretStorageService()], [ILoggerService, store.add(new TestLoggerService())], [IOutputService, upcast({ showChannel: () => { } })], ); @@ -185,27 +188,6 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual(registry.delegates.length, 0); }); - test('hasSavedInputs returns false when no inputs are saved', () => { - assert.strictEqual(registry.hasSavedInputs(testCollection, baseDefinition), false); - }); - - test('clearSavedInputs removes stored inputs', () => { - const definition: McpServerDefinition = { - ...baseDefinition, - variableReplacement: { - section: 'mcp' - } - }; - - // Save some mock inputs - const key = `mcpConfig.${testCollection.id}.${definition.id}`; - testStorageService.store(key, JSON.stringify({ 'input:foo': 'bar' }), StorageScope.APPLICATION, StorageTarget.MACHINE); - - assert.strictEqual(registry.hasSavedInputs(testCollection, definition), true); - registry.clearSavedInputs(testCollection, definition); - assert.strictEqual(registry.hasSavedInputs(testCollection, definition), false); - }); - test('resolveConnection creates connection with resolved variables and memorizes them', async () => { const definition: McpServerDefinition = { ...baseDefinition, diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts new file mode 100644 index 00000000000..f6f745d5751 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { McpRegistryInputStorage } from '../../common/mcpRegistryInputStorage.js'; + +suite('Workbench - MCP - RegistryInputStorage', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let testStorageService: TestStorageService; + let testSecretStorageService: TestSecretStorageService; + let testLogService: ILogService; + let mcpInputStorage: McpRegistryInputStorage; + + setup(() => { + testStorageService = store.add(new TestStorageService()); + testSecretStorageService = new TestSecretStorageService(); + testLogService = store.add(new NullLogService()); + + // Create the input storage with APPLICATION scope + mcpInputStorage = store.add(new McpRegistryInputStorage( + StorageScope.APPLICATION, + StorageTarget.MACHINE, + testStorageService, + testSecretStorageService, + testLogService + )); + }); + + test('setPlainText stores values that can be retrieved with getMap', async () => { + const values = { + 'key1': 'value1', + 'key2': 'value2' + }; + + await mcpInputStorage.setPlainText(values); + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, 'value1'); + assert.strictEqual(result.key2, 'value2'); + }); + + test('setSecrets stores encrypted values that can be retrieved with getMap', async () => { + const secrets = { + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }; + + await mcpInputStorage.setSecrets(secrets); + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.secretKey1, 'secretValue1'); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('getMap returns combined plain text and secret values', async () => { + await mcpInputStorage.setPlainText({ + 'plainKey': 'plainValue' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey': 'secretValue' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.plainKey, 'plainValue'); + assert.strictEqual(result.secretKey, 'secretValue'); + }); + + test('clear removes specific values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1', + 'key2': 'value2' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }); + + // Clear one plain and one secret value + await mcpInputStorage.clear('key1'); + await mcpInputStorage.clear('secretKey1'); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, undefined); + assert.strictEqual(result.key2, 'value2'); + assert.strictEqual(result.secretKey1, undefined); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('clearAll removes all values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1' + }); + + mcpInputStorage.clearAll(); + + const result = await mcpInputStorage.getMap(); + + assert.deepStrictEqual(result, {}); + }); + + test('updates to plain text values overwrite existing values', async () => { + await mcpInputStorage.setPlainText({ + 'key1': 'value1', + 'key2': 'value2' + }); + + await mcpInputStorage.setPlainText({ + 'key1': 'updatedValue1' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.key1, 'updatedValue1'); + assert.strictEqual(result.key2, 'value2'); + }); + + test('updates to secret values overwrite existing values', async () => { + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1', + 'secretKey2': 'secretValue2' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'updatedSecretValue1' + }); + + const result = await mcpInputStorage.getMap(); + + assert.strictEqual(result.secretKey1, 'updatedSecretValue1'); + assert.strictEqual(result.secretKey2, 'secretValue2'); + }); + + test('storage persists values across instances', async () => { + // Set values on first instance + await mcpInputStorage.setPlainText({ + 'key1': 'value1' + }); + + await mcpInputStorage.setSecrets({ + 'secretKey1': 'secretValue1' + }); + + await testStorageService.flush(); + + // Create a second instance that should have access to the same storage + const secondInstance = store.add(new McpRegistryInputStorage( + StorageScope.APPLICATION, + StorageTarget.MACHINE, + testStorageService, + testSecretStorageService, + testLogService + )); + + const result = await secondInstance.getMap(); + + assert.strictEqual(result.key1, 'value1'); + assert.strictEqual(result.secretKey1, 'secretValue1'); + + assert.ok(!testStorageService.get('mcpInputs', StorageScope.APPLICATION)?.includes('secretValue1')); + }); +}); + From fed0175e8985a29def12796390098a714fed1984 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:20:48 -0700 Subject: [PATCH 016/255] add telemetry at point of image attachment (#243270) * add telemetry at point of attaching image * fix imports --- .../chat/browser/chatAttachmentWidgets.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts index ec400c28df7..40691c17594 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts @@ -33,6 +33,7 @@ import { KeyCode } from '../../../../base/common/keyCodes.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; abstract class AbstractChatAttachmentWidget extends Disposable { public readonly element: HTMLElement; @@ -203,6 +204,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { @IOpenerService openerService: IOpenerService, @IHoverService private readonly hoverService: IHoverService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); @@ -228,9 +230,28 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const hoverElement = dom.$('div.chat-attached-context-hover'); hoverElement.setAttribute('aria-label', ariaLabel); - if (!this.modelSupportsVision()) { + type AttachImageEvent = { + currentModel: string; + supportsVision: boolean; + }; + type AttachImageEventClassification = { + currentModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model at the point of attaching the image.' }; + supportsVision: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the current model supports vision or not.' }; + owner: 'justschen'; + comment: 'Event used to gain insights when images are attached, and if the model supported vision or not.'; + }; + + const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'unknown'; + const supportsVision = this.modelSupportsVision(); + + this.telemetryService.publicLog2('copilot.attachImage', { + currentModel: currentLanguageModelName, + supportsVision: supportsVision + }); + + if (!supportsVision && this.currentLanguageModel) { this.element.classList.add('warning'); - hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name : this.currentLanguageModel, 'image'); + hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", currentLanguageModelName, 'image'); this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true })); } else { const buffer = attachment.value as Uint8Array; From 59ae5d4272cf4021d08e04eeb52834db3d452229 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:24:38 -0700 Subject: [PATCH 017/255] reveal first change on edit finish (#243273) more simplification Co-authored-by: amunger <> --- .../chatEditingNotebookEditorIntegration.ts | 67 +++++-------------- 1 file changed, 18 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts index dc2a00bbbe4..4379d0774d5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts @@ -86,8 +86,6 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I readonly currentIndex: IObservable = this._currentIndex; // TODO@amunger For now we're going to ignore being able to focus on a deleted cell. - private readonly _currentCell = observableValue(this, undefined); - readonly currentCell: IObservable = this._currentCell; private readonly _currentChange = observableValue<{ change: ICellDiffInfo; index: number } | undefined>(this, undefined); readonly currentChange: IObservable<{ change: ICellDiffInfo; index: number } | undefined> = this._currentChange; @@ -143,16 +141,7 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I && cellChanges.read(r).some(c => c.type !== 'unchanged' && c.type !== 'delete' && !c.diff.read(r).identical) ) { lastModifyingRequestId = _entry.lastModifyingRequestId; - const firstModifiedCell = cellChanges.read(r). - filter(c => c.type !== 'unchanged' && c.type !== 'delete'). - filter(c => !c.diff.read(r).identical). - reduce((prev, curr) => curr.modifiedCellIndex < prev ? curr.modifiedCellIndex : prev, Number.MAX_SAFE_INTEGER); - if (typeof firstModifiedCell !== 'number' || firstModifiedCell === Number.MAX_SAFE_INTEGER) { - return; - } - const activeCell = notebookEditor.getActiveCell(); - const index = activeCell ? notebookModel.cells.findIndex(c => c.handle === activeCell.handle) : firstModifiedCell; - this._currentCell.set(notebookModel.cells[index], undefined); + this.reveal(true); } })); @@ -314,31 +303,10 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I })); } - getCurrentCell() { - const activeCell = this.notebookModel.cells.find(c => c.handle === this.notebookEditor.getActiveCell()?.handle) || this._currentCell.get(); - if (!activeCell) { - return undefined; - } - const index = this.notebookModel.cells.findIndex(c => c.handle === activeCell.handle); - const integration = this.cellEditorIntegrations.get(activeCell)?.integration; - return integration ? { integration, index: index, handle: activeCell.handle, cell: activeCell } : undefined; - } - - selectCell(cell: NotebookCellTextModel) { - const integration = this.cellEditorIntegrations.get(cell)?.integration; - if (integration) { - this._currentCell.set(cell, undefined); - const cellViewModel = this.notebookEditor.getViewModel()?.viewCells.find(c => c.handle === cell.handle); - if (cellViewModel) { - this.notebookEditor.focusNotebookCell(cellViewModel, 'editor'); - } - } - } - getCell(modifiedCellIndex: number) { const cell = this.notebookModel.cells[modifiedCellIndex]; const integration = this.cellEditorIntegrations.get(cell)?.integration; - return integration ? { integration, index: modifiedCellIndex, handle: cell.handle, cell } : undefined; + return integration; } reveal(firstOrLast: boolean): void { @@ -358,9 +326,9 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const index = firstOrLast || change.type === 'insert' ? 0 : change.diff.get().changes.length - 1; // TODO: check if this breaks for inserted cells const textChange = change.diff.get().changes[index]; - const cell = this.getCell(change.modifiedCellIndex); - if (cell) { - cell.integration.reveal(firstOrLast); + const cellIntegration = this.getCell(change.modifiedCellIndex); + if (cellIntegration) { + cellIntegration.reveal(firstOrLast); this._currentChange.set({ change: change, index }, undefined); } else { const cellViewModel = this.getCellViewModel(change); @@ -374,7 +342,6 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } case 'delete': // reveal the deleted cell decorator - this._currentCell.set(undefined, undefined); this.insertDeleteDecorators.get()?.deletedCellDecorator.reveal(change.originalCellIndex); this._currentChange.set({ change: change, index: 0 }, undefined); return true; @@ -406,7 +373,6 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const firstChange = changes[0]; if (firstChange) { - this._currentCell.set(undefined, undefined); return this._revealChange(firstChange); } @@ -418,10 +384,10 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I switch (currentChange.change.type) { case 'modified': { - const currentChangeInfo = this.getCell(currentChange.change.modifiedCellIndex); - if (currentChangeInfo) { - if (currentChangeInfo.integration.next(false)) { - this._currentChange.set({ change: currentChange.change, index: currentChangeInfo.integration.currentIndex.get() }, undefined); + const cellIntegration = this.getCell(currentChange.change.modifiedCellIndex); + if (cellIntegration) { + if (cellIntegration.next(false)) { + this._currentChange.set({ change: currentChange.change, index: cellIntegration.currentIndex.get() }, undefined); return true; } } @@ -459,7 +425,6 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I if (!currentChange) { const lastChange = changes[changes.length - 1]; if (lastChange) { - this._currentCell.set(undefined, undefined); return this._revealChange(lastChange, false); } @@ -471,10 +436,10 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I switch (currentChange.change.type) { case 'modified': { - const currentChangeInfo = this.getCell(currentChange.change.modifiedCellIndex); - if (currentChangeInfo) { - if (currentChangeInfo.integration.previous(false)) { - this._currentChange.set({ change: currentChange.change, index: currentChangeInfo.integration.currentIndex.get() }, undefined); + const cellIntegration = this.getCell(currentChange.change.modifiedCellIndex); + if (cellIntegration) { + if (cellIntegration.previous(false)) { + this._currentChange.set({ change: currentChange.change, index: cellIntegration.currentIndex.get() }, undefined); return true; } } @@ -510,7 +475,11 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } enableAccessibleDiffView(): void { - this.getCurrentCell()?.integration.enableAccessibleDiffView(); + const cell = this.notebookEditor.getActiveCell()?.model; + if (cell) { + const integration = this.cellEditorIntegrations.get(cell)?.integration; + integration?.enableAccessibleDiffView(); + } } acceptNearestChange(change: IModifiedFileEntryChangeHunk): void { change.accept(); From 5e317a1e5c2977c4f7faf2c66d10f814ae5e6f1a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 11 Mar 2025 18:39:03 -0400 Subject: [PATCH 018/255] don't keep no suggestions around as a user keeps typing (#242699) --- .../suggest/browser/terminalSuggestAddon.ts | 5 ++--- .../services/suggest/browser/simpleSuggestWidget.ts | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index d54e7c977e2..7346f6d6938 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -502,7 +502,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } const xtermBox = this._screen!.getBoundingClientRect(); - this._suggestWidget.showSuggestions(0, false, false, { + this._suggestWidget.showSuggestions(0, false, true, { left: xtermBox.left + this._terminal.buffer.active.cursorX * dimensions.width, top: xtermBox.top + this._terminal.buffer.active.cursorY * dimensions.height, height: dimensions.height @@ -613,8 +613,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } const xtermBox = this._screen!.getBoundingClientRect(); - - suggestWidget.showSuggestions(0, false, false, { + suggestWidget.showSuggestions(0, false, !explicitlyInvoked, { left: xtermBox.left + this._terminal.buffer.active.cursorX * dimensions.width, top: xtermBox.top + this._terminal.buffer.active.cursorY * dimensions.height, height: dimensions.height diff --git a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts index 7109812270b..7c4db50d7ba 100644 --- a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts +++ b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts @@ -24,6 +24,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { canExpandCompletionItem, SimpleSuggestDetailsOverlay, SimpleSuggestDetailsWidget } from './simpleSuggestWidgetDetails.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import * as strings from '../../../../base/common/strings.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; const $ = dom.$; @@ -439,7 +440,6 @@ export class SimpleSuggestWidget, TI } private _setState(state: State): void { - if (this._state === state) { return; } @@ -451,9 +451,11 @@ export class SimpleSuggestWidget, TI switch (state) { case State.Hidden: if (this._status) { - dom.hide(this._messageElement, this._listElement, this._status.element); + dom.hide(this._status.element); } dom.hide(this._listElement); + dom.hide(this._messageElement); + dom.hide(this.element.domNode); this._details.hide(true); this._status?.hide(); // this._contentWidget.hide(); @@ -490,6 +492,7 @@ export class SimpleSuggestWidget, TI this._details.hide(); this._show(); this._focusedItem = undefined; + status(SimpleSuggestWidget.NO_SUGGESTIONS_MESSAGE); break; case State.Open: dom.hide(this._messageElement); From 11cb11ac108db993644e36851f631941d97d9358 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:50:39 -0700 Subject: [PATCH 019/255] move init code to appropriate autorun (#243277) Co-authored-by: amunger <> --- .../chatEditingNotebookEditorIntegration.ts | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts index 4379d0774d5..7a3abd9ae00 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts @@ -141,6 +141,19 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I && cellChanges.read(r).some(c => c.type !== 'unchanged' && c.type !== 'delete' && !c.diff.read(r).identical) ) { lastModifyingRequestId = _entry.lastModifyingRequestId; + + const sortedCellChanges = sortCellChanges(cellChanges.read(r)); + const values = new Uint32Array(sortedCellChanges.length); + for (let i = 0; i < sortedCellChanges.length; i++) { + const change = sortedCellChanges[i]; + values[i] = change.type === 'insert' ? 1 + : change.type === 'delete' ? 1 + : change.type === 'modified' ? change.diff.read(r).changes.length + : 0; + } + + this.diffIndexPrefixSum = new PrefixSumComputer(values); + this.reveal(true); } })); @@ -211,33 +224,6 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I }); })); - const cellsAreVisible = onDidChangeVisibleRanges.map(v => v.length > 0); - - // Focus - this._register(autorun(r => { - const sortedCellChanges = sortCellChanges(cellChanges.read(r)); - - const values = new Uint32Array(sortedCellChanges.length); - for (let i = 0; i < sortedCellChanges.length; i++) { - const change = sortedCellChanges[i]; - values[i] = change.type === 'insert' ? 1 - : change.type === 'delete' ? 1 - : change.type === 'modified' ? change.diff.read(r).changes.length - : 0; - } - - this.diffIndexPrefixSum = new PrefixSumComputer(values); - - const changes = sortedCellChanges.filter(c => c.type !== 'unchanged' && c.type !== 'delete' && !c.diff.read(r).identical); - if (!changes.length || !cellsAreVisible.read(r)) { - return; - } - - // set initial index - this._currentIndex.set(0, undefined); - this._revealChange(sortedCellChanges[0]); - })); - this._register(autorun(r => { const currentChange = this.currentChange.read(r); if (currentChange) { @@ -289,6 +275,7 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I }; }); + const cellsAreVisible = onDidChangeVisibleRanges.map(v => v.length > 0); this._register(autorun(r => { if (!cellsAreVisible.read(r)) { return; From 88c3f76f25652c02e840adc99e807ab5e25ed6ec Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 16:32:12 -0700 Subject: [PATCH 020/255] update tests --- .../mcp/test/common/mcpRegistry.test.ts | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index d7493cdccaa..57f2c727140 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -188,7 +188,7 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual(registry.delegates.length, 0); }); - test('resolveConnection creates connection with resolved variables and memorizes them', async () => { + test('resolveConnection creates connection with resolved variables and memorizes them until cleared', async () => { const definition: McpServerDefinition = { ...baseDefinition, launch: { @@ -223,36 +223,13 @@ suite('Workbench - MCP - Registry', () => { assert.ok(connection2); assert.strictEqual((connection2.launchDefinition as any).env.PATH, 'interactiveValue0'); connection2.dispose(); - }); - test('resolveConnection with stored variables resolves them', async () => { - const definition: McpServerDefinition = { - ...baseDefinition, - launch: { - type: McpServerTransportType.Stdio, - command: '${storedVar}', - args: [], - env: {}, - cwd: URI.parse('file:///test') - }, - variableReplacement: { - section: 'mcp' - } - }; + registry.clearSavedInputs(); - // Save some mock inputs - const key = `mcpConfig.${testCollection.id}.${definition.id}`; - testStorageService.store(key, { 'storedVar': 'resolved-value' }, StorageScope.APPLICATION, StorageTarget.MACHINE); + const connection3 = await registry.resolveConnection(testCollection, definition) as McpServerConnection; - // Register a delegate that can handle the connection - const delegate = new TestMcpHostDelegate(); - const disposable = registry.registerDelegate(delegate); - store.add(disposable); - - const connection = await registry.resolveConnection(testCollection, definition) as McpServerConnection; - - assert.ok(connection); - assert.strictEqual((connection.launchDefinition as any).command, 'resolved-value'); - connection.dispose(); + assert.ok(connection3); + assert.strictEqual((connection3.launchDefinition as any).env.PATH, 'interactiveValue4'); + connection3.dispose(); }); }); From 942a5d72a7170ac51bc13cacc08247d5a34c514d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 11 Mar 2025 16:36:54 -0700 Subject: [PATCH 021/255] Add ChatMode, make agent mode be session-specific, not global (#243280) * Add ChatMode, make agent mode be session-specific, not global Towards unifying chat views * Fix mode for transferred session and request parser --- .../api/browser/mainThreadChatAgents2.ts | 4 +- .../chat/browser/actions/chatClearActions.ts | 6 +- .../browser/actions/chatExecuteActions.ts | 15 ++- .../chatConfirmationContentPart.ts | 4 +- .../contrib/chat/browser/chatInputPart.ts | 105 +++++++++++++----- .../chatMarkdownDecorationsRenderer.ts | 6 +- .../contrib/chat/browser/chatViewPane.ts | 27 +++-- .../contrib/chat/browser/chatWidget.ts | 12 +- .../browser/contrib/chatInputCompletions.ts | 4 +- .../browser/contrib/chatInputEditorContrib.ts | 4 +- .../viewsWelcome/chatViewWelcomeController.ts | 4 +- .../contrib/chat/common/chatAgents.ts | 33 ++---- .../contrib/chat/common/chatRequestParser.ts | 8 +- .../contrib/chat/common/chatService.ts | 4 +- .../contrib/chat/common/chatServiceImpl.ts | 20 ++-- .../chat/common/chatWidgetHistoryService.ts | 2 + .../contrib/chat/common/constants.ts | 10 ++ .../chat/test/common/chatAgents.test.ts | 3 +- .../chat/test/common/voiceChatService.test.ts | 6 +- 19 files changed, 163 insertions(+), 114 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/constants.ts diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 510f26d520a..37e32ea1a82 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -138,8 +138,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const inputValue = widget?.inputEditor.getValue() ?? ''; const location = widget.location; - const toolsAgentModeEnabled = this._chatAgentService.toolsAgentModeEnabled; - this._chatService.transferChatSession({ sessionId, inputValue, location, toolsAgentModeEnabled }, URI.revive(toWorkspace)); + const mode = widget.input.currentMode; + this._chatService.transferChatSession({ sessionId, inputValue, location, mode }, URI.revive(toWorkspace)); } async $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: IDynamicChatAgentProps | undefined): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts index 6187720a229..85b6746bd26 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts @@ -15,9 +15,10 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { isChatViewTitleActionContext } from '../../common/chatActions.js'; -import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; +import { ChatAgentLocation } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatMode } from '../../common/constants.js'; import { ChatViewId, EditsViewId, IChatWidget, IChatWidgetService } from '../chat.js'; import { EditingSessionAction } from '../chatEditing/chatEditingActions.js'; import { ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; @@ -198,7 +199,6 @@ export function registerNewChatActions() { const widgetService = accessor.get(IChatWidgetService); const dialogService = accessor.get(IDialogService); const viewsService = accessor.get(IViewsService); - const agentService = accessor.get(IChatAgentService); if (!(await this._handleCurrentEditingSession(editingSession, dialogService))) { return; @@ -233,7 +233,7 @@ export function registerNewChatActions() { } if (!isChatViewTitleAction && typeof context.agentMode === 'boolean') { - agentService.toggleToolsAgentMode(context.agentMode); + widget.input.setChatMode(context.agentMode ? ChatMode.Agent : ChatMode.Edit); } if (context.inputValue) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 06024489347..df2139fb6b1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -18,6 +18,7 @@ import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IChatEditingService, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { chatAgentLeader, extractAgentAndCommand } from '../../common/chatParserTypes.js'; import { IChatService } from '../../common/chatService.js'; +import { ChatMode } from '../../common/constants.js'; import { EditsViewId, IChatWidget, IChatWidgetService } from '../chat.js'; import { discardAllEditsWithConfirmation, EditingSessionAction } from '../chatEditing/chatEditingActions.js'; import { ChatViewPane } from '../chatViewPane.js'; @@ -103,18 +104,14 @@ export class ToggleAgentModeAction extends EditingSessionAction { constructor() { super({ id: ToggleAgentModeAction.ID, - title: localize2('interactive.toggleAgent.label', "Toggle Agent Mode (Experimental)"), + title: localize2('interactive.toggleMode.label', "Toggle Chat Mode (Experimental)"), f1: true, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeys.Editing.hasToolsAgent, ChatContextKeys.requestInProgress.negate()), - toggled: { - condition: ChatContextKeys.Editing.agentMode, - tooltip: localize('agentEnabled', "Agent Mode Enabled (Experimental)"), - }, - tooltip: localize('agentDisabled', "Agent Mode Disabled"), + tooltip: localize('setChatMode', "Set Mode (Experimental)"), keybinding: { when: ContextKeyExpr.and( ChatContextKeys.inChatInput, @@ -137,7 +134,6 @@ export class ToggleAgentModeAction extends EditingSessionAction { override async runEditingSessionAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: any[]) { - const agentService = accessor.get(IChatAgentService); const chatService = accessor.get(IChatService); const commandService = accessor.get(ICommandService); const dialogService = accessor.get(IDialogService); @@ -164,7 +160,10 @@ export class ToggleAgentModeAction extends EditingSessionAction { } const arg = args[0] as IToggleAgentModeArgs | undefined; - agentService.toggleToolsAgentMode(typeof arg?.agentMode === 'boolean' ? arg.agentMode : undefined); + const setTo = arg ? + (arg.agentMode ? ChatMode.Agent : ChatMode.Edit) : + (chatWidget.input.toolsAgentModeEnabled ? ChatMode.Edit : ChatMode.Agent); + chatWidget.input.setChatMode(setTo); await commandService.executeCommand(ChatDoneActionId); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts index 68a8d913c37..c3361ccfd3b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts @@ -53,7 +53,9 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont options.agentId = element.agent?.id; options.slashCommand = element.slashCommand?.name; options.confirmation = e.label; - options.userSelectedModelId = chatWidgetService.getWidgetBySessionId(element.sessionId)?.input.currentLanguageModel; + const widget = chatWidgetService.getWidgetBySessionId(element.sessionId); + options.userSelectedModelId = widget?.input.currentLanguageModel; + options.mode = widget?.input.currentMode; if (await this.chatService.sendRequest(element.sessionId, prompt, options)) { confirmation.isUsed = true; confirmationWidget.setShowButtons(false); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 707265a92e0..1c755c9ff90 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -76,6 +76,7 @@ import { IChatFollowup } from '../common/chatService.js'; import { IChatVariablesService } from '../common/chatVariables.js'; import { IChatResponseViewModel } from '../common/chatViewModel.js'; import { ChatInputHistoryMaxEntries, IChatHistoryEntry, IChatInputState, IChatWidgetHistoryService } from '../common/chatWidgetHistoryService.js'; +import { ChatMode } from '../common/constants.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; import { CancelAction, ChatSubmitAction, ChatSubmitSecondaryAgentAction, ChatSwitchToNextModelActionId, IChatExecuteActionContext, IToggleAgentModeArgs, ToggleAgentModeActionId } from './actions/chatExecuteActions.js'; import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js'; @@ -300,6 +301,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentLanguageModel?.identifier; } + private _onDidChangeCurrentChatMode = this._register(new Emitter()); + private _currentMode: ChatMode = ChatMode.Chat; + public get toolsAgentModeEnabled(): boolean { + return this._currentMode === ChatMode.Agent; + } + + public get currentMode(): ChatMode { + return this._currentMode === ChatMode.Agent && !this.agentService.hasToolsAgent ? ChatMode.Edit : this._currentMode; + } + private cachedDimensions: dom.Dimension | undefined; private cachedExecuteToolbarWidth: number | undefined; private cachedInputToolbarWidth: number | undefined; @@ -361,7 +372,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IStorageService private readonly storageService: IStorageService, @ILabelService private readonly labelService: ILabelService, @IChatVariablesService private readonly variableService: IChatVariablesService, - @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IChatAgentService private readonly agentService: IChatAgentService, ) { super(); @@ -372,6 +383,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { ...getContribsInputState(), chatContextAttachments: this._attachmentModel.attachments, + chatMode: this._currentMode, }; }; this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; @@ -436,7 +448,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - this._register(this.chatAgentService.onDidChangeToolsAgentModeEnabled(() => { + this._register(this._onDidChangeCurrentChatMode.event(() => { this.checkModelSupported(); })); } @@ -456,9 +468,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + setChatMode(mode: ChatMode): void { + this._currentMode = mode; + this._onDidChangeCurrentChatMode.fire(); + } + private modelSupportedForDefaultAgent(model: ILanguageModelChatMetadataAndIdentifier): boolean { // Probably this logic could live in configuration on the agent, or somewhere else, if it gets more complex - if (this.chatAgentService.getDefaultAgent(this.location)?.isToolsAgent) { + if (this._currentMode === ChatMode.Agent) { if (this.configurationService.getValue('chat.agent.allModels')) { return true; } @@ -538,6 +555,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (state.inputValue) { this.setValue(state.inputValue, false); } + + if (state.inputState?.chatMode) { + this._currentMode = state.inputState.chatMode; + } else if (this.location === ChatAgentLocation.EditingSession) { + this._currentMode = ChatMode.Edit; + } } logInputHistory(): void { @@ -909,7 +932,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this.instantiationService.createInstance(ModelPickerActionViewItem, action, this._currentLanguageModel, itemDelegate); } } else if (action.id === ToggleAgentModeActionId && action instanceof MenuItemAction) { - return this.instantiationService.createInstance(ToggleAgentActionViewItem, action); + const delegate: IModePickerDelegate = { + getMode: () => this._currentMode, + onDidChangeMode: this._onDidChangeCurrentChatMode.event + }; + return this.instantiationService.createInstance(ToggleAgentActionViewItem, action, delegate); } return undefined; @@ -1469,45 +1496,65 @@ class ModelPickerActionViewItem extends DropdownMenuActionViewItemWithKeybinding const chatInputEditorContainerSelector = '.interactive-input-editor'; setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); -class ToggleAgentActionViewItem extends DropdownMenuActionViewItemWithKeybinding { - private readonly agentStateActions: IAction[]; +interface IModePickerDelegate { + onDidChangeMode: Event; + getMode(): ChatMode; +} +class ToggleAgentActionViewItem extends DropdownMenuActionViewItemWithKeybinding { constructor( action: MenuItemAction, + private readonly delegate: IModePickerDelegate, @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, @IContextKeyService contextKeyService: IContextKeyService, ) { - const agentStateActions = [ - { - ...action, - id: 'agentMode', - label: localize('chat.agentMode', "Agent"), - class: undefined, - enabled: true, - run: () => action.run({ agentMode: true } satisfies IToggleAgentModeArgs) - }, - { - ...action, - id: 'normalMode', - label: localize('chat.normalMode', "Edit"), - class: undefined, - enabled: true, - checked: !action.checked, - run: () => action.run({ agentMode: false } satisfies IToggleAgentModeArgs) - }, - ]; + const makeAction = (mode: ChatMode): IAction => ({ + ...action, + id: mode, + label: this.modeToString(mode), + class: undefined, + enabled: true, + checked: delegate.getMode() === mode, + run: async () => { + const result = await action.run({ agentMode: mode === ChatMode.Agent } satisfies IToggleAgentModeArgs); + this.renderLabel(this.element!); + return result; + } + }); - super(action, agentStateActions, contextMenuService, undefined, keybindingService, contextKeyService); - this.agentStateActions = agentStateActions; + const actionProvider: IActionProvider = { + getActions: () => { + const agentStateActions = [ + makeAction(ChatMode.Agent), + makeAction(ChatMode.Edit), + ]; + return agentStateActions; + } + }; + + super(action, actionProvider, contextMenuService, undefined, keybindingService, contextKeyService); + + this._register(delegate.onDidChangeMode(() => this.renderLabel(this.element!))); + } + + private modeToString(mode: ChatMode) { + switch (mode) { + case ChatMode.Agent: + return localize('chat.agentMode', "Agent"); + case ChatMode.Edit: + return localize('chat.normalMode', "Edit"); + case ChatMode.Chat: + return localize('chat.chatMode', "Chat"); + } } protected override renderLabel(element: HTMLElement): IDisposable | null { // Can't call super.renderLabel because it has a hack of forcing the 'codicon' class this.setAriaLabelAttributes(element); - const state = this.agentStateActions.find(action => action.checked)?.label ?? ''; - dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); + const state = this.delegate.getMode(); + dom.reset(element, dom.$('span.chat-model-label', undefined, this.modeToString(state)), ...renderLabelWithIcons(`$(chevron-down)`)); return null; } diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts index 0c3186b3b9f..3b763727a52 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts @@ -193,7 +193,8 @@ export class ChatMarkdownDecorationsRenderer { { location: widget.location, agentId: agent.id, - userSelectedModelId: widget.input.currentLanguageModel + userSelectedModelId: widget.input.currentLanguageModel, + mode: widget.input.currentMode }); })); } else { @@ -229,7 +230,8 @@ export class ChatMarkdownDecorationsRenderer { location: widget.location, agentId: agent.id, slashCommand: args.command, - userSelectedModelId: widget.input.currentLanguageModel + userSelectedModelId: widget.input.currentLanguageModel, + mode: widget.input.currentMode }); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 6e9563085f7..57b36dcfa1d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -30,6 +30,7 @@ import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatModelInitState, IChatModel } from '../common/chatModel.js'; import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; import { IChatService } from '../common/chatService.js'; +import { ChatMode } from '../common/constants.js'; import { ChatWidget, IChatViewState } from './chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; @@ -76,8 +77,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(this.chatAgentService.onDidChangeAgents(() => { if (this.chatAgentService.getDefaultAgent(this.chatOptions?.location)) { if (!this._widget?.viewModel) { - const sessionId = this.getSessionId(); - const model = sessionId ? this.chatService.getOrRestoreSession(sessionId) : undefined; + const info = this.getTransferredOrPersistedSessionInfo(); + const model = info.sessionId ? this.chatService.getOrRestoreSession(info.sessionId) : undefined; // The widget may be hidden at this point, because welcome views were allowed. Use setVisible to // avoid doing a render while the widget is hidden. This is changing the condition in `shouldShowWelcome` @@ -85,7 +86,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const wasVisible = this._widget.visible; try { this._widget.setVisible(false); - this.updateModel(model); + this.updateModel(model, info.inputValue || info.mode ? { inputState: { chatMode: info.mode }, inputValue: info.inputValue } : undefined); this.defaultParticipantRegistrationFailed = false; this.didUnregisterProvider = false; this._onDidChangeViewWelcomeState.fire(); @@ -144,15 +145,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return !!shouldShow; } - private getSessionId() { - let sessionId: string | undefined; + private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputValue?: string; mode?: ChatMode } { if (this.chatService.transferredSessionData?.location === this.chatOptions.location) { - sessionId = this.chatService.transferredSessionData.sessionId; - this.viewState.inputValue = this.chatService.transferredSessionData.inputValue; + const sessionId = this.chatService.transferredSessionData.sessionId; + return { + sessionId, + inputValue: this.chatService.transferredSessionData.inputValue, + mode: this.chatService.transferredSessionData.mode + }; } else { - sessionId = this.viewState.sessionId; + return { sessionId: this.viewState.sessionId }; } - return sessionId; } protected override renderBody(parent: HTMLElement): void { @@ -201,7 +204,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(this._widget.onDidClear(() => this.clear())); this._widget.render(parent); - const sessionId = this.getSessionId(); + const info = this.getTransferredOrPersistedSessionInfo(); const disposeListener = this._register(this.chatService.onDidDisposeSession((e) => { // Render the welcome view if provider registration fails, eg when signed out. This activates for any session, but the problem is the same regardless if (e.reason === 'initializationFailed') { @@ -210,9 +213,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._onDidChangeViewWelcomeState.fire(); } })); - const model = sessionId ? this.chatService.getOrRestoreSession(sessionId) : undefined; + const model = info.sessionId ? this.chatService.getOrRestoreSession(info.sessionId) : undefined; - this.updateModel(model); + this.updateModel(model, info.inputValue || info.mode ? { inputState: { chatMode: info.mode }, inputValue: info.inputValue } : undefined); } catch (e) { this.logService.error(e); throw e; diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index b7062cf6024..0be625675db 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -48,6 +48,7 @@ import { IChatSlashCommandService } from '../common/chatSlashCommands.js'; import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/chatViewModel.js'; import { IChatInputState } from '../common/chatWidgetHistoryService.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; +import { ChatMode } from '../common/constants.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions } from './chat.js'; import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; @@ -200,7 +201,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return { text: '', parts: [] }; } - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentMode }); } return this.parsedChatRequest; @@ -538,7 +539,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!this.viewModel) { return; } - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentMode }); this._onDidChangeParsedInput.fire(); } @@ -638,7 +639,7 @@ export class ChatWidget extends Disposable implements IChatWidget { { ...welcomeContent, tips, }, { location: this.location, - isWidgetWelcomeViewContent: true + isWidgetAgentWelcomeViewContent: this.input?.currentMode === ChatMode.Agent } )); dom.append(this.welcomeMessageContainer, welcomePart.element); @@ -859,7 +860,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } let msg = ''; - if (e.followup.agentId && e.followup.agentId !== this.chatAgentService.getDefaultAgent(this.location)?.id) { + if (e.followup.agentId && e.followup.agentId !== this.chatAgentService.getDefaultAgent(this.location, this.input.currentMode)?.id) { const agent = this.chatAgentService.getAgent(e.followup.agentId); if (!agent) { return; @@ -1137,10 +1138,11 @@ export class ChatWidget extends Disposable implements IChatWidget { this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionId); const result = await this.chatService.sendRequest(this.viewModel.sessionId, input, { + mode: this.inputPart.currentMode, userSelectedModelId: this.inputPart.currentLanguageModel, location: this.location, locationData: this._location.resolveData?.(), - parserContext: { selectedAgent: this._lastSelectedAgent }, + parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.inputPart.currentMode }, attachedContext, noCommandDetection: options?.noCommandDetection, hasInstructionAttachments: this.inputPart.hasInstructionAttachments, diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index 60ce746437c..64853cc5daa 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -265,7 +265,7 @@ class AgentCompletions extends Disposable { return { suggestions: justAgents.concat( coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { - if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location, widget.input.currentMode)?.id !== agent.id) { return; } @@ -324,7 +324,7 @@ class AgentCompletions extends Disposable { return { suggestions: coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { - if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location, widget.input.currentMode)?.id !== agent.id) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index e0997913f21..06957dd285a 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -112,7 +112,7 @@ class InputEditorDecorations extends Disposable { } if (!inputValue) { - const defaultAgent = this.chatAgentService.getDefaultAgent(this.widget.location); + const defaultAgent = this.chatAgentService.getDefaultAgent(this.widget.location, this.widget.input.currentMode); const decoration: IDecorationOptions[] = [ { range: { @@ -301,7 +301,7 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent }); + const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentMode }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestToolPart); diff --git a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts index eb8eadcfa0d..bbe6404c8fa 100644 --- a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts +++ b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts @@ -116,7 +116,7 @@ export interface IChatViewWelcomeContent { export interface IChatViewWelcomeRenderOptions { firstLinkToButton?: boolean; location: ChatAgentLocation; - isWidgetWelcomeViewContent?: boolean; + isWidgetAgentWelcomeViewContent?: boolean; } export class ChatViewWelcomePart extends Disposable { @@ -147,7 +147,7 @@ export class ChatViewWelcomePart extends Disposable { title.textContent = content.title; // Preview indicator - if (options?.location === ChatAgentLocation.EditingSession && typeof content.message !== 'function' && chatAgentService.toolsAgentModeEnabled && options.isWidgetWelcomeViewContent) { + if (options?.location === ChatAgentLocation.EditingSession && typeof content.message !== 'function' && options.isWidgetAgentWelcomeViewContent) { // Override welcome message for the agent. Sort of a hack, should it come from the participant? This case is different because the welcome content typically doesn't change per ChatWidget const agentMessage = localize({ key: 'agentMessage', comment: ['{Locked="["}', '{Locked="]({0})"}'] }, "Ask Copilot to edit your files in [agent mode]({0}). Copilot will automatically use multiple requests to pick files to edit, run terminal commands, and iterate on errors.\n\nCopilot is powered by AI, so mistakes are possible. Review output carefully before use.", 'https://aka.ms/vscode-copilot-agent'); content.message = new MarkdownString(agentMessage); diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index 633010ae07e..dca99689801 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -27,6 +27,7 @@ import { ChatContextKeys } from './chatContextKeys.js'; import { IChatProgressHistoryResponseContent, IChatRequestVariableData, ISerializableChatAgentData } from './chatModel.js'; import { IRawChatCommandContribution, RawChatParticipantLocation } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from './chatService.js'; +import { ChatMode } from './constants.js'; //#region agent service, commands etc @@ -206,9 +207,7 @@ export interface IChatAgentService { * undefined when an agent was removed */ readonly onDidChangeAgents: Event; - readonly onDidChangeToolsAgentModeEnabled: Event; - readonly toolsAgentModeEnabled: boolean; - toggleToolsAgentMode(enabled?: boolean): void; + readonly hasToolsAgent: boolean; registerAgent(id: string, data: IChatAgentData): IDisposable; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable; registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable; @@ -231,7 +230,7 @@ export interface IChatAgentService { /** * Get the default agent (only if activated) */ - getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined; + getDefaultAgent(location: ChatAgentLocation, mode?: ChatMode): IChatAgent | undefined; /** * Get the default agent data that has been contributed (may not be activated yet) @@ -241,8 +240,6 @@ export interface IChatAgentService { updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; } -const ChatToolsAgentModeStorageKey = 'chat.toolsAgentMode'; - export class ChatAgentService extends Disposable implements IChatAgentService { public static readonly AGENT_LEADER = '@'; @@ -254,21 +251,16 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private readonly _onDidChangeAgents = new Emitter(); readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; - private readonly _onDidChangeToolsAgentModeEnabled = new Emitter(); - readonly onDidChangeToolsAgentModeEnabled: Event = this._onDidChangeToolsAgentModeEnabled.event; - private readonly _agentsContextKeys = new Set(); private readonly _hasDefaultAgent: IContextKey; private readonly _defaultAgentRegistered: IContextKey; private readonly _editingAgentRegistered: IContextKey; - private readonly _agentModeContextKey: IContextKey; private readonly _hasToolsAgentContextKey: IContextKey; private _chatParticipantDetectionProviders = new Map(); constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IStorageService private readonly storageService: IStorageService, ) { super(); this._hasDefaultAgent = ChatContextKeys.enabled.bindTo(this.contextKeyService); @@ -280,12 +272,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } })); - this._agentModeContextKey = ChatContextKeys.Editing.agentMode.bindTo(contextKeyService); this._hasToolsAgentContextKey = ChatContextKeys.Editing.hasToolsAgent.bindTo(contextKeyService); - this._agentModeContextKey.set( - this.storageService.getBoolean(ChatToolsAgentModeStorageKey, StorageScope.WORKSPACE, false)); - this._register( - this.storageService.onWillSaveState(() => this.storageService.store(ChatToolsAgentModeStorageKey, this._agentModeContextKey.get(), StorageScope.WORKSPACE, StorageTarget.USER))); } registerAgent(id: string, data: IChatAgentData): IDisposable { @@ -412,9 +399,9 @@ export class ChatAgentService extends Disposable implements IChatAgentService { this._onDidChangeAgents.fire(new MergedChatAgent(agent.data, agent.impl)); } - getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined { + getDefaultAgent(location: ChatAgentLocation, mode?: ChatMode): IChatAgent | undefined { return findLast(this.getActivatedAgents(), a => { - if (location === ChatAgentLocation.EditingSession && this.toolsAgentModeEnabled !== !!a.isToolsAgent) { + if (location === ChatAgentLocation.EditingSession && ((mode === ChatMode.Agent) !== !!a.isToolsAgent)) { return false; } @@ -422,14 +409,8 @@ export class ChatAgentService extends Disposable implements IChatAgentService { }); } - public get toolsAgentModeEnabled(): boolean { - return !!this._hasToolsAgentContextKey.get() && !!this._agentModeContextKey.get(); - } - - toggleToolsAgentMode(enabled?: boolean): void { - this._agentModeContextKey.set(enabled ?? !this._agentModeContextKey.get()); - this._onDidChangeToolsAgentModeEnabled.fire(); - this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); + public get hasToolsAgent(): boolean { + return !!this._hasToolsAgentContextKey.get(); } getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { diff --git a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts index 230b17fc556..c6359f5c4ff 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts @@ -10,6 +10,7 @@ import { ChatAgentLocation, IChatAgentData, IChatAgentService } from './chatAgen import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; import { IChatVariablesService, IDynamicVariable } from './chatVariables.js'; +import { ChatMode } from './constants.js'; import { ILanguageModelToolsService } from './languageModelToolsService.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent @@ -19,6 +20,7 @@ const slashReg = /\/([\w_\-]+)(?=(\s|$|\b))/i; // A / command export interface IChatParserContext { /** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */ selectedAgent?: IChatAgentData; + mode?: ChatMode; } export class ChatRequestParser { @@ -45,7 +47,7 @@ export class ChatRequestParser { } else if (char === chatAgentLeader) { newPart = this.tryToParseAgent(message.slice(i), message, i, new Position(lineNumber, column), parts, location, context); } else if (char === chatSubcommandLeader) { - newPart = this.tryToParseSlashCommand(message.slice(i), message, i, new Position(lineNumber, column), parts, location); + newPart = this.tryToParseSlashCommand(message.slice(i), message, i, new Position(lineNumber, column), parts, location, context); } if (!newPart) { @@ -157,7 +159,7 @@ export class ChatRequestParser { return; } - private tryToParseSlashCommand(remainingMessage: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation): ChatRequestSlashCommandPart | ChatRequestAgentSubcommandPart | undefined { + private tryToParseSlashCommand(remainingMessage: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation, context?: IChatParserContext): ChatRequestSlashCommandPart | ChatRequestAgentSubcommandPart | undefined { const nextSlashMatch = remainingMessage.match(slashReg); if (!nextSlashMatch) { return; @@ -199,7 +201,7 @@ export class ChatRequestParser { return new ChatRequestSlashCommandPart(slashRange, slashEditorRange, slashCommand); } else { // check for with default agent for this location - const defaultAgent = this.agentService.getDefaultAgent(location); + const defaultAgent = this.agentService.getDefaultAgent(location, context?.mode); const subCommand = defaultAgent?.slashCommands.find(c => c.name === command); if (subCommand) { // Valid default agent subcommand diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 8340deb8945..e777a648233 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -21,6 +21,7 @@ import { ChatModel, IChatModel, IChatRequestModel, IChatRequestVariableData, ICh import { IParsedChatRequest } from './chatParserTypes.js'; import { IChatParserContext } from './chatRequestParser.js'; import { IChatRequestVariableValue } from './chatVariables.js'; +import { ChatMode } from './constants.js'; import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult } from './languageModelToolsService.js'; export interface IChatRequest { @@ -411,7 +412,7 @@ export interface IChatTransferredSessionData { sessionId: string; inputValue: string; location: ChatAgentLocation; - toolsAgentModeEnabled: boolean; + mode: ChatMode; } export interface IChatSendRequestResponseState { @@ -444,6 +445,7 @@ export interface IChatTerminalLocationData { export type IChatLocationData = IChatEditorLocationData | IChatNotebookLocationData | IChatTerminalLocationData; export interface IChatSendRequestOptions { + mode?: ChatMode; userSelectedModelId?: string; location?: ChatAgentLocation; locationData?: IChatLocationData; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index 0dafd8770a3..86c8b630ec6 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -33,6 +33,7 @@ import { IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChat import { ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; import { IChatVariablesService } from './chatVariables.js'; +import { ChatMode } from './constants.js'; import { ChatMessageRole, IChatMessage } from './languageModels.js'; import { ILanguageModelToolsService } from './languageModelToolsService.js'; @@ -45,7 +46,7 @@ interface IChatTransfer { chat: ISerializableChatData; inputValue: string; location: ChatAgentLocation; - toolsAgentModeEnabled: boolean; + mode: ChatMode; } const SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS = 1000 * 60; @@ -171,7 +172,7 @@ export class ChatService extends Disposable implements IChatService { sessionId: transferredChat.sessionId, inputValue: transferredData.inputValue, location: transferredData.location, - toolsAgentModeEnabled: transferredData.toolsAgentModeEnabled, + mode: transferredData.mode, }; } @@ -473,7 +474,8 @@ export class ChatService extends Disposable implements IChatService { const isTransferred = this.transferredSessionData?.sessionId === sessionId; if (isTransferred) { - this.chatAgentService.toggleToolsAgentMode(this.transferredSessionData.toolsAgentModeEnabled); + // TODO + // this.chatAgentService.toggleToolsAgentMode(this.transferredSessionData.toolsAgentModeEnabled); this._transferredSessionData = undefined; } @@ -501,7 +503,7 @@ export class ChatService extends Disposable implements IChatService { const location = options?.location ?? model.initialLocation; const attempt = options?.attempt ?? 0; const enableCommandDetection = !options?.noCommandDetection; - const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; + const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.mode)!; model.removeRequest(request.id, ChatRequestRemovalReason.Resend); @@ -554,7 +556,7 @@ export class ChatService extends Disposable implements IChatService { const location = options?.location ?? model.initialLocation; const attempt = options?.attempt ?? 0; - const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; + const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.mode)!; const parsedRequest = this.parseChatRequest(sessionId, request, location, options); const agent = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; @@ -575,7 +577,7 @@ export class ChatService extends Disposable implements IChatService { if (!agent) { throw new Error(`Unknown agent: ${options.agentId}`); } - parserContext = { selectedAgent: agent }; + parserContext = { selectedAgent: agent, mode: options.mode }; const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : ''; request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`; } @@ -891,13 +893,13 @@ export class ChatService extends Disposable implements IChatService { private getHistoryEntriesFromModel(requests: IChatRequestModel[], sessionId: string, location: ChatAgentLocation, forAgentId: string): IChatAgentHistoryEntry[] { const history: IChatAgentHistoryEntry[] = []; + const agent = this.chatAgentService.getAgent(forAgentId); for (const request of requests) { if (!request.response) { continue; } - const defaultAgentId = this.chatAgentService.getDefaultAgent(location)?.id; - if (forAgentId !== request.response.agent?.id && forAgentId !== defaultAgentId) { + if (forAgentId !== request.response.agent?.id && !agent?.isDefault) { // An agent only gets to see requests that were sent to this agent. // The default agent (the undefined case) gets to see all of them. continue; @@ -1031,7 +1033,7 @@ export class ChatService extends Disposable implements IChatService { toWorkspace: toWorkspace, inputValue: transferredSessionData.inputValue, location: transferredSessionData.location, - toolsAgentModeEnabled: transferredSessionData.toolsAgentModeEnabled, + mode: transferredSessionData.mode, }); this.storageService.store(globalChatKey, JSON.stringify(existingRaw), StorageScope.PROFILE, StorageTarget.MACHINE); diff --git a/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts b/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts index d2ab31d58ce..82ba4748b99 100644 --- a/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts +++ b/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts @@ -12,6 +12,7 @@ import { ChatAgentLocation } from './chatAgents.js'; import { WorkingSetEntryState } from './chatEditingService.js'; import { IChatRequestVariableEntry } from './chatModel.js'; import { CHAT_PROVIDER_ID } from './chatParticipantContribTypes.js'; +import { ChatMode } from './constants.js'; export interface IChatHistoryEntry { text: string; @@ -23,6 +24,7 @@ export interface IChatInputState { [key: string]: any; chatContextAttachments?: ReadonlyArray; chatWorkingSet?: ReadonlyArray<{ uri: URI; state: WorkingSetEntryState }>; + chatMode?: ChatMode; } export const IChatWidgetHistoryService = createDecorator('IChatWidgetHistoryService'); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts new file mode 100644 index 00000000000..9759e6c6316 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum ChatMode { + Chat = 'chat', + Edit = 'edit', + Agent = 'agent' +} diff --git a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts index 580fa44ce45..cec803118e1 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts @@ -9,7 +9,6 @@ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ChatAgentService, IChatAgentData, IChatAgentImplementation } from '../../common/chatAgents.js'; -import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; const testAgentId = 'testAgent'; const testAgentData: IChatAgentData = { @@ -42,7 +41,7 @@ suite('ChatAgents', function () { let contextKeyService: TestingContextKeyService; setup(() => { contextKeyService = new TestingContextKeyService(); - chatAgentService = store.add(new ChatAgentService(contextKeyService, store.add(new TestStorageService()))); + chatAgentService = store.add(new ChatAgentService(contextKeyService)); }); test('registerAgent', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts index 0005bca091e..53def63d490 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts @@ -66,7 +66,6 @@ suite('VoiceChat', () => { class TestChatAgentService implements IChatAgentService { _serviceBrand: undefined; readonly onDidChangeAgents = Event.None; - readonly onDidChangeToolsAgentModeEnabled = Event.None; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable { throw new Error(); } registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable { throw new Error('Method not implemented.'); } invokeAgent(id: string, request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error(); } @@ -86,10 +85,7 @@ suite('VoiceChat', () => { getAgentCompletionItems(id: string, query: string, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } agentHasDupeName(id: string): boolean { throw new Error('Method not implemented.'); } getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } - readonly toolsAgentModeEnabled: boolean = false; - toggleToolsAgentMode(): void { - throw new Error('Method not implemented.'); - } + hasToolsAgent: boolean = false; hasChatParticipantDetectionProviders(): boolean { throw new Error('Method not implemented.'); } From ab32b428cb81390242522cfd077a49c7e0c87226 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 12 Mar 2025 00:41:14 +0100 Subject: [PATCH 022/255] adopt extensions features using gallery capabilities and resources (#243281) * adopt extensions features using gallery capabilities and resources * remove `@` for properties in manifest resources --- src/vs/base/common/product.ts | 6 +- .../common/extensionGalleryService.ts | 223 ++++++++++-------- .../common/extensionManagement.ts | 24 +- .../extensions/browser/extensionEditor.ts | 5 +- .../browser/extensions.contribution.ts | 47 ++-- .../extensions/browser/extensionsActions.ts | 19 +- .../browser/extensionsWorkbenchService.ts | 16 +- .../contrib/extensions/common/extensions.ts | 2 + .../common/extensionManagementService.ts | 22 +- 9 files changed, 217 insertions(+), 147 deletions(-) diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 762e11341aa..0527af13b60 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -95,11 +95,9 @@ export interface IProductConfiguration { readonly extensionsGallery?: { readonly serviceUrl: string; - readonly itemUrl: string; - readonly publisherUrl: string; - readonly resourceUrlTemplate: string; - readonly extensionUrlTemplate: string; readonly controlUrl: string; + readonly extensionUrlTemplate: string; + readonly resourceUrlTemplate: string; readonly nlsBaseUrl: string; }; diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 568a055716b..2acd0d13ad5 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js'; import { IHeaders, IRequestContext, IRequestOptions, isOfflineError } from '../../../base/parts/request/common/request.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; -import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService, EXTENSION_IDENTIFIER_REGEX, SortBy } from './extensionManagement.js'; +import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService, EXTENSION_IDENTIFIER_REGEX, SortBy, FilterType, IExtensionGalleryCapabilities } from './extensionManagement.js'; import { adoptToGalleryExtensionId, areSameExtensions, getGalleryExtensionId, getGalleryExtensionTelemetryData } from './extensionManagementUtil.js'; import { IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { areApiProposalsCompatible, isEngineValid } from '../../extensions/common/extensionValidator.js'; @@ -30,6 +30,16 @@ import { StopWatch } from '../../../base/common/stopwatch.js'; import { format2 } from '../../../base/common/strings.js'; import { IAssignmentService } from '../../assignment/common/assignment.js'; +type ExtensionGalleryConfig = { + readonly serviceUrl: string; + readonly itemUrl: string; + readonly publisherUrl: string; + readonly resourceUrlTemplate: string; + readonly extensionUrlTemplate: string; + readonly controlUrl: string; + readonly nlsBaseUrl: string; +}; + const enum ExtensionGalleryResourceType { ExtensionQueryService = 'ExtensionQueryService', ExtensionLatestVersionUri = 'ExtensionLatestVersionUriTemplate', @@ -42,17 +52,6 @@ const enum ExtensionGalleryResourceType { ExtensionUriTemplate = 'ExtensionUriTemplate', } -const enum FilterType { - Category = 'Category', - ExtensionId = 'ExtensionId', - ExtensionName = 'ExtensionName', - ExcludeWithFlags = 'ExcludeWithFlags', - Featured = 'Featured', - SearchText = 'SearchText', - Tag = 'Tag', - Target = 'Target', -} - const enum Flag { None = 'None', IncludeVersions = 'IncludeVersions', @@ -71,8 +70,8 @@ const enum Flag { } type ExtensionGalleryManifestResource = { - readonly '@id': string; - readonly '@type': string; + readonly id: string; + readonly type: string; }; type ExtensionQueryCapabilityValue = { @@ -90,7 +89,7 @@ interface IExtensionGalleryManifest { readonly flags?: readonly ExtensionQueryCapabilityValue[]; }; readonly signing?: { - readonly allRepositoriesSigned: boolean; + readonly allRepositorySigned: boolean; }; }; } @@ -490,7 +489,21 @@ function setTelemetry(extension: IGalleryExtension, index: number, querySource?: extension.telemetryData = { index, querySource, queryActivityId: extension.queryContext?.[ACTIVITY_HEADER_NAME] }; } -function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGalleryExtensionVersion, allTargetPlatforms: TargetPlatform[], queryContext?: IStringDictionary): IGalleryExtension { +function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: ExtensionGalleryResourceType, version?: string): string | undefined { + for (const resource of manifest.resources) { + const [r, v] = resource.type.split('/'); + if (r !== type) { + continue; + } + if (!version || v === version) { + return resource.id; + } + break; + } + return undefined; +} + +function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGalleryExtensionVersion, allTargetPlatforms: TargetPlatform[], extensionGalleryManifest: IExtensionGalleryManifest, queryContext?: IStringDictionary): IGalleryExtension { const latestVersion = galleryExtension.versions[0]; const assets: IGalleryExtensionAssets = { manifest: getVersionAsset(version, AssetType.Manifest), @@ -504,6 +517,10 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller coreTranslations: getCoreTranslationAssets(version) }; + const detailsViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionDetailsViewUri); + const publisherViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.PublisherViewUri); + const ratingViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionRatingViewUri); + return { type: 'gallery', identifier: { @@ -543,7 +560,10 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller preview: getIsPreview(galleryExtension.flags), isSigned: !!assets.signature, queryContext, - supportLink: getSupportLink(latestVersion) + supportLink: getSupportLink(latestVersion), + detailsLink: detailsViewUri ? format2(detailsViewUri, { publisher: galleryExtension.publisher.publisherName, name: galleryExtension.extensionName }) : undefined, + publisherLink: publisherViewUri ? format2(publisherViewUri, { publisher: galleryExtension.publisher.publisherName }) : undefined, + ratingLink: ratingViewUri ? format2(ratingViewUri, { publisher: galleryExtension.publisher.publisherName, name: galleryExtension.extensionName }) : undefined, }; } @@ -608,6 +628,27 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return !!this.productService.extensionsGallery?.serviceUrl; } + async getCapabilities(): Promise { + const extensionGalleryManifest = await this.getExtensionGalleryManifest(); + + const filters: FilterType[] = []; + for (const capability of extensionGalleryManifest.capabilities.extensionQuery.filtering ?? []) { + filters.push(capability.name as FilterType); + } + + const sortBy: SortBy[] = []; + for (const capability of extensionGalleryManifest.capabilities.extensionQuery.sorting ?? []) { + sortBy.push(capability.name as SortBy); + } + + return { + query: { + sortBy, + filters + } + }; + } + getExtensions(extensionInfos: ReadonlyArray, token: CancellationToken): Promise; getExtensions(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, token: CancellationToken): Promise; async getExtensions(extensionInfos: ReadonlyArray, arg1: any, arg2?: any): Promise { @@ -618,10 +659,11 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const options = CancellationToken.isCancellationToken(arg1) ? {} : arg1 as IExtensionQueryOptions; const token = CancellationToken.isCancellationToken(arg1) ? arg1 : arg2 as CancellationToken; - const resourceApi = (options.preferResourceApi && (this.configurationService.getValue(UseUnpkgResourceApiConfigKey) ?? false)) ? await this.getResourceApi() : undefined; + const extensionGalleryManifest = await this.getExtensionGalleryManifest(); + const resourceApi = (options.preferResourceApi && (this.configurationService.getValue(UseUnpkgResourceApiConfigKey) ?? false)) ? await this.getResourceApi(extensionGalleryManifest) : undefined; const result = resourceApi - ? await this.getExtensionsUsingResourceApi(extensionInfos, options, resourceApi, token) - : await this.getExtensionsUsingQueryApi(extensionInfos, options, token); + ? await this.getExtensionsUsingResourceApi(extensionInfos, options, resourceApi, extensionGalleryManifest, token) + : await this.getExtensionsUsingQueryApi(extensionInfos, options, extensionGalleryManifest, token); const uuids = result.map(r => r.identifier.uuid); const extensionInfosByName: IExtensionInfo[] = []; @@ -643,16 +685,15 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle count: extensionInfosByName.length }); - const extensions = await this.getExtensionsUsingQueryApi(extensionInfosByName, options, token); + const extensions = await this.getExtensionsUsingQueryApi(extensionInfosByName, options, extensionGalleryManifest, token); result.push(...extensions); } return result; } - private async getResourceApi(): Promise<{ uri: string; fallback?: string } | undefined> { - const manifest = await this.getExtensionGalleryManifest(); - const latestVersionResource = this.getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionLatestVersionUri); + private async getResourceApi(extensionGalleryManifest: IExtensionGalleryManifest): Promise<{ uri: string; fallback?: string } | undefined> { + const latestVersionResource = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionLatestVersionUri); if (!latestVersionResource) { return undefined; } @@ -680,7 +721,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return undefined; } - private async getExtensionsUsingQueryApi(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, token: CancellationToken): Promise { + private async getExtensionsUsingQueryApi(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { const names: string[] = [], ids: string[] = [], includePreRelease: (IExtensionIdentifier & { includePreRelease: boolean })[] = [], @@ -729,6 +770,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date }, isQueryForReleaseVersionFromPreReleaseVersion }, + extensionGalleryManifest, token); if (options.source) { @@ -738,7 +780,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return extensions; } - private async getExtensionsUsingResourceApi(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, resourceApi: { uri: string; fallback?: string }, token: CancellationToken): Promise { + private async getExtensionsUsingResourceApi(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, resourceApi: { uri: string; fallback?: string }, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { const result: IGalleryExtension[] = []; const toQuery: IExtensionInfo[] = []; @@ -759,7 +801,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle let galleryExtension: IGalleryExtension | null | 'NOT_FOUND'; try { try { - galleryExtension = await this.getLatestGalleryExtension(extensionInfo, options, resourceApi.uri, token); + galleryExtension = await this.getLatestGalleryExtension(extensionInfo, options, resourceApi.uri, extensionGalleryManifest, token); } catch (error) { if (!resourceApi.fallback) { throw error; @@ -784,7 +826,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle preRelease: !!extensionInfo.preRelease, compatible: !!options.compatible }); - galleryExtension = await this.getLatestGalleryExtension(extensionInfo, options, resourceApi.fallback, token); + galleryExtension = await this.getLatestGalleryExtension(extensionInfo, options, resourceApi.fallback, extensionGalleryManifest, token); } if (galleryExtension === 'NOT_FOUND') { @@ -825,14 +867,14 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle })); if (toQuery.length) { - const extensions = await this.getExtensionsUsingQueryApi(toQuery, options, token); + const extensions = await this.getExtensionsUsingQueryApi(toQuery, options, extensionGalleryManifest, token); result.push(...extensions); } return result; } - private async getLatestGalleryExtension(extensionInfo: IExtensionInfo, options: IExtensionQueryOptions, resourceUriTemplate: string, token: CancellationToken): Promise { + private async getLatestGalleryExtension(extensionInfo: IExtensionInfo, options: IExtensionQueryOptions, resourceUriTemplate: string, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { const [publisher, name] = extensionInfo.id.split('.'); const uri = URI.parse(format2(resourceUriTemplate, { publisher, name })); const rawGalleryExtension = await this.getLatestRawGalleryExtension(extensionInfo.id, uri, token); @@ -855,7 +897,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, allTargetPlatforms); if (rawGalleryExtensionVersion) { - return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms); + return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest); } return null; @@ -975,6 +1017,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } async query(options: IQueryOptions, token: CancellationToken): Promise> { + const extensionGalleryManifest = await this.getExtensionGalleryManifest(); + let text = options.text || ''; const pageSize = options.pageSize ?? 50; @@ -1007,12 +1051,16 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle query = query.withFilter(FilterType.SearchText, text); } - query = query.withSortBy(SortBy.NoneOrRelevance); + if (extensionGalleryManifest.capabilities.extensionQuery.sorting?.some(c => c.name === SortBy.NoneOrRelevance)) { + query = query.withSortBy(SortBy.NoneOrRelevance); + } } else { - query = query.withSortBy(SortBy.InstallCount); + if (extensionGalleryManifest.capabilities.extensionQuery.sorting?.some(c => c.name === SortBy.InstallCount)) { + query = query.withSortBy(SortBy.InstallCount); + } } - if (typeof options.sortBy === 'number') { + if (options.sortBy && extensionGalleryManifest.capabilities.extensionQuery.sorting?.some(c => c.name === options.sortBy)) { query = query.withSortBy(options.sortBy); } @@ -1025,7 +1073,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } const runQuery = async (query: Query, token: CancellationToken) => { - const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, token); + const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, extensionGalleryManifest, token); extensions.forEach((e, index) => setTelemetry(e, ((query.pageNumber - 1) * query.pageSize) + index, options.source)); return { extensions, total }; }; @@ -1041,18 +1089,18 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return { firstPage: extensions, total, pageSize: query.pageSize, getPage }; } - private async queryGalleryExtensions(query: Query, criteria: ExtensionsCriteria, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { + private async queryGalleryExtensions(query: Query, criteria: ExtensionsCriteria, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { if ( this.productService.quality !== 'stable' && (await this.assignmentService?.getTreatment('useLatestPrereleaseAndStableVersionFlag')) ) { - return this.queryGalleryExtensionsUsingIncludeLatestPrereleaseAndStableVersionFlag(query, criteria, token); + return this.queryGalleryExtensionsUsingIncludeLatestPrereleaseAndStableVersionFlag(query, criteria, extensionGalleryManifest, token); } - return this.queryGalleryExtensionsWithAllVersionsAsFallback(query, criteria, token); + return this.queryGalleryExtensionsWithAllVersionsAsFallback(query, criteria, extensionGalleryManifest, token); } - private async queryGalleryExtensionsWithAllVersionsAsFallback(query: Query, criteria: ExtensionsCriteria, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { + private async queryGalleryExtensionsWithAllVersionsAsFallback(query: Query, criteria: ExtensionsCriteria, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { const flags = query.flags; /** @@ -1080,7 +1128,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle * Add necessary extension flags */ query = query.withFlags(...query.flags, Flag.IncludeAssetUri, Flag.IncludeCategoryAndTags, Flag.IncludeFiles, Flag.IncludeStatistics, Flag.IncludeVersionProperties); - const { galleryExtensions: rawGalleryExtensions, total, context } = await this.queryRawGalleryExtensions(query, token); + const { galleryExtensions: rawGalleryExtensions, total, context } = await this.queryRawGalleryExtensions(query, extensionGalleryManifest, token); const hasAllVersions: boolean = !query.flags.includes(Flag.IncludeLatestVersionOnly); if (hasAllVersions) { @@ -1101,7 +1149,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle allTargetPlatforms ); if (rawGalleryExtensionVersion) { - extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, context)); + extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context)); } } return { extensions, total }; @@ -1135,7 +1183,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, allTargetPlatforms ); - const extension = rawGalleryExtensionVersion ? toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, context) : null; + const extension = rawGalleryExtensionVersion ? toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context) : null; if (!extension /** Need all versions if the extension is a pre-release version but * - the query is to look for a release version or @@ -1162,7 +1210,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle .withFlags(...flags.filter(flag => flag !== Flag.IncludeLatestVersionOnly), Flag.IncludeVersions) .withPage(1, needAllVersions.size) .withFilter(FilterType.ExtensionId, ...needAllVersions.keys()); - const { extensions } = await this.queryGalleryExtensions(query, criteria, token); + const { extensions } = await this.queryGalleryExtensions(query, criteria, extensionGalleryManifest, token); this.telemetryService.publicLog2('galleryService:additionalQuery', { duration: stopWatch.elapsed(), count: needAllVersions.size @@ -1176,7 +1224,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return { extensions: result.sort((a, b) => a[0] - b[0]).map(([, extension]) => extension), total }; } - private async queryGalleryExtensionsUsingIncludeLatestPrereleaseAndStableVersionFlag(query: Query, criteria: ExtensionsCriteria, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { + private async queryGalleryExtensionsUsingIncludeLatestPrereleaseAndStableVersionFlag(query: Query, criteria: ExtensionsCriteria, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { /** * If versions criteria exist, then remove latest flags and add all versions flag. @@ -1204,7 +1252,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle * Add necessary extension flags */ query = query.withFlags(...query.flags, Flag.IncludeAssetUri, Flag.IncludeCategoryAndTags, Flag.IncludeFiles, Flag.IncludeStatistics, Flag.IncludeVersionProperties); - const { galleryExtensions: rawGalleryExtensions, total, context } = await this.queryRawGalleryExtensions(query, token); + const { galleryExtensions: rawGalleryExtensions, total, context } = await this.queryRawGalleryExtensions(query, extensionGalleryManifest, token); const extensions: IGalleryExtension[] = []; for (let index = 0; index < rawGalleryExtensions.length; index++) { @@ -1235,7 +1283,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle allTargetPlatforms ); if (rawGalleryExtensionVersion) { - extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, context)); + extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context)); } } @@ -1282,9 +1330,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return rawGalleryExtension.versions[0]; } - private async queryRawGalleryExtensions(query: Query, token: CancellationToken): Promise { - const manifest = await this.getExtensionGalleryManifest(); - const extensionsQueryApi = this.getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionQueryService); + private async queryRawGalleryExtensions(query: Query, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { + const extensionsQueryApi = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionQueryService); if (!extensionsQueryApi) { throw new Error('No extension gallery query service configured.'); @@ -1295,7 +1342,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle .withFlags(...query.flags, Flag.ExcludeNonValidated) .withFilter(FilterType.Target, 'Microsoft.VisualStudio.Code'); - const unpublishedFlag = manifest.capabilities.extensionQuery.flags?.find(f => f.name === Flag.Unpublished); + const unpublishedFlag = extensionGalleryManifest.capabilities.extensionQuery.flags?.find(f => f.name === Flag.Unpublished); /* Always exclude unpublished extensions */ if (unpublishedFlag) { query = query.withFilter(FilterType.ExcludeWithFlags, String(unpublishedFlag.value)); @@ -1305,7 +1352,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle filters: [ { criteria: query.criteria.reduce<{ filterType: number; value?: string }[]>((criteria, c) => { - const criterium = manifest.capabilities.extensionQuery.filtering?.find(f => f.name === c.filterType); + const criterium = extensionGalleryManifest.capabilities.extensionQuery.filtering?.find(f => f.name === c.filterType); if (criterium) { criteria.push({ filterType: criterium.value, @@ -1316,13 +1363,13 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, []), pageNumber: query.pageNumber, pageSize: query.pageSize, - sortBy: manifest.capabilities.extensionQuery.sorting?.find(s => s.name === query.sortBy)?.value, + sortBy: extensionGalleryManifest.capabilities.extensionQuery.sorting?.find(s => s.name === query.sortBy)?.value, sortOrder: query.sortOrder, } ], assetTypes: query.assetTypes, flags: query.flags.reduce((flags, flag) => { - const flagValue = manifest.capabilities.extensionQuery.flags?.find(f => f.name === flag); + const flagValue = extensionGalleryManifest.capabilities.extensionQuery.flags?.find(f => f.name === flag); if (flagValue) { flags |= flagValue.value; } @@ -1483,13 +1530,13 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle let url: string; if (isWeb) { - const resource = this.getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.WebExtensionStatisticsUri); + const resource = getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.WebExtensionStatisticsUri); if (!resource) { return; } url = format2(resource, { publisher, name, version, statTypeValue: type === StatisticType.Install ? '1' : '3' }); } else { - const resource = this.getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionStatisticsUri); + const resource = getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionStatisticsUri); if (!resource) { return; } @@ -1626,7 +1673,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle query = query.withFilter(FilterType.ExtensionName, extensionIdentifier.id); } - const { galleryExtensions } = await this.queryRawGalleryExtensions(query, CancellationToken.None); + const extensionGalleryManifest = await this.getExtensionGalleryManifest(); + const { galleryExtensions } = await this.queryRawGalleryExtensions(query, extensionGalleryManifest, CancellationToken.None); if (!galleryExtensions.length) { return []; } @@ -1807,73 +1855,60 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return { malicious, deprecated, search, extensionsEnabledWithPreRelease }; } - private getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: ExtensionGalleryResourceType, version?: string): string | undefined { - for (const resource of manifest.resources) { - const [r, v] = resource['@type'].split('/'); - if (r !== type) { - continue; - } - if (!version || v === version) { - return resource['@id']; - } - break; - } - return undefined; - } - private async getExtensionGalleryManifest(): Promise { - if (!this.productService.extensionsGallery?.serviceUrl) { + const extensionsGallery = this.productService.extensionsGallery as ExtensionGalleryConfig | undefined; + if (!extensionsGallery?.serviceUrl) { throw new Error('No extension gallery service configured.'); } const resources = [ { - '@id': `${this.productService.extensionsGallery.serviceUrl}/extensionquery`, - '@type': ExtensionGalleryResourceType.ExtensionQueryService + id: `${extensionsGallery.serviceUrl}/extensionquery`, + type: ExtensionGalleryResourceType.ExtensionQueryService }, { - '@id': `${this.productService.extensionsGallery.serviceUrl}/vscode/{publisher}/{name}/latest`, - '@type': ExtensionGalleryResourceType.ExtensionLatestVersionUri + id: `${extensionsGallery.serviceUrl}/vscode/{publisher}/{name}/latest`, + type: ExtensionGalleryResourceType.ExtensionLatestVersionUri }, { - '@id': `${this.productService.extensionsGallery.serviceUrl}/publishers/{publisher}/extensions/{name}/{version}/stats?statType={statTypeName}`, - '@type': ExtensionGalleryResourceType.ExtensionStatisticsUri + id: `${extensionsGallery.serviceUrl}/publishers/{publisher}/extensions/{name}/{version}/stats?statType={statTypeName}`, + type: ExtensionGalleryResourceType.ExtensionStatisticsUri }, { - '@id': `${this.productService.extensionsGallery.serviceUrl}/itemName/{publisher}.{name}/version/{version}/statType/{statTypeValue}/vscodewebextension`, - '@type': ExtensionGalleryResourceType.WebExtensionStatisticsUri + id: `${extensionsGallery.serviceUrl}/itemName/{publisher}.{name}/version/{version}/statType/{statTypeValue}/vscodewebextension`, + type: ExtensionGalleryResourceType.WebExtensionStatisticsUri }, ]; - if (this.productService.extensionsGallery?.publisherUrl) { + if (extensionsGallery.publisherUrl) { resources.push({ - '@id': `${this.productService.extensionsGallery.publisherUrl}/{publisher}`, - '@type': ExtensionGalleryResourceType.ExtensionDetailsViewUri + id: `${extensionsGallery.publisherUrl}/{publisher}`, + type: ExtensionGalleryResourceType.PublisherViewUri }); } - if (this.productService.extensionsGallery?.itemUrl) { + if (extensionsGallery.itemUrl) { resources.push({ - '@id': `${this.productService.extensionsGallery.itemUrl}/?itemName={publisher}.{name}`, - '@type': ExtensionGalleryResourceType.ExtensionDetailsViewUri + id: `${extensionsGallery.itemUrl}/?itemName={publisher}.{name}`, + type: ExtensionGalleryResourceType.ExtensionDetailsViewUri }); resources.push({ - '@id': `${this.productService.extensionsGallery.itemUrl}/?itemName={publisher}.{name}&ssr=false#review-details`, - '@type': ExtensionGalleryResourceType.ExtensionRatingViewUri + id: `${extensionsGallery.itemUrl}/?itemName={publisher}.{name}&ssr=false#review-details`, + type: ExtensionGalleryResourceType.ExtensionRatingViewUri }); } - if (this.productService.extensionsGallery.resourceUrlTemplate) { + if (extensionsGallery.resourceUrlTemplate) { resources.push({ - '@id': this.productService.extensionsGallery.resourceUrlTemplate, - '@type': ExtensionGalleryResourceType.ExtensionUriTemplate + id: extensionsGallery.resourceUrlTemplate, + type: ExtensionGalleryResourceType.ExtensionUriTemplate }); } - if (this.productService.extensionsGallery?.nlsBaseUrl) { + if (extensionsGallery.nlsBaseUrl) { resources.push({ - '@id': this.productService.extensionsGallery.nlsBaseUrl, - '@type': ExtensionGalleryResourceType.WebLanguagePackService + id: extensionsGallery.nlsBaseUrl, + type: ExtensionGalleryResourceType.WebLanguagePackService }); } @@ -2016,7 +2051,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle flags, }, signing: { - allRepositoriesSigned: true, + allRepositorySigned: true, } } }; diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index a272af04d65..ffeed393a05 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -218,6 +218,7 @@ export interface IGalleryExtension { publisher: string; publisherDisplayName: string; publisherDomain?: { link: string; verified: boolean }; + publisherLink?: string; publisherSponsorLink?: string; description: string; installCount: number; @@ -234,9 +235,11 @@ export interface IGalleryExtension { allTargetPlatforms: TargetPlatform[]; assets: IGalleryExtensionAssets; properties: IGalleryExtensionProperties; + detailsLink?: string; + ratingLink?: string; + supportLink?: string; telemetryData?: any; queryContext?: IStringDictionary; - supportLink?: string; } export type InstallSource = 'gallery' | 'vsix' | 'resource'; @@ -295,6 +298,17 @@ export const enum SortOrder { Descending = 2 } +export const enum FilterType { + Category = 'Category', + ExtensionId = 'ExtensionId', + ExtensionName = 'ExtensionName', + ExcludeWithFlags = 'ExcludeWithFlags', + Featured = 'Featured', + SearchText = 'SearchText', + Tag = 'Tag', + Target = 'Target', +} + export interface IQueryOptions { text?: string; exclude?: string[]; @@ -361,6 +375,13 @@ export interface IExtensionQueryOptions { preferResourceApi?: boolean; } +export interface IExtensionGalleryCapabilities { + readonly query: { + readonly sortBy: readonly SortBy[]; + readonly filters: readonly FilterType[]; + }; +} + export const IExtensionGalleryService = createDecorator('extensionGalleryService'); /** @@ -370,6 +391,7 @@ export const IExtensionGalleryService = createDecorator; query(options: IQueryOptions, token: CancellationToken): Promise>; getExtensions(extensionInfos: ReadonlyArray, token: CancellationToken): Promise; getExtensions(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, token: CancellationToken): Promise; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 8542167a79b..300936902dc 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -589,10 +589,13 @@ export class ExtensionEditor extends EditorPane { if (extension.url) { this.transientDisposables.add(onClick(template.name, () => this.openerService.open(URI.parse(extension.url!)))); - this.transientDisposables.add(onClick(template.rating, () => this.openerService.open(URI.parse(`${extension.url}&ssr=false#review-details`)))); this.transientDisposables.add(onClick(template.publisher, () => this.extensionsWorkbenchService.openSearch(`publisher:"${extension.publisherDisplayName}"`))); } + if (extension.ratingUrl) { + this.transientDisposables.add(onClick(template.rating, () => this.openerService.open(URI.parse(extension.ratingUrl!)))); + } + const manifest = await this.extensionManifest.get().promise; if (token.isCancellationRequested) { return; diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index f66033a1ca1..3bc489fb79c 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -8,7 +8,7 @@ import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { MenuRegistry, MenuId, registerAction2, Action2, IMenuItem, IAction2Options } from '../../../../platform/actions/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApiConfigKey, AllowedExtensionsConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApiConfigKey, AllowedExtensionsConfigKey, SortBy, FilterType } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; @@ -546,6 +546,8 @@ overrideActionForActiveExtensionEditorWebview(PasteAction, webview => webview.pa export const CONTEXT_HAS_LOCAL_SERVER = new RawContextKey('hasLocalServer', false); export const CONTEXT_HAS_REMOTE_SERVER = new RawContextKey('hasRemoteServer', false); export const CONTEXT_HAS_WEB_SERVER = new RawContextKey('hasWebServer', false); +const CONTEXT_GALLERY_SORT_CAPABILITIES = new RawContextKey('gallerySortCapabilities', ''); +const CONTEXT_GALLERY_FILTER_CAPABILITIES = new RawContextKey('galleryFilterCapabilities', ''); async function runAction(action: IAction): Promise { try { @@ -566,8 +568,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi constructor( @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IViewsService private readonly viewsService: IViewsService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, @@ -597,11 +599,18 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi hasWebServerContext.set(true); } + this.registerGalleryCapabilitiesContexts(); this.registerGlobalActions(); this.registerContextMenuActions(); this.registerQuickAccessProvider(); } + private async registerGalleryCapabilitiesContexts(): Promise { + const capabilities = await this.extensionGalleryService.getCapabilities(); + CONTEXT_GALLERY_SORT_CAPABILITIES.bindTo(this.contextKeyService).set(`_${capabilities.query.sortBy.join('_')}_UpdateDate_`); + CONTEXT_GALLERY_FILTER_CAPABILITIES.bindTo(this.contextKeyService).set(`_${capabilities.query.filters.join('_')}_`); + } + private registerQuickAccessProvider(): void { if (this.extensionManagementServerService.localExtensionManagementServer || this.extensionManagementServerService.remoteExtensionManagementServer @@ -994,16 +1003,17 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi }); const showFeaturedExtensionsId = 'extensions.filter.featured'; + const featuresExtensionsWhenContext = ContextKeyExpr.and(CONTEXT_HAS_GALLERY, ContextKeyExpr.regex(CONTEXT_GALLERY_FILTER_CAPABILITIES.key, new RegExp(`_${FilterType.Featured}_`))); this.registerExtensionAction({ id: showFeaturedExtensionsId, title: localize2('showFeaturedExtensions', 'Show Featured Extensions'), category: ExtensionsLocalizedLabel, menu: [{ id: MenuId.CommandPalette, - when: CONTEXT_HAS_GALLERY + when: featuresExtensionsWhenContext }, { id: extensionsFilterSubMenu, - when: CONTEXT_HAS_GALLERY, + when: featuresExtensionsWhenContext, group: '1_predefined', order: 1, }], @@ -1074,7 +1084,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi MenuRegistry.appendMenuItem(extensionsFilterSubMenu, { submenu: extensionsCategoryFilterSubMenu, title: localize('filter by category', "Category"), - when: CONTEXT_HAS_GALLERY, + when: ContextKeyExpr.and(CONTEXT_HAS_GALLERY, ContextKeyExpr.regex(CONTEXT_GALLERY_FILTER_CAPABILITIES.key, new RegExp(`_${FilterType.Category}_`))), group: '2_categories', order: 1, }); @@ -1193,19 +1203,20 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi }); [ - { id: 'installs', title: localize('sort by installs', "Install Count"), precondition: BuiltInExtensionsContext.negate() }, - { id: 'rating', title: localize('sort by rating', "Rating"), precondition: BuiltInExtensionsContext.negate() }, - { id: 'name', title: localize('sort by name', "Name"), precondition: BuiltInExtensionsContext.negate() }, - { id: 'publishedDate', title: localize('sort by published date', "Published Date"), precondition: BuiltInExtensionsContext.negate() }, - { id: 'updateDate', title: localize('sort by update date', "Updated Date"), precondition: ContextKeyExpr.and(SearchMarketplaceExtensionsContext.negate(), RecommendedExtensionsContext.negate(), BuiltInExtensionsContext.negate()) }, - ].map(({ id, title, precondition }, index) => { + { id: 'installs', title: localize('sort by installs', "Install Count"), precondition: BuiltInExtensionsContext.negate(), sortCapability: SortBy.InstallCount }, + { id: 'rating', title: localize('sort by rating', "Rating"), precondition: BuiltInExtensionsContext.negate(), sortCapability: SortBy.WeightedRating }, + { id: 'name', title: localize('sort by name', "Name"), precondition: BuiltInExtensionsContext.negate(), sortCapability: SortBy.Title }, + { id: 'publishedDate', title: localize('sort by published date', "Published Date"), precondition: BuiltInExtensionsContext.negate(), sortCapability: SortBy.PublishedDate }, + { id: 'updateDate', title: localize('sort by update date', "Updated Date"), precondition: ContextKeyExpr.and(SearchMarketplaceExtensionsContext.negate(), RecommendedExtensionsContext.negate(), BuiltInExtensionsContext.negate()), sortCapability: 'UpdateDate' }, + ].map(({ id, title, precondition, sortCapability }, index) => { + const sortCapabilityContext = ContextKeyExpr.regex(CONTEXT_GALLERY_SORT_CAPABILITIES.key, new RegExp(`_${sortCapability}_`)); this.registerExtensionAction({ id: `extensions.sort.${id}`, title, - precondition: ContextKeyExpr.and(precondition, ContextKeyExpr.regex(ExtensionsSearchValueContext.key, /^@feature:/).negate()), + precondition: ContextKeyExpr.and(precondition, ContextKeyExpr.regex(ExtensionsSearchValueContext.key, /^@feature:/).negate(), sortCapabilityContext), menu: [{ id: extensionsSortSubMenu, - when: ContextKeyExpr.or(CONTEXT_HAS_GALLERY, DefaultViewsContext), + when: ContextKeyExpr.and(ContextKeyExpr.or(CONTEXT_HAS_GALLERY, DefaultViewsContext), sortCapabilityContext), order: index, }], toggled: ExtensionsSortByContext.isEqualTo(id), @@ -1645,12 +1656,10 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi group: '1_copy', when: ContextKeyExpr.has('isGalleryExtension'), }, - run: async (accessor: ServicesAccessor, extensionId: string) => { + run: async (accessor: ServicesAccessor, _, extension: IExtensionArg) => { const clipboardService = accessor.get(IClipboardService); - const productService = accessor.get(IProductService); - if (productService.extensionsGallery?.itemUrl) { - const link = `${productService.extensionsGallery.itemUrl}?itemName=${extensionId}`; - await clipboardService.writeText(link); + if (extension.galleryLink) { + await clipboardService.writeText(extension.galleryLink); } } }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 3cd6405610e..7c58defdf35 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -212,9 +212,6 @@ export class PromptExtensionInstallFailureAction extends Action { if (!this.extension.gallery) { return undefined; } - if (!this.productService.extensionsGallery) { - return undefined; - } if (!this.extensionManagementServerService.localExtensionManagementServer && !this.extensionManagementServerService.remoteExtensionManagementServer) { return undefined; } @@ -233,7 +230,18 @@ export class PromptExtensionInstallFailureAction extends Action { if (targetPlatform === TargetPlatform.UNKNOWN) { return undefined; } - return URI.parse(`${this.productService.extensionsGallery.serviceUrl}/publishers/${this.extension.publisher}/vsextensions/${this.extension.name}/${this.version}/vspackage${targetPlatform !== TargetPlatform.UNDEFINED ? `?targetPlatform=${targetPlatform}` : ''}`); + + const [extension] = await this.galleryService.getExtensions([{ + ...this.extension.identifier, + version: this.version + }], { + targetPlatform + }, CancellationToken.None); + + if (!extension) { + return undefined; + } + return URI.parse(extension.assets.download.uri); } } @@ -1438,7 +1446,8 @@ export class MenuItemExtensionAction extends ExtensionAction { const extensionArg: IExtensionArg = { id: this.extension.identifier.id, version: this.extension.version, - location: this.extension.local?.location + location: this.extension.local?.location, + galleryLink: this.extension.url }; await this.action.run(id, extensionArg); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index c81a7b99e5e..77f686949c4 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -196,11 +196,7 @@ export class Extension implements IExtension { } get publisherUrl(): URI | undefined { - if (!this.productService.extensionsGallery || !this.gallery) { - return undefined; - } - - return resources.joinPath(URI.parse(this.productService.extensionsGallery.publisherUrl), this.publisher); + return this.gallery?.publisherLink ? URI.parse(this.gallery.publisherLink) : undefined; } get publisherDomain(): { link: string; verified: boolean } | undefined { @@ -228,11 +224,7 @@ export class Extension implements IExtension { } get url(): string | undefined { - if (!this.productService.extensionsGallery || !this.gallery) { - return undefined; - } - - return `${this.productService.extensionsGallery.itemUrl}?itemName=${this.publisher}.${this.name}`; + return this.gallery?.detailsLink; } get iconUrl(): string { @@ -314,6 +306,10 @@ export class Extension implements IExtension { return this.gallery ? this.gallery.ratingCount : undefined; } + get ratingUrl(): string | undefined { + return this.gallery?.ratingLink; + } + get outdated(): boolean { try { if (!this.gallery || !this.local) { diff --git a/src/vs/workbench/contrib/extensions/common/extensions.ts b/src/vs/workbench/contrib/extensions/common/extensions.ts index 97e8d4b6d15..ad36a535de6 100644 --- a/src/vs/workbench/contrib/extensions/common/extensions.ts +++ b/src/vs/workbench/contrib/extensions/common/extensions.ts @@ -83,6 +83,7 @@ export interface IExtension { readonly installCount?: number; readonly rating?: number; readonly ratingCount?: number; + readonly ratingUrl?: string; readonly outdated: boolean; readonly outdatedTargetPlatform: boolean; readonly runtimeState: ExtensionRuntimeState | undefined; @@ -263,4 +264,5 @@ export interface IExtensionArg { id: string; version: string; location: URI | undefined; + galleryLink: string | undefined; } diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts index 75d90366ab1..2cb589f849f 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts @@ -46,7 +46,6 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { joinPath } from '../../../../base/common/resources.js'; import { verifiedPublisherIcon } from './extensionsIcons.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; @@ -869,9 +868,6 @@ export class ExtensionManagementService extends Disposable implements IWorkbench } }; - const getPublisherLink = ({ publisherDisplayName, publisher }: { publisherDisplayName: string; publisher: string }) => { - return `[${publisherDisplayName}](${joinPath(URI.parse(this.productService.extensionsGallery!.publisherUrl), publisher)})`; - }; const unverifiedLink = 'https://aka.ms/vscode-verify-publisher'; const title = allPublishers.length === 1 @@ -886,35 +882,35 @@ export class ExtensionManagementService extends Disposable implements IWorkbench const extension = untrustedExtensions[0]; const manifest = untrustedExtensionManifests[0]; if (otherUntrustedPublishers.length) { - customMessage.appendMarkdown(localize('extension published by message', "The extension {0} is published by {1}.", `[${extension.displayName}](${this.productService.extensionsGallery!.itemUrl}?itemName=${extension.identifier.id})`, getPublisherLink(extension))); + customMessage.appendMarkdown(localize('extension published by message', "The extension {0} is published by {1}.", `[${extension.displayName}](${extension.detailsLink})`, extension.publisherLink)); customMessage.appendMarkdown(' '); const commandUri = URI.parse(`command:extension.open?${encodeURIComponent(JSON.stringify([extension.identifier.id, manifest.extensionPack?.length ? 'extensionPack' : 'dependencies']))}`).toString(); if (otherUntrustedPublishers.length === 1) { - customMessage.appendMarkdown(localize('singleUntrustedPublisher', "Installing this extension will also install [extensions]({0}) published by {1}.", commandUri, getPublisherLink(otherUntrustedPublishers[0]))); + customMessage.appendMarkdown(localize('singleUntrustedPublisher', "Installing this extension will also install [extensions]({0}) published by {1}.", commandUri, otherUntrustedPublishers[0].publisherLink)); } else { - customMessage.appendMarkdown(localize('message3', "Installing this extension will also install [extensions]({0}) published by {1} and {2}.", commandUri, otherUntrustedPublishers.slice(0, otherUntrustedPublishers.length - 1).map(p => getPublisherLink(p)).join(', '), getPublisherLink(otherUntrustedPublishers[otherUntrustedPublishers.length - 1]))); + customMessage.appendMarkdown(localize('message3', "Installing this extension will also install [extensions]({0}) published by {1} and {2}.", commandUri, otherUntrustedPublishers.slice(0, otherUntrustedPublishers.length - 1).map(p => p.publisherLink).join(', '), otherUntrustedPublishers[otherUntrustedPublishers.length - 1].publisherLink)); } customMessage.appendMarkdown(' '); customMessage.appendMarkdown(localize('firstTimeInstallingMessage', "This is the first time you're installing extensions from these publishers.")); } else { - customMessage.appendMarkdown(localize('message1', "The extension {0} is published by {1}. This is the first extension you're installing from this publisher.", `[${extension.displayName}](${this.productService.extensionsGallery!.itemUrl}?itemName=${extension.identifier.id})`, getPublisherLink(extension))); + customMessage.appendMarkdown(localize('message1', "The extension {0} is published by {1}. This is the first extension you're installing from this publisher.", `[${extension.displayName}](${extension.detailsLink})`, extension.publisherLink)); } } else { - customMessage.appendMarkdown(localize('multiInstallMessage', "This is the first time you're installing extensions from publishers {0} and {1}.", allPublishers.slice(0, allPublishers.length - 1).map(p => getPublisherLink(p)).join(', '), getPublisherLink(allPublishers[allPublishers.length - 1]))); + customMessage.appendMarkdown(localize('multiInstallMessage', "This is the first time you're installing extensions from publishers {0} and {1}.", allPublishers.slice(0, allPublishers.length - 1).map(p => p.publisherLink).join(', '), allPublishers[allPublishers.length - 1].publisherLink)); } if (verifiedPublishers.length || unverfiiedPublishers.length === 1) { for (const publisher of verifiedPublishers) { customMessage.appendText('\n'); - const publisherVerifiedMessage = localize('verifiedPublisherWithName', "{0} has verified ownership of {1}.", getPublisherLink(publisher), `[$(link-external) ${URI.parse(publisher.publisherDomain!.link).authority}](${publisher.publisherDomain!.link})`); + const publisherVerifiedMessage = localize('verifiedPublisherWithName', "{0} has verified ownership of {1}.", publisher.publisherLink, `[$(link-external) ${URI.parse(publisher.publisherDomain!.link).authority}](${publisher.publisherDomain!.link})`); customMessage.appendMarkdown(`$(${verifiedPublisherIcon.id}) ${publisherVerifiedMessage}`); } if (unverfiiedPublishers.length) { customMessage.appendText('\n'); if (unverfiiedPublishers.length === 1) { - customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublisherWithName', "{0} is [**not** verified]({1}).", getPublisherLink(unverfiiedPublishers[0]), unverifiedLink)}`); + customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublisherWithName', "{0} is [**not** verified]({1}).", unverfiiedPublishers[0].publisherLink, unverifiedLink)}`); } else { - customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublishers', "{0} and {1} are [**not** verified]({2}).", unverfiiedPublishers.slice(0, unverfiiedPublishers.length - 1).map(p => getPublisherLink(p)).join(', '), getPublisherLink(unverfiiedPublishers[unverfiiedPublishers.length - 1]), unverifiedLink)}`); + customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublishers', "{0} and {1} are [**not** verified]({2}).", unverfiiedPublishers.slice(0, unverfiiedPublishers.length - 1).map(p => p.publisherLink).join(', '), unverfiiedPublishers[unverfiiedPublishers.length - 1].publisherLink, unverifiedLink)}`); } } } else { @@ -946,7 +942,7 @@ export class ExtensionManagementService extends Disposable implements IWorkbench } - private async getOtherUntrustedPublishers(manifests: IExtensionManifest[]): Promise<{ publisher: string; publisherDisplayName: string; publisherDomain?: { link: string; verified: boolean } }[]> { + private async getOtherUntrustedPublishers(manifests: IExtensionManifest[]): Promise<{ publisher: string; publisherDisplayName: string; publisherLink?: string; publisherDomain?: { link: string; verified: boolean } }[]> { const extensionIds = new Set(); for (const manifest of manifests) { for (const id of [...(manifest.extensionPack ?? []), ...(manifest.extensionDependencies ?? [])]) { From 0a721d35a20b5736445948aba1c1e21a617d95b5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 17:01:42 -0700 Subject: [PATCH 023/255] update layer check --- build/lib/layersChecker.js | 3 ++- build/lib/layersChecker.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/build/lib/layersChecker.js b/build/lib/layersChecker.js index 5cf5c58402c..8e46cb71541 100644 --- a/build/lib/layersChecker.js +++ b/build/lib/layersChecker.js @@ -81,7 +81,8 @@ const CORE_TYPES = [ 'ImportMeta', // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', - 'SubtleCrypto' + 'SubtleCrypto', + 'JsonWebKey', ]; // Types that are defined in a common layer but are known to be only // available in native environments should not be allowed in browser diff --git a/build/lib/layersChecker.ts b/build/lib/layersChecker.ts index 63377328928..235d2d68dbe 100644 --- a/build/lib/layersChecker.ts +++ b/build/lib/layersChecker.ts @@ -80,7 +80,8 @@ const CORE_TYPES = [ // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', - 'SubtleCrypto' + 'SubtleCrypto', + 'JsonWebKey', ]; // Types that are defined in a common layer but are known to be only From 589bd5dfe93c0cbaf84830931209f22334c59c15 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 11 Mar 2025 18:22:35 -0700 Subject: [PATCH 024/255] mcp: work on --add-mcp CLI (#243282) * mcp: work on --add-mcp CLI * throw an error, get it mergable --- src/vs/code/node/cli.ts | 1 + src/vs/code/node/cliProcessMain.ts | 6 ++ src/vs/platform/environment/common/argv.ts | 2 + src/vs/platform/environment/node/argv.ts | 10 +- .../platform/mcp/common/mcpManagementCli.ts | 95 +++++++++++++++++++ .../platform/mcp/common/mcpPlatformTypes.ts | 15 +++ .../common/discovery/configMcpDiscovery.ts | 4 +- .../contrib/mcp/common/mcpConfiguration.ts | 12 +-- 8 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/vs/platform/mcp/common/mcpManagementCli.ts create mode 100644 src/vs/platform/mcp/common/mcpPlatformTypes.ts diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index e91bea07d6a..312df0c1f92 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -36,6 +36,7 @@ function shouldSpawnCliProcess(argv: NativeParsedArgs): boolean { || !!argv['uninstall-extension'] || !!argv['update-extensions'] || !!argv['locate-extension'] + || !!argv['add-mcp'] || !!argv['telemetry']; } diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index aff3b2da579..35ffa128d90 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -65,6 +65,7 @@ import { localize } from '../../nls.js'; import { FileUserDataProvider } from '../../platform/userData/common/fileUserDataProvider.js'; import { addUNCHostToAllowlist, getUNCHost } from '../../base/node/unc.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; +import { McpManagementCli } from '../../platform/mcp/common/mcpManagementCli.js'; class CliMain extends Disposable { @@ -304,6 +305,11 @@ class CliMain extends Disposable { return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).locateExtension(this.argv['locate-extension']); } + // Install MCP server + else if (this.argv['add-mcp']) { + return instantiationService.createInstance(McpManagementCli, new ConsoleLogger(LogLevel.Info, false)).addMcpDefinitions(this.argv['add-mcp-to-workspace'], this.argv['add-mcp']); + } + // Telemetry else if (this.argv['telemetry']) { console.log(await buildTelemetryMessage(environmentService.appRoot, environmentService.extensionsPath)); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index cdcb8ee0a05..a4f7464f934 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -93,6 +93,8 @@ export interface NativeParsedArgs { 'disable-telemetry'?: boolean; 'export-default-configuration'?: string; 'install-source'?: string; + 'add-mcp'?: string[]; + 'add-mcp-to-workspace'?: string; 'disable-updates'?: boolean; 'use-inmemory-secretstorage'?: boolean; 'password-store'?: string; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index b4e76a58f3f..2a69ae919cf 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -104,6 +104,9 @@ export const OPTIONS: OptionDescriptions> = { 'update-extensions': { type: 'boolean', cat: 'e', description: localize('updateExtensions', "Update the installed extensions.") }, 'enable-proposed-api': { type: 'string[]', allowEmptyValue: true, cat: 'e', args: 'ext-id', description: localize('experimentalApis', "Enables proposed API features for extensions. Can receive one or more extension IDs to enable individually.") }, + 'add-mcp': { type: 'string[]', cat: 'o', args: 'json', description: localize('addMcp', "Adds a Model Context Protocol server definition to the user profile, or workspace or folder when used with --mcp-workspace. Accepts JSON input in the form '{\"name\":\"server-name\",\"command\":...}'") }, + 'add-mcp-to-workspace': { type: 'string', cat: 'o', args: 'path', description: localize('addMcpWorkspace', "Folder or workspace in which to add Model Context Protocol servers, when used with '--add-mcp'") }, + 'version': { type: 'boolean', cat: 't', alias: 'v', description: localize('version', "Print version.") }, 'verbose': { type: 'boolean', cat: 't', global: true, description: localize('verbose', "Print verbose output (implies --wait).") }, 'log': { type: 'string[]', cat: 't', args: 'level', global: true, description: localize('log', "Log level to use. Default is 'info'. Allowed values are 'critical', 'error', 'warn', 'info', 'debug', 'trace', 'off'. You can also configure the log level of an extension by passing extension id and log level in the following format: '${publisher}.${name}:${logLevel}'. For example: 'vscode.csharp:trace'. Can receive one or more such entries.") }, @@ -410,9 +413,12 @@ function indent(count: number): string { function wrapText(text: string, columns: number): string[] { const lines: string[] = []; while (text.length) { - const index = text.length < columns ? text.length : text.lastIndexOf(' ', columns); + let index = text.length < columns ? text.length : text.lastIndexOf(' ', columns); + if (index === 0) { + index = columns; + } const line = text.slice(0, index).trim(); - text = text.slice(index); + text = text.slice(index).trimStart(); lines.push(line); } return lines; diff --git a/src/vs/platform/mcp/common/mcpManagementCli.ts b/src/vs/platform/mcp/common/mcpManagementCli.ts new file mode 100644 index 00000000000..54ab50ee121 --- /dev/null +++ b/src/vs/platform/mcp/common/mcpManagementCli.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ConfigurationService } from '../../configuration/common/configurationService.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogger, ILogService } from '../../log/common/log.js'; +import { IPolicyService } from '../../policy/common/policy.js'; +import { hasWorkspaceFileExtension } from '../../workspace/common/workspace.js'; +import { IMcpConfiguration, IMcpConfigurationServer } from './mcpPlatformTypes.js'; + +type ValidatedConfig = { name: string; config: IMcpConfigurationServer }; + +export class McpManagementCli { + constructor( + private readonly _logger: ILogger, + @IConfigurationService private readonly _userConfigurationService: IConfigurationService, + @IFileService private readonly _fileService: IFileService, + @IPolicyService private readonly _policyService: IPolicyService, + @ILogService private readonly _logService: ILogService, + ) { } + + async addMcpDefinitions( + workspace: string | undefined, + definitions: string[], + ) { + const configs = definitions.map((config) => this.validateConfiguration(config)); + + if (workspace) { + // todo (see below comments) + throw new InvalidMcpOperationError(`Installing into workspaces is not yet supported`); + } + + if (!workspace) { + await this.updateMcpInConfig(this._userConfigurationService, configs); + } else if (hasWorkspaceFileExtension(workspace)) { + // This is not right because settings are nested in .code-workspace... + const workspaceConfigService = new ConfigurationService(URI.file(workspace), this._fileService, this._policyService, this._logService); + await this.updateMcpInConfig(workspaceConfigService, configs); + workspaceConfigService.dispose(); + } else { + // todo: this seems incorrect. IConfigurationService.getValue() fails if + // if we point it to mcp.json and call `sevice.getValue()` with no args + // but if we point to launch.json, it writes it there instead of the + // standalone config file. This technically works but is undesirable. + const workspaceFile = URI.joinPath(URI.file(workspace), '.vscode', 'settings.json'); + const workspaceFolderConfigService = new ConfigurationService(workspaceFile, this._fileService, this._policyService, this._logService); + await this.updateMcpInConfig(workspaceFolderConfigService, configs); + workspaceFolderConfigService.dispose(); + } + + this._logger.info(`Added MCP servers: ${configs.map(c => c.name).join(', ')}`); + } + + private async updateMcpInConfig(service: IConfigurationService, configs: ValidatedConfig[]) { + const mcp = service.getValue('mcp') || { servers: {} }; + mcp.servers ??= {}; + + for (const config of configs) { + mcp.servers[config.name] = config.config; + } + + await service.updateValue('mcp', mcp); + } + + private validateConfiguration(config: string): ValidatedConfig { + let parsed: IMcpConfigurationServer & { name: string }; + try { + parsed = JSON.parse(config); + } catch (e) { + throw new InvalidMcpOperationError(`Invalid JSON '${config}': ${e}`); + } + + if (!parsed.name) { + throw new InvalidMcpOperationError(`Missing name property in ${config}`); + } + + if (!parsed.command) { + throw new InvalidMcpOperationError(`Missing command property in ${config}`); + } + + const { name, ...rest } = parsed; + return { name, config: rest }; + } +} + +class InvalidMcpOperationError extends Error { + constructor(message: string) { + super(message); + this.stack = message; + } +} diff --git a/src/vs/platform/mcp/common/mcpPlatformTypes.ts b/src/vs/platform/mcp/common/mcpPlatformTypes.ts new file mode 100644 index 00000000000..877e199ae06 --- /dev/null +++ b/src/vs/platform/mcp/common/mcpPlatformTypes.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IMcpConfiguration { + inputs: unknown[]; + servers: Record; +} + +export interface IMcpConfigurationServer { + command: string; + args?: readonly string[]; + env?: Record; +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts index aea862d7fb1..69c5df92371 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts @@ -97,9 +97,9 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { label: name, launch: { type: McpServerTransportType.Stdio, - args: value.args, + args: value.args || [], command: value.command, - env: value.env, + env: value.env || {}, cwd: undefined, }, variableReplacement: { diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 78fb48d097c..b17da0d3313 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -8,6 +8,7 @@ import { localize } from '../../../../nls.js'; import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; import { inputsSchema } from '../../../services/configurationResolver/common/configurationResolverSchema.js'; +export type { IMcpConfigurationServer, IMcpConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; const mcpSchemaExampleServer = { command: 'node', @@ -17,17 +18,6 @@ const mcpSchemaExampleServer = { export const mcpConfigurationSection = 'mcp'; -export interface IMcpConfiguration { - inputs: unknown[]; - servers: Record; -} - -export interface IMcpConfigurationServer { - command: string; - args: readonly string[]; - env: Record; -} - export const mcpSchemaExampleServers = { 'mcp-server-time': { command: 'python', From ead2561297f2a220d2d87567b94fddda87178f12 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Tue, 11 Mar 2025 19:37:38 -0700 Subject: [PATCH 025/255] Implement workspace trust checks for empty folders transferred from chat (#243288) --- .../browser/workspace.contribution.ts | 8 +++++ .../workspaces/common/workspaceTrust.ts | 10 +++++++ .../workspaces/common/workspaceUtils.ts | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 src/vs/workbench/services/workspaces/common/workspaceUtils.ts diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index c512b648529..c973f5f191f 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -50,6 +50,7 @@ import { basename, dirname as uriDirname } from '../../../../base/common/resourc import { URI } from '../../../../base/common/uri.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; +import { areWorkspaceFoldersEmpty, isChatTransferredWorkspace } from '../../../services/workspaces/common/workspaceUtils.js'; const BANNER_RESTRICTED_MODE = 'workbench.banner.restrictedMode'; const STARTUP_PROMPT_SHOWN_KEY = 'workspace.trust.startupPrompt.shown'; @@ -440,6 +441,13 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon return; } + // Don't show modal prompt for empty folders transferred from chat + const workspace = this.workspaceContextService.getWorkspace(); + if (isChatTransferredWorkspace(workspace, this.storageService) && await areWorkspaceFoldersEmpty(workspace, this.fileService)) { + this.updateWorkbenchIndicators(false); + return; + } + if (this.startupPromptSetting === 'never') { this.updateWorkbenchIndicators(false); return; diff --git a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts index e11ae3c24e2..90e1141c192 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts @@ -24,6 +24,7 @@ import { isEqualAuthority } from '../../../../base/common/resources.js'; import { isWeb } from '../../../../base/common/platform.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { promiseWithResolvers } from '../../../../base/common/async.js'; +import { areWorkspaceFoldersEmpty, isChatTransferredWorkspace } from './workspaceUtils.js'; export const WORKSPACE_TRUST_ENABLED = 'security.workspace.trust.enabled'; export const WORKSPACE_TRUST_STARTUP_PROMPT = 'security.workspace.trust.startupPrompt'; @@ -333,6 +334,15 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork if (trusted === undefined) { await this.resolveCanonicalUris(); trusted = this.calculateWorkspaceTrust(); + + const workspace = this.workspaceService.getWorkspace(); + if (!trusted && + isChatTransferredWorkspace(workspace, this.storageService) && + await areWorkspaceFoldersEmpty(workspace, this.fileService)) { + + // Trust empty folders transferred from chat + trusted = true; + } } if (this.isWorkspaceTrusted() === trusted) { return; } diff --git a/src/vs/workbench/services/workspaces/common/workspaceUtils.ts b/src/vs/workbench/services/workspaces/common/workspaceUtils.ts new file mode 100644 index 00000000000..4ba2f5af24a --- /dev/null +++ b/src/vs/workbench/services/workspaces/common/workspaceUtils.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../../base/common/uri.js'; +import { IWorkspace } from '../../../../platform/workspace/common/workspace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; + +export function isChatTransferredWorkspace(workspace: IWorkspace, storageService: IStorageService): boolean { + const workspaceUri = workspace.folders[0]?.uri; + if (!workspaceUri) { + return false; + } + const chatWorkspaceTransfer = storageService.getObject('chat.workspaceTransfer', StorageScope.PROFILE, []); + const toWorkspace: { toWorkspace: URI }[] = chatWorkspaceTransfer.map((item: any) => { + return { toWorkspace: URI.from(item.toWorkspace) }; + }); + return toWorkspace.some(item => item.toWorkspace.toString() === workspaceUri.toString()); +} + +export async function areWorkspaceFoldersEmpty(workspace: IWorkspace, fileService: IFileService): Promise { + for (const folder of workspace.folders) { + const folderStat = await fileService.resolve(folder.uri); + if (folderStat.children && folderStat.children.length > 0) { + return false; + } + } + return true; +} From fc7f88a0a73dac40ba16af282b40fddfb0d01f75 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 12 Mar 2025 15:33:39 +1100 Subject: [PATCH 026/255] Do not re-install ext from kernel picker if it is already installed (#243287) * Do not re-install ext from kernel picker if it is already installed * updates * revert --- .../browser/viewParts/notebookKernelQuickPickStrategy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts index 3d2c44c67ec..b8e8b2af29c 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts @@ -34,6 +34,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import { INotebookTextModel } from '../../common/notebookCommon.js'; import { SELECT_KERNEL_ID } from '../controller/coreActions.js'; import { EnablementState } from '../../../../services/extensionManagement/common/extensionManagement.js'; +import { areSameExtensions } from '../../../../../platform/extensionManagement/common/extensionManagementUtil.js'; type KernelPick = IQuickPickItem & { kernel: INotebookKernel }; function isKernelPick(item: QuickPickInput): item is KernelPick { @@ -293,7 +294,8 @@ abstract class KernelPickerStrategyBase implements IKernelPickerStrategy { const extension = (await extensionWorkbenchService.getExtensions([{ id: extId }], CancellationToken.None))[0]; if (extension.enablementState === EnablementState.DisabledGlobally || extension.enablementState === EnablementState.DisabledWorkspace || extension.enablementState === EnablementState.DisabledByEnvironment) { extensionsToEnable.push(extension); - } else { + } else if (!extensionWorkbenchService.installed.some(e => areSameExtensions(e.identifier, extension.identifier))) { + // Install this extension only if it hasn't already been installed. const canInstall = await extensionWorkbenchService.canInstall(extension); if (canInstall === true) { extensionsToInstall.push(extension); @@ -307,7 +309,7 @@ abstract class KernelPickerStrategyBase implements IKernelPickerStrategy { extension, { installPreReleaseVersion: isInsiders ?? false, - context: { skipWalkthrough: true } + context: { skipWalkthrough: true }, }, ProgressLocation.Notification ); From c1fb4c3983bcc28e63dd159dbbb2ea98d352d33e Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 12 Mar 2025 14:44:53 +0900 Subject: [PATCH 027/255] fix: pty crash on windows with legacy conpty path (#243267) --- package-lock.json | 8 ++++---- package.json | 2 +- remote/package-lock.json | 8 ++++---- remote/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d386986ea4..a6f99636b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "native-is-elevated": "0.7.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta30", + "node-pty": "1.1.0-beta31", "open": "^8.4.2", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", @@ -12626,9 +12626,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta30", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta30.tgz", - "integrity": "sha512-cmNYVWfbf961aOqnxIFXssvw6Fp6/78BQBNlwYRWUHBenJjUhCJ1wMZpJy+SegoLC07P9D6HTtq39Kd89rpv/w==", + "version": "1.1.0-beta31", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta31.tgz", + "integrity": "sha512-DwNyk7nQ8NfHX7NrIqvNQ5GiK6eBbsRYJ+hvHK04PTzZ6o5j1Qsc67g0QxXW8tki/ZJmE9Zxw6PEGncvDshdVw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a61d78887c0..c6a731a184a 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "native-is-elevated": "0.7.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta30", + "node-pty": "1.1.0-beta31", "open": "^8.4.2", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", diff --git a/remote/package-lock.json b/remote/package-lock.json index 89ceed12c71..6ccde5843bd 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -37,7 +37,7 @@ "kerberos": "2.1.1", "minimist": "^1.2.6", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta30", + "node-pty": "1.1.0-beta31", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -1098,9 +1098,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta30", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta30.tgz", - "integrity": "sha512-cmNYVWfbf961aOqnxIFXssvw6Fp6/78BQBNlwYRWUHBenJjUhCJ1wMZpJy+SegoLC07P9D6HTtq39Kd89rpv/w==", + "version": "1.1.0-beta31", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta31.tgz", + "integrity": "sha512-DwNyk7nQ8NfHX7NrIqvNQ5GiK6eBbsRYJ+hvHK04PTzZ6o5j1Qsc67g0QxXW8tki/ZJmE9Zxw6PEGncvDshdVw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/remote/package.json b/remote/package.json index 905239d0916..47fe4472cc7 100644 --- a/remote/package.json +++ b/remote/package.json @@ -32,7 +32,7 @@ "kerberos": "2.1.1", "minimist": "^1.2.6", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta30", + "node-pty": "1.1.0-beta31", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", From 13e979a629ced09519f4b41af5cbc5d75be05eae Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 12 Mar 2025 09:33:45 +0100 Subject: [PATCH 028/255] chat - tweaks to status (#243301) * chat - tweaks to status * more * add `from` for upgrade action * . --- src/vs/base/browser/ui/dialog/dialog.css | 4 + .../chat/browser/actions/chatActions.ts | 2 +- .../browser/actions/chatExecuteActions.ts | 1 + .../chatContentParts/chatQuotaExceededPart.ts | 2 +- .../contrib/chat/browser/chatInputPart.ts | 2 +- .../contrib/chat/browser/chatSetup.ts | 4 +- .../contrib/chat/browser/chatStatus.ts | 74 ++++++++++--------- .../contrib/chat/browser/media/chatStatus.css | 8 +- 8 files changed, 52 insertions(+), 45 deletions(-) diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index 60dba786a8a..ff25e129d89 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -71,6 +71,10 @@ white-space: normal; } +.monaco-dialog-box .dialog-message-row .dialog-message-container ul { + padding-inline-start: 20px; /* reduce excessive indent of list items in the dialog */ +} + /** Dialog: Message */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { line-height: 22px; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index eeb033f2612..badc1e5967e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -608,7 +608,7 @@ export function registerChatActions() { buttons: [ { label: localize('upgradePro', "Upgrade to Copilot Pro"), - run: () => commandService.executeCommand('workbench.action.chat.upgradePlan') + run: () => commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-dialog') }, ], custom: { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index df2139fb6b1..b7ed7172747 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -224,6 +224,7 @@ export class SwitchToNextModelAction extends Action2 { weight: KeybindingWeight.WorkbenchContrib, when: ChatContextKeys.inChatInput }, + precondition: ChatContextKeys.enabled, menu: { id: MenuId.ChatExecute, order: 3, diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts index 97985ca4969..0e8dcd58fa0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts @@ -99,7 +99,7 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar }; this._register(button1.onDidClick(async () => { - await commandService.executeCommand('workbench.action.chat.upgradePlan'); + await commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-response'); shouldShowRetryButton = true; addRetryButtonIfNeeded(); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 1c755c9ff90..d7d78d1cfb4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -1462,7 +1462,7 @@ class ModelPickerActionViewItem extends DropdownMenuActionViewItemWithKeybinding const actions = models.map(entry => setLanguageModelAction(entry)); if (chatEntitlementService.entitlement === ChatEntitlement.Limited) { actions.push(new Separator()); - actions.push(toAction({ id: 'moreModels', label: localize('chat.moreModels', "Add More Models..."), run: () => commandService.executeCommand('workbench.action.chat.upgradePlan') })); + actions.push(toAction({ id: 'moreModels', label: localize('chat.moreModels', "Add More Models..."), run: () => commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-models') })); } return actions; diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index 149242c8e04..e4b57bc7d93 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -239,13 +239,13 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor): Promise { + override async run(accessor: ServicesAccessor, from?: string): Promise { const openerService = accessor.get(IOpenerService); const telemetryService = accessor.get(ITelemetryService); const hostService = accessor.get(IHostService); const commandService = accessor.get(ICommandService); - telemetryService.publicLog2('workbenchActionExecuted', { id: this.desc.id, from: 'chat' }); + telemetryService.publicLog2('workbenchActionExecuted', { id: this.desc.id, from: from ?? 'chat' }); openerService.open(URI.parse(defaultChat.upgradePlanUrl)); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus.ts index 6fb6b89848e..ea5fd261758 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus.ts @@ -12,15 +12,13 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../services/statusbar/browser/statusbar.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; -import { OPEN_CHAT_QUOTA_EXCEEDED_DIALOG, CHAT_SETUP_ACTION_LABEL } from './actions/chatActions.js'; import { $, addDisposableListener, append, clearNode, EventHelper, EventType } from '../../../../base/browser/dom.js'; import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../common/chatEntitlementService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { Command } from '../../../../editor/common/languages.js'; -import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { contrastBorder, inputValidationErrorBorder, inputValidationInfoBorder, inputValidationWarningBorder, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -100,8 +98,6 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu private static readonly SETTING = 'chat.experimental.statusIndicator.enabled'; - private static readonly SIGN_IN_COMMAND_ID = 'workbench.action.chat.signIn'; - private entry: IStatusbarEntryAccessor | undefined = undefined; private dashboard = new Lazy(() => this.instantiationService.createInstance(ChatStatusDashboard)); @@ -117,7 +113,6 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this.create(); this.registerListeners(); - this.registerCommands(); } private async create(): Promise { @@ -159,31 +154,24 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.entry?.update(this.getEntryProps()))); } - private registerCommands(): void { - CommandsRegistry.registerCommand(ChatStatusBarEntry.SIGN_IN_COMMAND_ID, () => { - this.chatEntitlementService.requests?.value.signIn(); - }); - } - private getEntryProps(): IStatusbarEntry { let text = '$(copilot)'; let ariaLabel = localize('chatStatus', "Copilot Status"); - let command: string | Command = ShowTooltipCommand; let kind: StatusbarEntryKind | undefined; const { chatQuotaExceeded, completionsQuotaExceeded } = this.chatEntitlementService.quotas; // New User if (isNewUser(this.contextKeyService, this.chatEntitlementService)) { - ariaLabel = CHAT_SETUP_ACTION_LABEL.value; + ariaLabel = localize('triggerChatSetup', "Use AI Features with Copilot for Free"); } // Signed out else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { - const signInWarning = localize('signInToUseCopilot', "Sign in to Use Copilot..."); - text = `$(copilot-warning) ${signInWarning}`; - ariaLabel = signInWarning; - command = ChatStatusBarEntry.SIGN_IN_COMMAND_ID; + const signedOutWarning = localize('notSignedIntoCopilot', "Signed out"); + + text = `$(copilot-not-connected) ${signedOutWarning}`; + ariaLabel = signedOutWarning; kind = 'prominent'; } @@ -200,7 +188,6 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu text = `$(copilot-warning) ${quotaWarning}`; ariaLabel = quotaWarning; - command = OPEN_CHAT_QUOTA_EXCEEDED_DIALOG; kind = 'prominent'; } @@ -208,7 +195,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu name: localize('chatStatus', "Copilot Status"), text, ariaLabel, - command, + command: ShowTooltipCommand, showInAllWindows: true, kind, tooltip: { element: token => this.dashboard.value.show(token) } @@ -228,6 +215,14 @@ function isNewUser(contextKeyService: IContextKeyService, chatEntitlementService chatEntitlementService.entitlement === ChatEntitlement.Available; // not yet signed up to copilot } +function canUseCopilot(contextKeyService: IContextKeyService, chatEntitlementService: IChatEntitlementService): boolean { + const newUser = isNewUser(contextKeyService, chatEntitlementService); + const signedOut = chatEntitlementService.entitlement === ChatEntitlement.Unknown; + const allQuotaReached = chatEntitlementService.quotas.chatQuotaExceeded && chatEntitlementService.quotas.completionsQuotaExceeded; + + return !newUser && !signedOut && !allQuotaReached; +} + interface ISettingsAccessor { readSetting: () => boolean; writeSetting: (value: boolean) => Promise; @@ -242,7 +237,7 @@ class ChatStatusDashboard extends Disposable { constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, @IConfigurationService private readonly configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, @IEditorService private readonly editorService: IEditorService, @@ -260,13 +255,16 @@ class ChatStatusDashboard extends Disposable { let addSeparator = false; - // New to Copilot - if (isNewUser(this.contextKeyService, this.chatEntitlementService)) { - this.element.appendChild($('div.header', undefined, localize('setupCopilotForFreeHeader', "Use AI Features with Copilot"))); + const newUser = isNewUser(this.contextKeyService, this.chatEntitlementService); + const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; + + // New to Copilot / Signed out + if (newUser || signedOut) { + this.element.appendChild($('div.header', undefined, localize('setupCopilot', "Use AI Features with Copilot"))); addSeparator = true; - this.element.appendChild( - $('div', undefined, + const setup = this.element.appendChild( + $('div.setup', undefined, $('div.chat-feature-container', undefined, renderIcon(Codicon.code), $('span', undefined, localize('featureChat', "Code faster with Completions")) @@ -282,9 +280,9 @@ class ChatStatusDashboard extends Disposable { ) ); - const setupCopilotButton = disposables.add(new Button(this.element, { ...defaultButtonStyles })); - setupCopilotButton.label = localize('setupCopilotForFreeButton', "Setup Copilot for Free"); - disposables.add(setupCopilotButton.onDidClick(() => this.runCommandAndClose('workbench.action.chat.triggerSetup'))); + const button = disposables.add(new Button(setup, { ...defaultButtonStyles })); + button.label = newUser ? localize('setupCopilotForFreeButton', "Setup Copilot for Free") : localize('signInToUseCopilotButton', "Sign In to Use Copilot"); + disposables.add(button.onDidClick(() => this.runCommandAndClose(newUser ? { id: 'workbench.action.chat.triggerSetup' } : () => this.chatEntitlementService.requests?.value.signIn()))); } // Quota Indicator @@ -300,9 +298,9 @@ class ChatStatusDashboard extends Disposable { this.element.appendChild($('div.description', undefined, localize('limitQuota', "Limits will reset on {0}.", this.dateFormatter.value.format(quotaResetDate)))); if (chatQuotaExceeded || completionsQuotaExceeded) { - const upgradePlanButton = disposables.add(new Button(this.element, { ...defaultButtonStyles, secondary: true })); + const upgradePlanButton = disposables.add(new Button(this.element, { ...defaultButtonStyles, secondary: canUseCopilot(this.contextKeyService, this.chatEntitlementService) /* use secondary color when copilot can still be used */ })); upgradePlanButton.label = localize('upgradeToCopilotPro', "Upgrade to Copilot Pro"); - disposables.add(upgradePlanButton.onDidClick(() => this.runCommandAndClose('workbench.action.chat.upgradePlan'))); + disposables.add(upgradePlanButton.onDidClick(() => this.runCommandAndClose({ id: 'workbench.action.chat.upgradePlan', args: ['chat-status'] }))); } (async () => { @@ -319,7 +317,7 @@ class ChatStatusDashboard extends Disposable { } // Settings - if (!isNewUser(this.contextKeyService, this.chatEntitlementService)) { + if (canUseCopilot(this.contextKeyService, this.chatEntitlementService)) { if (addSeparator) { this.element.appendChild($('hr')); addSeparator = false; @@ -334,8 +332,12 @@ class ChatStatusDashboard extends Disposable { return this.element; } - private runCommandAndClose(commandId: string): void { - this.commandService.executeCommand(commandId); + private runCommandAndClose(command: { id: string; args?: unknown[] } | Function): void { + if (typeof command === 'function') { + command(); + } else { + this.commandService.executeCommand(command.id, ...(command.args ?? [])); + } this.hoverService.hideHover(true); } @@ -386,11 +388,11 @@ class ChatStatusDashboard extends Disposable { // --- Code Completions { const globalSetting = append(settings, $('div.setting')); - this.createCodeCompletionsSetting(globalSetting, localize('settings.codeCompletions', "Code completions (all files)"), '*', disposables); + this.createCodeCompletionsSetting(globalSetting, localize('settings.codeCompletions', "Code Completions (all files)"), '*', disposables); if (modeId) { const languageSetting = append(settings, $('div.setting')); - this.createCodeCompletionsSetting(languageSetting, localize('settings.codeCompletionsLanguage', "Code completions ({0})", this.languageService.getLanguageName(modeId) ?? modeId), modeId, disposables); + this.createCodeCompletionsSetting(languageSetting, localize('settings.codeCompletionsLanguage', "Code Completions ({0})", this.languageService.getLanguageName(modeId) ?? modeId), modeId, disposables); } } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css index dfb5a545e92..82f35aebb20 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css @@ -26,17 +26,17 @@ } .chat-status-bar-entry-tooltip .monaco-button { - margin-top: 4px; - margin-bottom: 4px; + margin-top: 5px; + margin-bottom: 5px; } /* Setup for New User */ -.chat-feature-container { +.chat-status-bar-entry-tooltip .setup .chat-feature-container { display: flex; align-items: center; gap: 5px; - padding: 3px; + padding: 4px; } /* Quota Indicator */ From 63ee986e059679afd25ce6638a36541ce76a2532 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 12 Mar 2025 10:18:56 +0100 Subject: [PATCH 029/255] debt - move some code out of `platform` (#243307) --- .../browser/ui/severityIcon}/media/severityIcon.css | 0 .../browser/ui/severityIcon}/severityIcon.ts | 6 +++--- src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts | 2 +- .../contrib/extensions/browser/extensionFeaturesTab.ts | 2 +- .../contrib/extensions/browser/extensionsViewlet.ts | 2 +- .../workbench/contrib/extensions/browser/extensionsViews.ts | 2 +- src/vs/workbench/contrib/markers/browser/markersTable.ts | 2 +- .../workbench/contrib/markers/browser/markersTreeViewer.ts | 2 +- src/vs/workbench/contrib/search/browser/searchMessage.ts | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) rename src/vs/{platform/severityIcon/browser => base/browser/ui/severityIcon}/media/severityIcon.css (100%) rename src/vs/{platform/severityIcon/browser => base/browser/ui/severityIcon}/severityIcon.ts (82%) diff --git a/src/vs/platform/severityIcon/browser/media/severityIcon.css b/src/vs/base/browser/ui/severityIcon/media/severityIcon.css similarity index 100% rename from src/vs/platform/severityIcon/browser/media/severityIcon.css rename to src/vs/base/browser/ui/severityIcon/media/severityIcon.css diff --git a/src/vs/platform/severityIcon/browser/severityIcon.ts b/src/vs/base/browser/ui/severityIcon/severityIcon.ts similarity index 82% rename from src/vs/platform/severityIcon/browser/severityIcon.ts rename to src/vs/base/browser/ui/severityIcon/severityIcon.ts index 73bfe9c2da1..66f5564139a 100644 --- a/src/vs/platform/severityIcon/browser/severityIcon.ts +++ b/src/vs/base/browser/ui/severityIcon/severityIcon.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import './media/severityIcon.css'; -import { Codicon } from '../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import Severity from '../../../base/common/severity.js'; +import { Codicon } from '../../../common/codicons.js'; +import { ThemeIcon } from '../../../common/themables.js'; +import Severity from '../../../common/severity.js'; export namespace SeverityIcon { diff --git a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts index 33c8ce33323..d8828c06915 100644 --- a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts +++ b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ILabelService } from '../../../../platform/label/common/label.js'; import { IMarker, IRelatedInformation, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { contrastBorder, editorBackground, editorErrorBorder, editorErrorForeground, editorInfoBorder, editorInfoForeground, editorWarningBorder, editorWarningForeground, oneOf, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; import { IColorTheme, IThemeChangeEvent, IThemeService } from '../../../../platform/theme/common/themeService.js'; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts b/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts index 9d0f876a307..57b0ea186ef 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts @@ -27,7 +27,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import Severity from '../../../../base/common/severity.js'; import { errorIcon, infoIcon, warningIcon } from './extensionsIcons.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { OS } from '../../../../base/common/platform.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 5e3d3cdb327..9b840de9a61 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -62,7 +62,7 @@ import { ILocalizedString } from '../../../../platform/action/common/action.js'; import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index cbb4d0cbd4e..dd378644f9c 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -39,7 +39,7 @@ import { IAction, Action, Separator, ActionRunner } from '../../../../base/commo import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionUntrustedWorkspaceSupportType, ExtensionVirtualWorkspaceSupportType, IExtensionDescription, IExtensionIdentifier, isLanguagePackExtension } from '../../../../platform/extensions/common/extensions.js'; import { CancelablePromise, createCancelablePromise, ThrottledDelayer } from '../../../../base/common/async.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; diff --git a/src/vs/workbench/contrib/markers/browser/markersTable.ts b/src/vs/workbench/contrib/markers/browser/markersTable.ts index af8e6eafa18..3a10bafb327 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTable.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTable.ts @@ -13,7 +13,7 @@ import { IOpenEvent, IWorkbenchTableOptions, WorkbenchTable } from '../../../../ import { HighlightedLabel } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { compareMarkersByUri, Marker, MarkerTableItem, ResourceMarkers } from './markersModel.js'; import { MarkerSeverity } from '../../../../platform/markers/common/markers.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { FilterOptions } from './markersFilterOptions.js'; diff --git a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts index 609416de96a..4ed27e35584 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts @@ -35,7 +35,7 @@ import { applyCodeAction, ApplyCodeActionReason, getCodeActions } from '../../.. import { CodeActionKind, CodeActionSet, CodeActionTriggerSource } from '../../../../editor/contrib/codeAction/common/types.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { IEditorService, ACTIVE_GROUP } from '../../../services/editor/common/editorService.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { CodeActionTriggerType } from '../../../../editor/common/languages.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { Progress } from '../../../../platform/progress/common/progress.js'; diff --git a/src/vs/workbench/contrib/search/browser/searchMessage.ts b/src/vs/workbench/contrib/search/browser/searchMessage.ts index 37a03f28a89..ea8990aad3e 100644 --- a/src/vs/workbench/contrib/search/browser/searchMessage.ts +++ b/src/vs/workbench/contrib/search/browser/searchMessage.ts @@ -10,7 +10,7 @@ import { parseLinkedText } from '../../../../base/common/linkedText.js'; import Severity from '../../../../base/common/severity.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; +import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { TextSearchCompleteMessage, TextSearchCompleteMessageType } from '../../../services/search/common/searchExtTypes.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { Schemas } from '../../../../base/common/network.js'; From 1d929555c6ff546a22213f2ad5be797d7f884e71 Mon Sep 17 00:00:00 2001 From: Nick Trogh <1908215+ntrogh@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:28:57 +0100 Subject: [PATCH 030/255] Update command labels to meet capitalization guidance --- src/vs/editor/contrib/clipboard/browser/clipboard.ts | 2 +- .../contrib/codelens/browser/codelensController.ts | 2 +- src/vs/editor/contrib/find/browser/findController.ts | 4 ++-- .../contrib/multicursor/browser/multicursor.ts | 12 ++++++------ .../workbench/browser/parts/editor/editorActions.ts | 2 +- .../inlayHints/browser/inlayHintsAccessibilty.ts | 2 +- .../contrib/multicursor/notebookMulticursor.ts | 2 +- .../contrib/terminal/browser/terminalActions.ts | 8 ++++---- .../contrib/terminal/common/terminalStrings.ts | 6 +++--- .../electron-sandbox/actions/developerActions.ts | 2 +- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/vs/editor/contrib/clipboard/browser/clipboard.ts b/src/vs/editor/contrib/clipboard/browser/clipboard.ts index a5bc65bc100..aaf90905781 100644 --- a/src/vs/editor/contrib/clipboard/browser/clipboard.ts +++ b/src/vs/editor/contrib/clipboard/browser/clipboard.ts @@ -157,7 +157,7 @@ class ExecCommandCopyWithSyntaxHighlightingAction extends EditorAction { constructor() { super({ id: 'editor.action.clipboardCopyWithSyntaxHighlightingAction', - label: nls.localize2('actions.clipboard.copyWithSyntaxHighlightingLabel', "Copy With Syntax Highlighting"), + label: nls.localize2('actions.clipboard.copyWithSyntaxHighlightingLabel', "Copy with Syntax Highlighting"), precondition: undefined, kbOpts: { kbExpr: EditorContextKeys.textInputFocus, diff --git a/src/vs/editor/contrib/codelens/browser/codelensController.ts b/src/vs/editor/contrib/codelens/browser/codelensController.ts index 85cc79d7ca3..bd54868ea99 100644 --- a/src/vs/editor/contrib/codelens/browser/codelensController.ts +++ b/src/vs/editor/contrib/codelens/browser/codelensController.ts @@ -465,7 +465,7 @@ registerEditorAction(class ShowLensesInCurrentLine extends EditorAction { super({ id: 'codelens.showLensesInCurrentLine', precondition: EditorContextKeys.hasCodeLensProvider, - label: localize2('showLensOnLine', "Show CodeLens Commands For Current Line"), + label: localize2('showLensOnLine', "Show CodeLens Commands for Current Line"), }); } diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 6b6102ea5ac..9e00a1d5d47 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -584,7 +584,7 @@ export class StartFindWithArgsAction extends EditorAction { constructor() { super({ id: FIND_IDS.StartFindWithArgs, - label: nls.localize2('startFindWithArgsAction', "Find With Arguments"), + label: nls.localize2('startFindWithArgsAction', "Find with Arguments"), precondition: undefined, kbOpts: { kbExpr: null, @@ -633,7 +633,7 @@ export class StartFindWithSelectionAction extends EditorAction { constructor() { super({ id: FIND_IDS.StartFindWithSelection, - label: nls.localize2('startFindWithSelectionAction', "Find With Selection"), + label: nls.localize2('startFindWithSelectionAction', "Find with Selection"), precondition: undefined, kbOpts: { kbExpr: null, diff --git a/src/vs/editor/contrib/multicursor/browser/multicursor.ts b/src/vs/editor/contrib/multicursor/browser/multicursor.ts index 3138b9317bb..abc43c1f7d7 100644 --- a/src/vs/editor/contrib/multicursor/browser/multicursor.ts +++ b/src/vs/editor/contrib/multicursor/browser/multicursor.ts @@ -201,7 +201,7 @@ class InsertCursorAtEndOfLineSelected extends EditorAction { constructor() { super({ id: 'editor.action.addCursorsToBottom', - label: nls.localize2('mutlicursor.addCursorsToBottom', "Add Cursors To Bottom"), + label: nls.localize2('mutlicursor.addCursorsToBottom', "Add Cursors to Bottom"), precondition: undefined }); } @@ -233,7 +233,7 @@ class InsertCursorAtTopOfLineSelected extends EditorAction { constructor() { super({ id: 'editor.action.addCursorsToTop', - label: nls.localize2('mutlicursor.addCursorsToTop', "Add Cursors To Top"), + label: nls.localize2('mutlicursor.addCursorsToTop', "Add Cursors to Top"), precondition: undefined }); } @@ -686,7 +686,7 @@ export class AddSelectionToNextFindMatchAction extends MultiCursorSelectionContr constructor() { super({ id: 'editor.action.addSelectionToNextFindMatch', - label: nls.localize2('addSelectionToNextFindMatch', "Add Selection To Next Find Match"), + label: nls.localize2('addSelectionToNextFindMatch', "Add Selection to Next Find Match"), precondition: undefined, kbOpts: { kbExpr: EditorContextKeys.focus, @@ -710,7 +710,7 @@ export class AddSelectionToPreviousFindMatchAction extends MultiCursorSelectionC constructor() { super({ id: 'editor.action.addSelectionToPreviousFindMatch', - label: nls.localize2('addSelectionToPreviousFindMatch', "Add Selection To Previous Find Match"), + label: nls.localize2('addSelectionToPreviousFindMatch', "Add Selection to Previous Find Match"), precondition: undefined, menuOpts: { menuId: MenuId.MenubarSelectionMenu, @@ -729,7 +729,7 @@ export class MoveSelectionToNextFindMatchAction extends MultiCursorSelectionCont constructor() { super({ id: 'editor.action.moveSelectionToNextFindMatch', - label: nls.localize2('moveSelectionToNextFindMatch', "Move Last Selection To Next Find Match"), + label: nls.localize2('moveSelectionToNextFindMatch', "Move Last Selection to Next Find Match"), precondition: undefined, kbOpts: { kbExpr: EditorContextKeys.focus, @@ -747,7 +747,7 @@ export class MoveSelectionToPreviousFindMatchAction extends MultiCursorSelection constructor() { super({ id: 'editor.action.moveSelectionToPreviousFindMatch', - label: nls.localize2('moveSelectionToPreviousFindMatch', "Move Last Selection To Previous Find Match"), + label: nls.localize2('moveSelectionToPreviousFindMatch', "Move Last Selection to Previous Find Match"), precondition: undefined }); } diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index c922c06e817..52d1dc5859c 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -2493,7 +2493,7 @@ export class ReOpenInTextEditorAction extends Action2 { constructor() { super({ id: 'workbench.action.reopenTextEditor', - title: localize2('reopenTextEditor', 'Reopen Editor With Text Editor'), + title: localize2('reopenTextEditor', 'Reopen Editor with Text Editor'), f1: true, category: Categories.View, precondition: ActiveEditorAvailableEditorIdsContext diff --git a/src/vs/workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.ts b/src/vs/workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.ts index 0494623f078..ab3d1dabf53 100644 --- a/src/vs/workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.ts +++ b/src/vs/workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.ts @@ -174,7 +174,7 @@ registerAction2(class StartReadHints extends EditorAction2 { constructor() { super({ id: 'inlayHints.startReadingLineWithHint', - title: localize2('read.title', "Read Line With Inline Hints"), + title: localize2('read.title', "Read Line with Inline Hints"), precondition: EditorContextKeys.hasInlayHintsProvider, f1: true }); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts index 8ce231cbd24..116d5d3fab9 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts @@ -1035,7 +1035,7 @@ class NotebookAddMatchToMultiSelectionAction extends NotebookAction { constructor() { super({ id: NOTEBOOK_ADD_FIND_MATCH_TO_SELECTION_ID, - title: localize('addFindMatchToSelection', "Add Selection To Next Find Match"), + title: localize('addFindMatchToSelection', "Add Selection to Next Find Match"), precondition: ContextKeyExpr.and( ContextKeyExpr.equals('config.notebook.multiCursor.enabled', true), NOTEBOOK_IS_ACTIVE_EDITOR, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 664eea28908..6008c2b6dd7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -904,7 +904,7 @@ export function registerTerminalActions() { registerActiveInstanceAction({ id: TerminalCommandId.SelectToPreviousCommand, - title: localize2('workbench.action.terminal.selectToPreviousCommand', 'Select To Previous Command'), + title: localize2('workbench.action.terminal.selectToPreviousCommand', 'Select to Previous Command'), keybinding: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.UpArrow, when: TerminalContextKeys.focus, @@ -919,7 +919,7 @@ export function registerTerminalActions() { registerActiveInstanceAction({ id: TerminalCommandId.SelectToNextCommand, - title: localize2('workbench.action.terminal.selectToNextCommand', 'Select To Next Command'), + title: localize2('workbench.action.terminal.selectToNextCommand', 'Select to Next Command'), keybinding: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.DownArrow, when: TerminalContextKeys.focus, @@ -934,7 +934,7 @@ export function registerTerminalActions() { registerActiveXtermAction({ id: TerminalCommandId.SelectToPreviousLine, - title: localize2('workbench.action.terminal.selectToPreviousLine', 'Select To Previous Line'), + title: localize2('workbench.action.terminal.selectToPreviousLine', 'Select to Previous Line'), precondition: sharedWhenClause.terminalAvailable, run: async (xterm, _, instance) => { xterm.markTracker.selectToPreviousLine(); @@ -945,7 +945,7 @@ export function registerTerminalActions() { registerActiveXtermAction({ id: TerminalCommandId.SelectToNextLine, - title: localize2('workbench.action.terminal.selectToNextLine', 'Select To Next Line'), + title: localize2('workbench.action.terminal.selectToNextLine', 'Select to Next Line'), precondition: sharedWhenClause.terminalAvailable, run: async (xterm, _, instance) => { xterm.markTracker.selectToNextLine(); diff --git a/src/vs/workbench/contrib/terminal/common/terminalStrings.ts b/src/vs/workbench/contrib/terminal/common/terminalStrings.ts index 101fb7f7e99..ed00de1f363 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalStrings.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalStrings.ts @@ -36,9 +36,9 @@ export const terminalStrings = { rename: localize2('workbench.action.terminal.rename', "Rename..."), toggleSizeToContentWidth: localize2('workbench.action.terminal.sizeToContentWidthInstance', "Toggle Size to Content Width"), focusHover: localize2('workbench.action.terminal.focusHover', "Focus Hover"), - sendSequence: localize2('workbench.action.terminal.sendSequence', "Send Custom Sequence To Terminal"), + sendSequence: localize2('workbench.action.terminal.sendSequence', "Send Custom Sequence to Terminal"), newWithCwd: localize2('workbench.action.terminal.newWithCwd', "Create New Terminal Starting in a Custom Working Directory"), renameWithArgs: localize2('workbench.action.terminal.renameWithArg', "Rename the Currently Active Terminal"), - scrollToPreviousCommand: localize2('workbench.action.terminal.scrollToPreviousCommand', "Scroll To Previous Command"), - scrollToNextCommand: localize2('workbench.action.terminal.scrollToNextCommand', "Scroll To Next Command") + scrollToPreviousCommand: localize2('workbench.action.terminal.scrollToPreviousCommand', "Scroll to Previous Command"), + scrollToNextCommand: localize2('workbench.action.terminal.scrollToNextCommand', "Scroll to Next Command") }; diff --git a/src/vs/workbench/electron-sandbox/actions/developerActions.ts b/src/vs/workbench/electron-sandbox/actions/developerActions.ts index 2c3040af2eb..42ce42d0025 100644 --- a/src/vs/workbench/electron-sandbox/actions/developerActions.ts +++ b/src/vs/workbench/electron-sandbox/actions/developerActions.ts @@ -73,7 +73,7 @@ export class ReloadWindowWithExtensionsDisabledAction extends Action2 { constructor() { super({ id: 'workbench.action.reloadWindowWithExtensionsDisabled', - title: localize2('reloadWindowWithExtensionsDisabled', 'Reload With Extensions Disabled'), + title: localize2('reloadWindowWithExtensionsDisabled', 'Reload with Extensions Disabled'), category: Categories.Developer, f1: true }); From 52e197c53369c7a29d0d760fcb15251e778d5c48 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:37:16 +0100 Subject: [PATCH 031/255] Fix initialization orders for inline edits (#243310) fix init orders --- build/lib/propertyInitOrderChecker.js | 4 -- build/lib/propertyInitOrderChecker.ts | 4 -- .../components/gutterIndicatorMenu.ts | 3 +- .../view/inlineEdits/inlineEditsModel.ts | 28 ++++++++----- .../view/inlineEdits/inlineEditsView.ts | 23 +++++++---- .../inlineEdits/inlineEditsViewProducer.ts | 10 +++-- .../inlineEditsDeletionView.ts | 40 +++++++++++-------- 7 files changed, 64 insertions(+), 48 deletions(-) diff --git a/build/lib/propertyInitOrderChecker.js b/build/lib/propertyInitOrderChecker.js index 6bcc728f06e..1ea42de4a56 100644 --- a/build/lib/propertyInitOrderChecker.js +++ b/build/lib/propertyInitOrderChecker.js @@ -87,17 +87,13 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts', 'vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts', 'vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts', 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts', diff --git a/build/lib/propertyInitOrderChecker.ts b/build/lib/propertyInitOrderChecker.ts index 78dc22e4002..908b45a9788 100644 --- a/build/lib/propertyInitOrderChecker.ts +++ b/build/lib/propertyInitOrderChecker.ts @@ -56,17 +56,13 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts', 'vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts', 'vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts', 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts', diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts index 5a5b7aaa1d3..848af4d2613 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts @@ -27,7 +27,7 @@ import { FirstFnArg, } from '../utils/utils.js'; export class GutterIndicatorMenuContent { - private readonly _inlineEditsShowCollapsed = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed); + private readonly _inlineEditsShowCollapsed: IObservable; constructor( private readonly _model: IInlineEditModel, @@ -37,6 +37,7 @@ export class GutterIndicatorMenuContent { @IKeybindingService private readonly _keybindingService: IKeybindingService, @ICommandService private readonly _commandService: ICommandService, ) { + this._inlineEditsShowCollapsed = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed); } public toDisposableLiveElement(): LiveElement { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts index 56db7d9f1b3..2c8e66d7227 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts @@ -9,6 +9,7 @@ import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { LineRange } from '../../../../../common/core/lineRange.js'; import { StringText, TextEdit } from '../../../../../common/core/textEdit.js'; +import { Command } from '../../../../../common/languages.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; import { InlineCompletionWithUpdatedRange } from '../../model/inlineCompletionsSource.js'; import { IInlineEditModel, InlineEditTabAction } from './inlineEditsViewInterface.js'; @@ -16,18 +17,25 @@ import { InlineEditWithChanges } from './inlineEditWithChanges.js'; export class InlineEditModel implements IInlineEditModel { - readonly action = this.inlineEdit.inlineCompletion.action; - readonly displayName = this.inlineEdit.inlineCompletion.source.provider.displayName ?? localize('inlineEdit', "Inline Edit"); - readonly extensionCommands = this.inlineEdit.inlineCompletion.source.inlineCompletions.commands ?? []; + readonly action: Command | undefined; + readonly displayName: string; + readonly extensionCommands: Command[]; - readonly inAcceptFlow = this._model.inAcceptFlow; - readonly inPartialAcceptFlow = this._model.inPartialAcceptFlow; + readonly inAcceptFlow: IObservable; + readonly inPartialAcceptFlow: IObservable; constructor( private readonly _model: InlineCompletionsModel, readonly inlineEdit: InlineEditWithChanges, readonly tabAction: IObservable, - ) { } + ) { + this.action = this.inlineEdit.inlineCompletion.action; + this.displayName = this.inlineEdit.inlineCompletion.source.provider.displayName ?? localize('inlineEdit', "Inline Edit"); + this.extensionCommands = this.inlineEdit.inlineCompletion.source.inlineCompletions.commands ?? []; + + this.inAcceptFlow = this._model.inAcceptFlow; + this.inPartialAcceptFlow = this._model.inPartialAcceptFlow; + } accept() { this._model.accept(); @@ -52,18 +60,16 @@ export class GhostTextIndicator { readonly model: InlineEditModel; - private readonly _editorObs = observableCodeEditor(this._editor); - constructor( - private _editor: ICodeEditor, + editor: ICodeEditor, model: InlineCompletionsModel, readonly lineRange: LineRange, inlineCompletion: InlineCompletionWithUpdatedRange, renderExplicitly: boolean, ) { - + const editorObs = observableCodeEditor(editor); const tabAction = derived(this, reader => { - if (this._editorObs.isFocused.read(reader)) { + if (editorObs.isFocused.read(reader)) { if (model.inlineCompletionState.read(reader)?.inlineCompletion?.sourceInlineCompletion.showInlineEditMenu) { return InlineEditTabAction.Accept; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts index 6b6834a8221..dad7dacaf49 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts @@ -10,7 +10,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { autorunWithStore, derived, derivedOpts, derivedWithStore, IObservable, IReader, ISettableObservable, mapObservableArrayCached, observableValue } from '../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; -import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { EditorOption } from '../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../common/core/lineRange.js'; import { Position } from '../../../../../common/core/position.js'; @@ -34,14 +34,14 @@ import { applyEditToModifiedRangeMappings, createReindentEdit } from './utils/ut import './view.css'; export class InlineEditsView extends Disposable { - private readonly _editorObs = observableCodeEditor(this._editor); + private readonly _editorObs: ObservableCodeEditor = observableCodeEditor(this._editor); - private readonly _useMixedLinesDiff = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useMixedLinesDiff); - private readonly _useInterleavedLinesDiff = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useInterleavedLinesDiff); - private readonly _useCodeShifting = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.allowCodeShifting); - private readonly _renderSideBySide = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.renderSideBySide); - private readonly _showCollapsed = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed); - private readonly _useMultiLineGhostText = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useMultiLineGhostText); + private readonly _useMixedLinesDiff; + private readonly _useInterleavedLinesDiff; + private readonly _useCodeShifting; + private readonly _renderSideBySide; + private readonly _showCollapsed; + private readonly _useMultiLineGhostText; private readonly _tabAction = derived(reader => this._model.read(reader)?.tabAction.read(reader) ?? InlineEditTabAction.Inactive); @@ -62,6 +62,13 @@ export class InlineEditsView extends Disposable { ) { super(); + this._useMixedLinesDiff = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useMixedLinesDiff); + this._useInterleavedLinesDiff = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useInterleavedLinesDiff); + this._useCodeShifting = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.allowCodeShifting); + this._renderSideBySide = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.renderSideBySide); + this._showCollapsed = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed); + this._useMultiLineGhostText = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.useMultiLineGhostText); + this._register(autorunWithStore((reader, store) => { const model = this._model.read(reader); if (!model) { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts index cd93ce8a60a..f63b071a0ff 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { derived, IObservable, ISettableObservable } from '../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; -import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { LineRange } from '../../../../../common/core/lineRange.js'; import { Range } from '../../../../../common/core/range.js'; import { SingleTextEdit, TextEdit } from '../../../../../common/core/textEdit.js'; @@ -23,7 +23,7 @@ import { InlineEditTabAction } from './inlineEditsViewInterface.js'; export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This class is no longer a diff producer. Rename it or get rid of it public static readonly hot = createHotClass(InlineEditsViewAndDiffProducer); - private readonly _editorObs = observableCodeEditor(this._editor); + private readonly _editorObs: ObservableCodeEditor; private readonly _inlineEdit = derived(this, (reader) => { const model = this._model.read(reader); @@ -91,10 +91,12 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c private readonly _edit: IObservable, private readonly _model: IObservable, private readonly _focusIsInMenu: ISettableObservable, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._register(this._instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEditModel, this._ghostTextIndicator, this._focusIsInMenu)); + this._editorObs = observableCodeEditor(this._editor); + + this._register(instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEditModel, this._ghostTextIndicator, this._focusIsInMenu)); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts index 2881fb21196..9feed6f4a6b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, derived, derivedObservableWithCache, IObservable } from '../../../../../../../base/common/observable.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; -import { observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { Point } from '../../../../../../browser/point.js'; import { LineRange } from '../../../../../../common/core/lineRange.js'; import { Position } from '../../../../../../common/core/position.js'; @@ -20,11 +20,17 @@ import { getOriginalBorderColor, originalBackgroundColor } from '../theme.js'; import { createRectangle, getPrefixTrim, mapOutFalsy, maxContentWidthInRange } from '../utils/utils.js'; export class InlineEditsDeletionView extends Disposable implements IInlineEditsView { - private readonly _editorObs = observableCodeEditor(this._editor); private readonly _onDidClick = this._register(new Emitter()); readonly onDidClick = this._onDidClick.event; + private readonly _editorObs: ObservableCodeEditor; + + private readonly _originalVerticalStartPosition: IObservable; + private readonly _originalVerticalEndPosition: IObservable; + + private readonly _originalDisplayRange: IObservable; + constructor( private readonly _editor: ICodeEditor, private readonly _edit: IObservable, @@ -36,6 +42,22 @@ export class InlineEditsDeletionView extends Disposable implements IInlineEditsV ) { super(); + this._editorObs = observableCodeEditor(this._editor); + + const originalStartPosition = derived(this, (reader) => { + const inlineEdit = this._edit.read(reader); + return inlineEdit ? new Position(inlineEdit.originalLineRange.startLineNumber, 1) : null; + }); + + const originalEndPosition = derived(this, (reader) => { + const inlineEdit = this._edit.read(reader); + return inlineEdit ? new Position(inlineEdit.originalLineRange.endLineNumberExclusive, 1) : null; + }); + + this._originalDisplayRange = this._uiState.map(s => s?.originalRange); + this._originalVerticalStartPosition = this._editorObs.observePosition(originalStartPosition, this._store).map(p => p?.y); + this._originalVerticalEndPosition = this._editorObs.observePosition(originalEndPosition, this._store).map(p => p?.y); + this._register(this._editorObs.createOverlayWidget({ domNode: this._nonOverflowView.element, position: constObservable(null), @@ -50,20 +72,6 @@ export class InlineEditsDeletionView extends Disposable implements IInlineEditsV private readonly _display = derived(this, reader => !!this._uiState.read(reader) ? 'block' : 'none'); - private readonly _originalStartPosition = derived(this, (reader) => { - const inlineEdit = this._edit.read(reader); - return inlineEdit ? new Position(inlineEdit.originalLineRange.startLineNumber, 1) : null; - }); - - private readonly _originalEndPosition = derived(this, (reader) => { - const inlineEdit = this._edit.read(reader); - return inlineEdit ? new Position(inlineEdit.originalLineRange.endLineNumberExclusive, 1) : null; - }); - - private readonly _originalVerticalStartPosition = this._editorObs.observePosition(this._originalStartPosition, this._store).map(p => p?.y); - private readonly _originalVerticalEndPosition = this._editorObs.observePosition(this._originalEndPosition, this._store).map(p => p?.y); - - private readonly _originalDisplayRange = this._uiState.map(s => s?.originalRange); private readonly _editorMaxContentWidthInRange = derived(this, reader => { const originalDisplayRange = this._originalDisplayRange.read(reader); if (!originalDisplayRange) { From f9c6242a1793598cacfeb9fa229a7440beacae29 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 12 Mar 2025 11:03:07 +0100 Subject: [PATCH 032/255] fix https://github.com/microsoft/vscode/issues/243168 (#243311) --- src/vs/editor/contrib/suggest/browser/suggestModel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index bf6ce4b6867..fcf97af35a6 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -505,6 +505,7 @@ export class SuggestModel implements IDisposable { this._requestToken?.dispose(); if (!this._editor.hasModel()) { + completions.disposable.dispose(); return; } @@ -514,6 +515,7 @@ export class SuggestModel implements IDisposable { } if (this._triggerState === undefined) { + completions.disposable.dispose(); return; } From 7c64719ac9e5a7a5b00702a7486032290086604b Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:04:04 +0100 Subject: [PATCH 033/255] insertion view init order fix (#243316) init order --- build/lib/propertyInitOrderChecker.js | 1 - build/lib/propertyInitOrderChecker.ts | 1 - .../inlineEditsInsertionView.ts | 39 +++++++++++-------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/build/lib/propertyInitOrderChecker.js b/build/lib/propertyInitOrderChecker.js index 1ea42de4a56..4c5b735d8a7 100644 --- a/build/lib/propertyInitOrderChecker.js +++ b/build/lib/propertyInitOrderChecker.js @@ -88,7 +88,6 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts', diff --git a/build/lib/propertyInitOrderChecker.ts b/build/lib/propertyInitOrderChecker.ts index 908b45a9788..5b158255212 100644 --- a/build/lib/propertyInitOrderChecker.ts +++ b/build/lib/propertyInitOrderChecker.ts @@ -57,7 +57,6 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts', - 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts', diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts index 2f631455e49..bb20b7d863e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts @@ -10,7 +10,7 @@ import { constObservable, derived, derivedWithStore, IObservable, observableValu import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; -import { observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { Rect } from '../../../../../../browser/rect.js'; import { LineSource, renderLines, RenderOptions } from '../../../../../../browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; import { EditorOption } from '../../../../../../common/config/editorOptions.js'; @@ -28,7 +28,7 @@ import { getModifiedBorderColor, modifiedChangedLineBackgroundColor } from '../t import { createRectangle, getPrefixTrim, mapOutFalsy } from '../utils/utils.js'; export class InlineEditsInsertionView extends Disposable implements IInlineEditsView { - private readonly _editorObs = observableCodeEditor(this._editor); + private readonly _editorObs: ObservableCodeEditor; private readonly _onDidClick = this._register(new Emitter()); readonly onDidClick = this._onDidClick.event; @@ -86,18 +86,8 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return new GhostText(state.lineNumber, [new GhostTextPart(state.column, state.text, false, inlineDecorations)]); }); - protected readonly _ghostTextView = this._register(this._instantiationService.createInstance(GhostTextView, - this._editor, - { - ghostText: this._ghostText, - minReservedLineCount: constObservable(0), - targetTextModel: this._editorObs.model.map(model => model ?? undefined), - warning: constObservable(undefined), - }, - observableValue(this, { syntaxHighlightingEnabled: true, extraClasses: ['inline-edit'] }), - true, - true - )); + protected readonly _ghostTextView: GhostTextView; + readonly isHovered: IObservable; constructor( private readonly _editor: ICodeEditor, @@ -107,11 +97,28 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits text: string; } | undefined>, private readonly _tabAction: IObservable, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, @ILanguageService private readonly _languageService: ILanguageService, ) { super(); + this._editorObs = observableCodeEditor(this._editor); + + this._ghostTextView = this._register(instantiationService.createInstance(GhostTextView, + this._editor, + { + ghostText: this._ghostText, + minReservedLineCount: constObservable(0), + targetTextModel: this._editorObs.model.map(model => model ?? undefined), + warning: constObservable(undefined), + }, + observableValue(this, { syntaxHighlightingEnabled: true, extraClasses: ['inline-edit'] }), + true, + true + )); + + this.isHovered = this._ghostTextView.isHovered; + this._register(this._ghostTextView.onDidClick((e) => { this._onDidClick.fire(e); })); @@ -283,6 +290,4 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits }, [ [this._foregroundSvg], ]).keepUpdated(this._store); - - readonly isHovered = this._ghostTextView.isHovered; } From b36d137b54bfde3290b0f28ae857388f9fcd3184 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 12 Mar 2025 11:05:43 +0100 Subject: [PATCH 034/255] add close btn to inline chat (#243319) --- .../inlineChat/browser/inlineChatActions.ts | 16 +++++++++++----- .../inlineChat/browser/inlineChatZoneWidget.ts | 3 ++- .../contrib/inlineChat/common/inlineChat.ts | 2 ++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 7fa126eccc0..434bffb469d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatController1, InlineChatController2, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, ACTION_DISCARD_CHANGES, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_HAS_AGENT2 } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, ACTION_DISCARD_CHANGES, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_HAS_AGENT2, MENU_INLINE_CHAT_SIDE } from '../common/inlineChat.js'; import { ctxIsGlobalEditingSession, ctxRequestCount } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId } from '../../../../platform/actions/common/actions.js'; @@ -577,20 +577,26 @@ abstract class AbstractInline2ChatAction extends EditorAction2 { } export class StopSessionAction2 extends AbstractInline2ChatAction { + constructor() { super({ id: 'inlineChat2.stop', - title: localize2('stop', "Stop"), + title: localize2('stop', "Undo & Close"), f1: true, + icon: Codicon.close, precondition: CTX_INLINE_CHAT_VISIBLE, keybinding: [{ - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Escape, - }, { when: ctxRequestCount.isEqualTo(0), weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyI, + }, { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape, }], + menu: { + id: MENU_INLINE_CHAT_SIDE, + group: 'navigation', + } }); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index 138b51910c4..83836042939 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -22,7 +22,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatWidgetViewOptions } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; import { isResponseVM } from '../../chat/common/chatViewModel.js'; -import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; +import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_SIDE, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; export class InlineChatZoneWidget extends ZoneWidget { @@ -81,6 +81,7 @@ export class InlineChatZoneWidget extends ZoneWidget { chatWidgetViewOptions: { menus: { telemetrySource: 'interactiveEditorWidget-toolbar', + inputSideToolbar: MENU_INLINE_CHAT_SIDE }, ...options, rendererOptions: { diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index d2a96c1d2cd..3b6979b8f10 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -106,6 +106,8 @@ export const MENU_INLINE_CHAT_WIDGET_STATUS = MenuId.for('inlineChatWidget.statu export const MENU_INLINE_CHAT_WIDGET_SECONDARY = MenuId.for('inlineChatWidget.secondary'); export const MENU_INLINE_CHAT_ZONE = MenuId.for('inlineChatWidget.changesZone'); +export const MENU_INLINE_CHAT_SIDE = MenuId.for('inlineChatWidget.side'); + // --- colors From bdc8c075801f95287c92d7040ce63d9448f082ab Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:07:22 +0100 Subject: [PATCH 035/255] High contrast gutter items (#240906) High contract gutter items --- build/lib/stylelint/vscode-known-variables.json | 2 ++ src/vs/editor/browser/widget/diffEditor/style.css | 3 ++- src/vs/workbench/contrib/scm/common/quickDiff.ts | 11 ++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 9a5b08022b6..6b8419291cb 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -232,6 +232,8 @@ "--vscode-editorGutter-commentGlyphForeground", "--vscode-editorGutter-commentRangeForeground", "--vscode-editorGutter-commentUnresolvedGlyphForeground", + "--vscode-editorGutter-itemGlyphForeground", + "--vscode-editorGutter-itemBackground", "--vscode-editorGutter-deletedBackground", "--vscode-editorGutter-foldingControlForeground", "--vscode-editorGutter-modifiedBackground", diff --git a/src/vs/editor/browser/widget/diffEditor/style.css b/src/vs/editor/browser/widget/diffEditor/style.css index 8c027c33e1c..8a461de93ef 100644 --- a/src/vs/editor/browser/widget/diffEditor/style.css +++ b/src/vs/editor/browser/widget/diffEditor/style.css @@ -382,7 +382,7 @@ .actions-container { width: fit-content; border-radius: 4px; - background: var(--vscode-editorGutter-commentRangeForeground); + background: var(--vscode-editorGutter-itemBackground); .action-item { &:hover { @@ -390,6 +390,7 @@ } .action-label { + color: var(--vscode-editorGutter-itemGlyphForeground); padding: 1px 2px; } } diff --git a/src/vs/workbench/contrib/scm/common/quickDiff.ts b/src/vs/workbench/contrib/scm/common/quickDiff.ts index 55cd69561f0..60dd34b399e 100644 --- a/src/vs/workbench/contrib/scm/common/quickDiff.ts +++ b/src/vs/workbench/contrib/scm/common/quickDiff.ts @@ -14,7 +14,10 @@ import { LineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js import { IChange } from '../../../../editor/common/diff/legacyLinesDiffComputer.js'; import { IColorTheme } from '../../../../platform/theme/common/themeService.js'; import { Color } from '../../../../base/common/color.js'; -import { editorErrorForeground, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; +import { + darken, editorBackground, editorForeground, listInactiveSelectionBackground, opaque, + editorErrorForeground, registerColor, transparent +} from '../../../../platform/theme/common/colorRegistry.js'; export const IQuickDiffService = createDecorator('quickDiff'); @@ -45,6 +48,12 @@ export const overviewRulerAddedForeground = registerColor('editorOverviewRuler.a export const overviewRulerDeletedForeground = registerColor('editorOverviewRuler.deletedForeground', transparent(editorGutterDeletedBackground, 0.6), nls.localize('overviewRulerDeletedForeground', 'Overview ruler marker color for deleted content.')); +export const editorGutterItemGlyphForeground = registerColor('editorGutter.itemGlyphForeground', + { dark: editorForeground, light: editorForeground, hcDark: Color.black, hcLight: Color.white }, + nls.localize('editorGutterItemGlyphForeground', 'Editor gutter decoration color for gutter item glyphs.') +); +export const editorGutterItemBackground = registerColor('editorGutter.itemBackground', { dark: opaque(listInactiveSelectionBackground, editorBackground), light: darken(opaque(listInactiveSelectionBackground, editorBackground), .05), hcDark: Color.white, hcLight: Color.black }, nls.localize('editorGutterItemBackground', 'Editor gutter decoration color for gutter item background. This color should be opaque.')); + export interface QuickDiffProvider { label: string; rootUri: URI | undefined; From 94ee396f8464609258676ee1705370d450edf748 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:12:18 +0100 Subject: [PATCH 036/255] Enable tree sitter for typescript (#243322) --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f3e04c8f893..1116a4903f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -177,5 +177,6 @@ "*": "error", "ts": "warning", "eslint": "warning" - } + }, + "editor.experimental.preferTreeSitter.typescript": true } From 255c12ce12a8d38721bb337346b6b11d3d57dede Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:12:36 +0100 Subject: [PATCH 037/255] Fix tab progress with different tab sizings (#243321) fixes https://github.com/microsoft/vscode/issues/242661 --- .../browser/parts/editor/media/multieditortabscontrol.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index b9375b27dc2..31c863e9901 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -345,7 +345,7 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink > .tab-label.tab-label-has-badge::after, .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fixed > .tab-label.tab-label-has-badge::after { - padding-right: 5px; /* with tab sizing shrink/fixed and badges, we want a right-padding because the close button is hidden */ + margin-right: 5px; /* with tab sizing shrink/fixed and badges, we want a right-margin because the close button is hidden. Use margin instead of padding to support animating the badge (https://github.com/microsoft/vscode/issues/242661) */ } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink:not(.tab-actions-left):not(.close-action-off) .tab-label { @@ -386,7 +386,7 @@ * fully without clipping in case the parent container has `overflow: hidden` * and/or `flex: none`. We added those styles to fix other issues so a pragmatic * solution is to give the label container a bit more room to fully render. - * + * * Refs: https://github.com/microsoft/vscode/issues/207409 */ padding-right: 1px; From 8c0bf90908836410fcb1099a7c550a34f36f0ffc Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 04:48:35 -0700 Subject: [PATCH 038/255] Improve name active -> pending Part of #242195 --- .../api/common/extHostTerminalShellIntegration.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index fbbe2863ea4..8dcc6614301 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -150,7 +150,7 @@ export class ExtHostTerminalShellIntegration extends Disposable implements IExtH } class InternalTerminalShellIntegration extends Disposable { - private _activeExecutions: InternalTerminalShellExecution[] = []; + private _pendingExecutions: InternalTerminalShellExecution[] = []; private _currentExecution: InternalTerminalShellExecution | undefined; get currentExecution(): InternalTerminalShellExecution | undefined { return this._currentExecution; } @@ -220,7 +220,7 @@ class InternalTerminalShellIntegration extends Disposable { requestNewShellExecution(commandLine: vscode.TerminalShellExecutionCommandLine, cwd: URI | undefined) { const execution = new InternalTerminalShellExecution(commandLine, cwd ?? this._cwd); - this._activeExecutions.push(execution); + this._pendingExecutions.push(execution); this._onDidRequestNewExecution.fire(commandLine.value); return execution; } @@ -234,16 +234,16 @@ class InternalTerminalShellIntegration extends Disposable { this._onDidRequestEndExecution.fire({ terminal: this._terminal, shellIntegration: this.value, execution: this._currentExecution.value, exitCode: undefined }); } - // Get the active execution, how strict this is depends on whether the terminal has rich - // command detection + // Get the matching pending execution, how strict this is depends on the confidence of the + // command line let currentExecution: InternalTerminalShellExecution | undefined; if (commandLine.confidence === TerminalShellExecutionCommandLineConfidence.High) { - const index = this._activeExecutions.findIndex(e => e.value.commandLine.value === commandLine.value); + const index = this._pendingExecutions.findIndex(e => e.value.commandLine.value === commandLine.value); if (index !== -1) { - currentExecution = this._activeExecutions.splice(index, 1)[0]; + currentExecution = this._pendingExecutions.splice(index, 1)[0]; } } else { - currentExecution = this._activeExecutions.shift(); + currentExecution = this._pendingExecutions.shift(); } // If there is no execution, create a new one From 6e072ba61f6119d23ae3cd5b1a70eb67b7164887 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 05:10:58 -0700 Subject: [PATCH 039/255] Require chat enabled as precond for terminal actions Fixes #243306 --- .../chat/browser/terminalChatActions.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 782aca3edb1..a18ee7ae7dd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -10,6 +10,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; import { ChatAgentLocation } from '../../../chat/common/chatAgents.js'; +import { ChatContextKeys } from '../../../chat/common/chatContextKeys.js'; import { IChatService } from '../../../chat/common/chatService.js'; import { AbstractInline1ChatAction } from '../../../inlineChat/browser/inlineChatActions.js'; import { isDetachedTerminalInstance } from '../../../terminal/browser/terminal.js'; @@ -30,6 +31,7 @@ registerActiveXtermAction({ }, f1: true, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.hasChatAgent ), @@ -74,7 +76,10 @@ registerActiveXtermAction({ }], icon: Codicon.close, f1: true, - precondition: TerminalChatContextKeys.visible, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + TerminalChatContextKeys.visible, + ), run: (_xterm, _accessor, activeInstance) => { if (isDetachedTerminalInstance(activeInstance)) { return; @@ -90,6 +95,7 @@ registerActiveXtermAction({ shortTitle: localize2('run', 'Run'), category: AbstractInline1ChatAction.category, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), TerminalChatContextKeys.responseContainsCodeBlock, @@ -122,6 +128,7 @@ registerActiveXtermAction({ shortTitle: localize2('runFirst', 'Run First'), category: AbstractInline1ChatAction.category, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), TerminalChatContextKeys.responseContainsMultipleCodeBlocks @@ -154,6 +161,7 @@ registerActiveXtermAction({ category: AbstractInline1ChatAction.category, icon: Codicon.insert, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), TerminalChatContextKeys.responseContainsCodeBlock, @@ -186,6 +194,7 @@ registerActiveXtermAction({ shortTitle: localize2('insertFirst', 'Insert First'), category: AbstractInline1ChatAction.category, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), TerminalChatContextKeys.responseContainsMultipleCodeBlocks @@ -218,6 +227,7 @@ registerActiveXtermAction({ icon: Codicon.refresh, category: AbstractInline1ChatAction.category, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), ), @@ -259,6 +269,7 @@ registerActiveXtermAction({ title: localize2('viewInChat', 'View in Chat'), category: AbstractInline1ChatAction.category, precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), TerminalChatContextKeys.requestActive.negate(), ), From d81f6ad5b8fe26ed793a47e6911d75bafee7b3de Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 05:49:08 -0700 Subject: [PATCH 040/255] Improve docs for 633 D sequence Fixes #179913 --- .../platform/terminal/common/xterm/shellIntegrationAddon.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index a4489849b2c..f17dbb5be08 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -131,8 +131,10 @@ const enum VSCodeOscPt { CommandExecuted = 'C', /** - * Sent just after a command has finished. The exit code is optional, when not specified it - * means no command was run (ie. enter on empty prompt or ctrl+c). + * Sent just after a command has finished. This should generally be used on the new line + * following the end of a command's output, just before {@link PromptStart}. The exit code is + * optional, when not specified it means no command was run (ie. enter on empty prompt or + * ctrl+c). * * Format: `OSC 633 ; D [; ] ST` * From ca677df9f5b397233ab0fd90658d039e47275f5d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 06:39:26 -0700 Subject: [PATCH 041/255] Hide terminal hover details by default Fixes #242103 --- .../browser/environmentVariableInfo.ts | 3 +- .../terminal/browser/terminalTabbedView.ts | 2 +- .../terminal/browser/terminalTabsList.ts | 11 +++-- .../terminal/browser/terminalTooltip.ts | 47 +++++++++++++++---- .../contrib/terminal/browser/terminalView.ts | 6 ++- .../contrib/terminal/common/terminal.ts | 4 ++ .../terminal/common/terminalStorageKeys.ts | 1 + 7 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/environmentVariableInfo.ts b/src/vs/workbench/contrib/terminal/browser/environmentVariableInfo.ts index 1ec81c70b9f..9da2be1b5f2 100644 --- a/src/vs/workbench/contrib/terminal/browser/environmentVariableInfo.ts +++ b/src/vs/workbench/contrib/terminal/browser/environmentVariableInfo.ts @@ -87,7 +87,8 @@ export class EnvironmentVariableInfoChangesActive implements IEnvironmentVariabl return { id: TerminalStatus.EnvironmentVariableInfoChangesActive, severity: Severity.Info, - tooltip: this._getInfo(scope), + tooltip: undefined, // The action is present when details aren't shown + detailedTooltip: this._getInfo(scope), hoverActions: this._getActions(scope) }; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 872391084be..66792ebeccb 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -459,7 +459,7 @@ export class TerminalTabbedView extends Disposable { return; } this._hoverService.showHover({ - ...getInstanceHoverInfo(instance), + ...getInstanceHoverInfo(instance, this._storageService), target: this._terminalContainer, trapFocus: true }, true); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index 5d8140d604b..2b280ca8712 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -53,6 +53,8 @@ import { TerminalContextActionRunner } from './terminalContextMenu.js'; import type { IHoverAction } from '../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { TerminalStorageKeys } from '../common/terminalStorageKeys.js'; const $ = DOM.$; @@ -82,6 +84,7 @@ export class TerminalTabList extends WorkbenchList { @IInstantiationService instantiationService: IInstantiationService, @IDecorationsService decorationsService: IDecorationsService, @IThemeService private readonly _themeService: IThemeService, + @IStorageService private readonly _storageService: IStorageService, @ILifecycleService lifecycleService: ILifecycleService, @IHoverService private readonly _hoverService: IHoverService, ) { @@ -128,7 +131,8 @@ export class TerminalTabList extends WorkbenchList { this.reveal(i); } this.refresh(); - }) + }), + this._storageService.onDidChangeValue(StorageScope.APPLICATION, TerminalStorageKeys.TabsShowDetailed, this.disposables)(() => this.refresh()), ]; // Dispose of instance listeners on shutdown to avoid extra work and so tabs don't disappear @@ -230,7 +234,7 @@ export class TerminalTabList extends WorkbenchList { } this._hoverService.showHover({ - ...getInstanceHoverInfo(instance), + ...getInstanceHoverInfo(instance, this._storageService), target: this.getHTMLElement(), trapFocus: true }, true); @@ -257,6 +261,7 @@ class TerminalTabsRenderer extends Disposable implements IListRenderer 0) { @@ -37,8 +57,16 @@ export function getShellProcessTooltip(instance: ITerminalInstance, markdown: bo } if (instance.shellLaunchConfig.executable) { - let commandLine = instance.shellLaunchConfig.executable; - const args = asArray(instance.injectedArgs || instance.shellLaunchConfig.args || []).map(x => `'${x}'`).join(' '); + let commandLine = ''; + if (!showDetailed && instance.shellLaunchConfig.executable.length > 32) { + const base = basename(instance.shellLaunchConfig.executable); + const sepIndex = instance.shellLaunchConfig.executable.length - base.length - 1; + const sep = instance.shellLaunchConfig.executable.substring(sepIndex, sepIndex + 1); + commandLine += `…${sep}${base}`; + } else { + commandLine += instance.shellLaunchConfig.executable; + } + const args = asArray(instance.injectedArgs || instance.shellLaunchConfig.args || []).map(x => x.match(/\s/) ? `'${x}'` : x).join(' '); if (args) { commandLine += ` ${args}`; } @@ -46,7 +74,7 @@ export function getShellProcessTooltip(instance: ITerminalInstance, markdown: bo lines.push(localize('shellProcessTooltip.commandLine', 'Command line: {0}', commandLine)); } - return lines.length ? `${markdown ? '\n\n---\n\n' : '\n\n'}${lines.join('\n')}` : ''; + return lines.length ? `\n\n---\n\n${lines.join('\n')}` : ''; } export function refreshShellIntegrationInfoStatus(instance: ITerminalInstance) { @@ -71,6 +99,7 @@ export function refreshShellIntegrationInfoStatus(instance: ITerminalInstance) { instance.statusList.add({ id: TerminalStatus.ShellIntegrationInfo, severity: Severity.Info, - tooltip: `${localize('shellIntegration', "Shell integration")}: ${cmdDetectionType}${seenSequencesString}` + tooltip: `${localize('shellIntegration', "Shell integration")}: ${cmdDetectionType}`, + detailedTooltip: `${localize('shellIntegration', "Shell integration")}: ${cmdDetectionType}${seenSequencesString}` }); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 7e22be4d682..5390345ee23 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -50,6 +50,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { InstanceContext, TerminalContextActionRunner } from './terminalContextMenu.js'; import { MicrotaskDelay } from '../../../../base/common/symbols.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; export class TerminalViewPane extends ViewPane { private _parentDomElement: HTMLElement | undefined; @@ -660,7 +661,8 @@ class SingleTabHoverDelegate implements IHoverDelegate { constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IHoverService private readonly _hoverService: IHoverService, - @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService + @IStorageService private readonly _storageService: IStorageService, + @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, ) { } @@ -675,7 +677,7 @@ class SingleTabHoverDelegate implements IHoverDelegate { if (!instance) { return; } - const hoverInfo = getInstanceHoverInfo(instance); + const hoverInfo = getInstanceHoverInfo(instance, this._storageService); return this._hoverService.showHover({ ...options, content: hoverInfo.content, diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 2a717f4a7ed..3683a60a46b 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -370,6 +370,10 @@ export interface ITerminalStatus { * What to show for this status in the terminal's hover. */ tooltip?: string | undefined; + /** + * What to show for this status in the terminal's hover when details are toggled. + */ + detailedTooltip?: string | undefined; /** * Actions to expose on hover. */ diff --git a/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts b/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts index 6a495eca81b..915da5be420 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts @@ -7,6 +7,7 @@ export const enum TerminalStorageKeys { SuggestedRendererType = 'terminal.integrated.suggestedRendererType', TabsListWidthHorizontal = 'tabs-list-width-horizontal', TabsListWidthVertical = 'tabs-list-width-vertical', + TabsShowDetailed = 'terminal.integrated.tabs.showDetailed', DeprecatedEnvironmentVariableCollections = 'terminal.integrated.environmentVariableCollections', EnvironmentVariableCollections = 'terminal.integrated.environmentVariableCollectionsV2', TerminalBufferState = 'terminal.integrated.bufferState', From c65d27807b48dbaf8f294a565d442440a40c52ac Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 06:48:22 -0700 Subject: [PATCH 042/255] showHover -> showInstantHover This rename and moving the interface a little lower is designed to dissuade the use of the API in favor of the delayed ones. Part of #243348 --- src/vs/base/browser/ui/hover/hover.ts | 56 +++++++++---------- .../base/browser/ui/hover/hoverDelegate2.ts | 2 +- .../services/hoverService/hoverService.ts | 10 ++-- .../components/gutterIndicatorView.ts | 2 +- src/vs/platform/hover/browser/hover.ts | 2 +- .../hover/test/browser/nullHoverService.ts | 2 +- .../contrib/chat/browser/chatListRenderer.ts | 2 +- .../contrib/debug/browser/breakpointsView.ts | 2 +- .../browser/extensionFeaturesTab.ts | 2 +- .../extensions/browser/extensionsWidgets.ts | 2 +- .../browser/view/cellParts/cellStatusPart.ts | 2 +- .../settingsEditorSettingIndicators.ts | 12 ++-- .../terminal/browser/terminalIconPicker.ts | 2 +- .../terminal/browser/terminalTabbedView.ts | 2 +- .../terminal/browser/terminalTabsList.ts | 2 +- .../contrib/terminal/browser/terminalView.ts | 2 +- .../browser/widgets/terminalHoverWidget.ts | 2 +- .../browser/userDataProfilesEditor.ts | 2 +- 18 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/vs/base/browser/ui/hover/hover.ts b/src/vs/base/browser/ui/hover/hover.ts index be6a8ab1ace..6b877ec17fd 100644 --- a/src/vs/base/browser/ui/hover/hover.ts +++ b/src/vs/base/browser/ui/hover/hover.ts @@ -13,32 +13,6 @@ import type { IDisposable } from '../../../common/lifecycle.js'; * Enables the convenient display of rich markdown-based hovers in the workbench. */ export interface IHoverDelegate2 { - /** - * Shows a hover immediately, provided a hover with the same {@link options} object is not - * already visible. - * - * Use this method when you want to: - * - * - Control showing the hover yourself. - * - Show the hover immediately. - * - * @param options A set of options defining the characteristics of the hover. - * @param focus Whether to focus the hover (useful for keyboard accessibility). - * - * @example A simple usage with a single element target. - * - * ```typescript - * showHover({ - * text: new MarkdownString('Hello world'), - * target: someElement - * }); - * ``` - */ - showHover( - options: IHoverOptions, - focus?: boolean - ): IHoverWidget | undefined; - /** * Shows a hover after a delay, or immediately if the {@link groupId} matches the currently * shown hover. @@ -100,6 +74,32 @@ export interface IHoverDelegate2 { lifecycleOptions?: IHoverLifecycleOptions, ): IDisposable; + /** + * Shows a hover immediately, provided a hover with the same {@link options} object is not + * already visible. + * + * Use this method when you want to: + * + * - Control showing the hover yourself. + * - Show the hover immediately. + * + * @param options A set of options defining the characteristics of the hover. + * @param focus Whether to focus the hover (useful for keyboard accessibility). + * + * @example A simple usage with a single element target. + * + * ```typescript + * showInstantHover({ + * text: new MarkdownString('Hello world'), + * target: someElement + * }); + * ``` + */ + showInstantHover( + options: IHoverOptions, + focus?: boolean + ): IHoverWidget | undefined; + /** * Hides the hover if it was visible. This call will be ignored if the hover is currently * "locked" via the alt/option key unless `force` is set. @@ -116,8 +116,8 @@ export interface IHoverDelegate2 { * Sets up a managed hover for the given element. A managed hover will set up listeners for * mouse events, show the hover after a delay and provide hooks to easily update the content. * - * This should be used over {@link showHover} when fine-grained control is not needed. The - * managed hover also does not scale well, consider using {@link showHover} when showing hovers + * This should be used over {@link showInstantHover} when fine-grained control is not needed. The + * managed hover also does not scale well, consider using {@link showInstantHover} when showing hovers * for many elements. * * @param hoverDelegate The hover delegate containing hooks and configuration for the hover. diff --git a/src/vs/base/browser/ui/hover/hoverDelegate2.ts b/src/vs/base/browser/ui/hover/hoverDelegate2.ts index 05b4c692d94..b49cb84951c 100644 --- a/src/vs/base/browser/ui/hover/hoverDelegate2.ts +++ b/src/vs/base/browser/ui/hover/hoverDelegate2.ts @@ -7,7 +7,7 @@ import { Disposable } from '../../../common/lifecycle.js'; import type { IHoverDelegate2 } from './hover.js'; let baseHoverDelegate: IHoverDelegate2 = { - showHover: () => undefined, + showInstantHover: () => undefined, showDelayedHover: () => undefined, setupDelayedHover: () => Disposable.None, setupDelayedHoverAtMouse: () => Disposable.None, diff --git a/src/vs/editor/browser/services/hoverService/hoverService.ts b/src/vs/editor/browser/services/hoverService/hoverService.ts index 25d55d9e9cd..523378c4c79 100644 --- a/src/vs/editor/browser/services/hoverService/hoverService.ts +++ b/src/vs/editor/browser/services/hoverService/hoverService.ts @@ -69,7 +69,7 @@ export class HoverService extends Disposable implements IHoverService { })); } - showHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean, dontShow?: boolean): IHoverWidget | undefined { + showInstantHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean, dontShow?: boolean): IHoverWidget | undefined { const hover = this._createHover(options, skipLastFocusedUpdate); if (!hover) { return undefined; @@ -100,7 +100,7 @@ export class HoverService extends Disposable implements IHoverService { // Check group identity, if it's the same skip the delay and show the hover immediately if (this._currentHover && !this._currentHover.isDisposed && this._currentDelayedHoverGroupId !== undefined && this._currentDelayedHoverGroupId === lifecycleOptions?.groupId) { - return this.showHover({ + return this.showInstantHover({ ...options, appearance: { ...options.appearance, @@ -177,12 +177,12 @@ export class HoverService extends Disposable implements IHoverService { store.add(addDisposableListener(target, EventType.KEY_DOWN, e => { const evt = new StandardKeyboardEvent(e); if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { - this.showHover(resolveHoverOptions(), true); + this.showInstantHover(resolveHoverOptions(), true); } })); } - this._delayedHovers.set(target, { show: (focus: boolean) => { this.showHover(resolveHoverOptions(), focus); } }); + this._delayedHovers.set(target, { show: (focus: boolean) => { this.showInstantHover(resolveHoverOptions(), focus); } }); store.add(toDisposable(() => this._delayedHovers.delete(target))); return store; @@ -320,7 +320,7 @@ export class HoverService extends Disposable implements IHoverService { if (!this._lastHoverOptions) { return; } - this.showHover(this._lastHoverOptions, true, true); + this.showInstantHover(this._lastHoverOptions, true, true); } private _showAndFocusHoverForActiveElement(): void { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index 8ec842d9fc5..7707dca9ab1 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -154,7 +154,7 @@ export class InlineEditsGutterIndicator extends Disposable { disposableStore.add(focusTracker.onDidFocus(() => this._focusIsInMenu.set(true, undefined))); disposableStore.add(toDisposable(() => this._focusIsInMenu.set(false, undefined))); - const h = this._hoverService.showHover({ + const h = this._hoverService.showInstantHover({ target: this._iconRef.element, content: content.element, }) as HoverWidget | undefined; diff --git a/src/vs/platform/hover/browser/hover.ts b/src/vs/platform/hover/browser/hover.ts index f2651c086d1..01fcf3cb7a3 100644 --- a/src/vs/platform/hover/browser/hover.ts +++ b/src/vs/platform/hover/browser/hover.ts @@ -79,7 +79,7 @@ export class WorkbenchHoverDelegate extends Disposable implements IHoverDelegate ? options.content.toString() : options.content.value; - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...options, ...overrideOptions, persistence: { diff --git a/src/vs/platform/hover/test/browser/nullHoverService.ts b/src/vs/platform/hover/test/browser/nullHoverService.ts index 4644330752b..2f846c8e225 100644 --- a/src/vs/platform/hover/test/browser/nullHoverService.ts +++ b/src/vs/platform/hover/test/browser/nullHoverService.ts @@ -9,7 +9,7 @@ import type { IHoverService } from '../../browser/hover.js'; export const NullHoverService: IHoverService = { _serviceBrand: undefined, hideHover: () => undefined, - showHover: () => undefined, + showInstantHover: () => undefined, showDelayedHover: () => undefined, setupDelayedHover: () => Disposable.None, setupDelayedHoverAtMouse: () => Disposable.None, diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 7bc69accb60..9751c788d42 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -346,7 +346,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.hoverService.showHover({ content: options.content, target: this.hintContainer!.element }, focus), + showHover: (options, focus?) => this.hoverService.showInstantHover({ content: options.content, target: this.hintContainer!.element }, focus), delay: this.configurationService.getValue('workbench.hover.delay') } })); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts b/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts index 57b0ea186ef..0b90d652d3f 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionFeaturesTab.ts @@ -292,7 +292,7 @@ class RuntimeStatusMarkdownRenderer extends Disposable implements IExtensionFeat highlightCircle.style.display = 'block'; tooltip.style.left = `${closestPoint.x + 24}px`; tooltip.style.top = `${closestPoint.y + 14}px`; - hoverDisposable.value = this.hoverService.showHover({ + hoverDisposable.value = this.hoverService.showInstantHover({ content: new MarkdownString(`${closestPoint.date}: ${closestPoint.count} requests`), target: tooltip, appearance: { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts index 17bba8cc4fa..6fc27676d0e 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts @@ -555,7 +555,7 @@ export class ExtensionHoverWidget extends ExtensionWidget { this.hover.value = this.hoverService.setupManagedHover({ delay: this.configurationService.getValue('workbench.hover.delay'), showHover: (options, focus) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...options, additionalClasses: ['extension-hover'], position: { diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts index f58ff0aa23f..a23d2f8b28e 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts @@ -79,7 +79,7 @@ export class CellEditorStatusBar extends CellContentPart { readonly showHover = (options: IHoverDelegateOptions) => { options.position = options.position ?? {}; options.position.hoverPosition = HoverPosition.ABOVE; - return hoverService.showHover(options); + return hoverService.showInstantHover(options); }; readonly placement = 'element'; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 678042ec78b..c669643a71a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -145,7 +145,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const content = localize('trustLabel', "The setting value can only be applied in a trusted workspace."); const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...this.defaultHoverOptions, content, target: workspaceTrustElement, @@ -186,7 +186,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const syncIgnoredHoverContent = localize('syncIgnoredTitle', "This setting is ignored during sync"); const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...this.defaultHoverOptions, content: syncIgnoredHoverContent, target: syncIgnoredElement @@ -329,7 +329,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const content = isPreviewSetting ? PREVIEW_INDICATOR_DESCRIPTION : EXPERIMENTAL_INDICATOR_DESCRIPTION; const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...this.defaultHoverOptions, content, target: this.previewIndicator.element @@ -374,7 +374,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { this.scopeOverridesIndicator.label.text = '$(briefcase) ' + localize('policyLabelText', "Managed by organization"); const content = localize('policyDescription', "This setting is managed by your organization and its actual value cannot be changed."); const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...this.defaultHoverOptions, content, actions: [{ @@ -396,7 +396,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const content = localize('applicationSettingDescription', "The setting is not specific to the current profile, and will retain its value when switching profiles."); const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ ...this.defaultHoverOptions, content, target: this.scopeOverridesIndicator.element @@ -512,7 +512,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { } const showHover = (focus: boolean) => { - return this.hoverService.showHover({ + return this.hoverService.showInstantHover({ content: new MarkdownString().appendMarkdown(defaultOverrideHoverContent), target: this.defaultOverrideIndicator.element, position: { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts b/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts index 4e295a44f37..f5f78bd178f 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts @@ -58,7 +58,7 @@ export class TerminalIconPicker extends Disposable { this._iconSelectBox.dispose(); })); this._iconSelectBox.clearInput(); - const hoverWidget = this._hoverService.showHover({ + const hoverWidget = this._hoverService.showInstantHover({ content: this._iconSelectBox.domNode, target: getActiveDocument().body, position: { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 872391084be..a776866b211 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -458,7 +458,7 @@ export class TerminalTabbedView extends Disposable { if (!instance) { return; } - this._hoverService.showHover({ + this._hoverService.showInstantHover({ ...getInstanceHoverInfo(instance), target: this._terminalContainer, trapFocus: true diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index 5d8140d604b..2829a26ef12 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -229,7 +229,7 @@ export class TerminalTabList extends WorkbenchList { return; } - this._hoverService.showHover({ + this._hoverService.showInstantHover({ ...getInstanceHoverInfo(instance), target: this.getHTMLElement(), trapFocus: true diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 7e22be4d682..d117bbeacd8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -676,7 +676,7 @@ class SingleTabHoverDelegate implements IHoverDelegate { return; } const hoverInfo = getInstanceHoverInfo(instance); - return this._hoverService.showHover({ + return this._hoverService.showInstantHover({ ...options, content: hoverInfo.content, actions: hoverInfo.actions diff --git a/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts b/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts index 235a4586fb8..5f6aec126a0 100644 --- a/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts +++ b/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts @@ -44,7 +44,7 @@ export class TerminalHover extends Disposable implements ITerminalWidget { return; } const target = new CellHoverTarget(container, this._targetOptions); - const hover = this._hoverService.showHover({ + const hover = this._hoverService.showInstantHover({ target, content: this._text, actions: this._actions, diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditor.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditor.ts index f4127ee477f..04a69674350 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditor.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditor.ts @@ -1047,7 +1047,7 @@ class ProfileIconRenderer extends ProfilePropertyRenderer { return; } iconSelectBox.clearInput(); - hoverWidget = this.hoverService.showHover({ + hoverWidget = this.hoverService.showInstantHover({ content: iconSelectBox.domNode, target: iconElement, position: { From 870d14c7b395ec400d1d934a80b715cdafafcbf2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 12 Mar 2025 10:24:22 -0400 Subject: [PATCH 043/255] add task status API (#243092) --- .../common/extensionsApiProposals.ts | 3 + .../workbench/api/browser/mainThreadTask.ts | 25 ++++++- .../workbench/api/common/extHost.api.impl.ts | 5 ++ .../workbench/api/common/extHost.protocol.ts | 1 + src/vs/workbench/api/common/extHostTask.ts | 22 ++++++ src/vs/workbench/api/common/extHostTypes.ts | 42 +++++++++++ src/vs/workbench/api/common/shared/tasks.ts | 53 ++++++++++++++ .../tasks/browser/abstractTaskService.ts | 6 +- .../tasks/browser/task.contribution.ts | 2 +- .../tasks/browser/terminalTaskSystem.ts | 15 +++- .../contrib/tasks/common/taskService.ts | 5 ++ .../workbench/contrib/tasks/common/tasks.ts | 59 ++++++++++++---- .../vscode.proposed.taskStatus.d.ts | 70 +++++++++++++++++++ 13 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.taskStatus.d.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 80042f2c555..7eebcbb224b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -334,6 +334,9 @@ const _allApiProposals = { taskPresentationGroup: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskPresentationGroup.d.ts', }, + taskStatus: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskStatus.d.ts', + }, telemetry: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.telemetry.d.ts', }, diff --git a/src/vs/workbench/api/browser/mainThreadTask.ts b/src/vs/workbench/api/browser/mainThreadTask.ts index 52e977fe25d..c3a1c502e5f 100644 --- a/src/vs/workbench/api/browser/mainThreadTask.ts +++ b/src/vs/workbench/api/browser/mainThreadTask.ts @@ -15,7 +15,7 @@ import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../platform/workspace/common/workspace.js'; import { - ContributedTask, ConfiguringTask, KeyedTaskIdentifier, ITaskExecution, Task, ITaskEvent, TaskEventKind, + ContributedTask, ConfiguringTask, KeyedTaskIdentifier, ITaskExecution, Task, ITaskEvent, IPresentationOptions, CommandOptions, ICommandConfiguration, RuntimeType, CustomTask, TaskScope, TaskSource, TaskSourceKind, IExtensionTaskSource, IRunOptions, ITaskSet, TaskGroup, TaskDefinition, PresentationOptions, RunOptions } from '../../contrib/tasks/common/tasks.js'; @@ -29,7 +29,9 @@ import { ExtHostContext, MainThreadTaskShape, ExtHostTaskShape, MainContext } fr import { ITaskDefinitionDTO, ITaskExecutionDTO, IProcessExecutionOptionsDTO, ITaskPresentationOptionsDTO, IProcessExecutionDTO, IShellExecutionDTO, IShellExecutionOptionsDTO, ICustomExecutionDTO, ITaskDTO, ITaskSourceDTO, ITaskHandleDTO, ITaskFilterDTO, ITaskProcessStartedDTO, ITaskProcessEndedDTO, ITaskSystemInfoDTO, - IRunOptionsDTO, ITaskGroupDTO + IRunOptionsDTO, ITaskGroupDTO, + ITaskStatus, + TaskEventKind } from '../common/shared/tasks.js'; import { IConfigurationResolverService } from '../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationTarget } from '../../../platform/configuration/common/configuration.js'; @@ -45,6 +47,24 @@ namespace TaskExecutionDTO { } } +export interface ITaskTerminalStatusDTO { + execution: ITaskExecutionDTO; + taskEventKind: TaskEventKind; +} + +export namespace TaskTerminalStatusDTO { + export function from(value: ITaskStatus): ITaskTerminalStatusDTO { + return { + execution: { + id: value.execution.id, + task: TaskDTO.from(value.execution.task) + }, + taskEventKind: value.taskEventKind + }; + } +} + + namespace TaskProcessStartedDTO { export function from(value: ITaskExecution, processId: number): ITaskProcessStartedDTO { return { @@ -455,6 +475,7 @@ export class MainThreadTask extends Disposable implements MainThreadTaskShape { } else if (event.kind === TaskEventKind.End) { this._proxy.$OnDidEndTask(TaskExecutionDTO.from(task.getTaskExecution())); } + this._proxy.$onDidChangeTaskTerminalStatus(TaskTerminalStatusDTO.from({ execution: task.getTaskExecution(), taskEventKind: event.kind })); })); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index c35994fc4ed..e8fa70f081f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1357,6 +1357,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, onDidEndTaskProcess: (listeners, thisArgs?, disposables?) => { return _asExtensionEvent(extHostTask.onDidEndTaskProcess)(listeners, thisArgs, disposables); + }, + onDidChangeTaskStatus: (listeners) => { + checkProposedApiEnabled(extension, 'taskStatus'); + return _asExtensionEvent(extHostTask.onDidChangeTaskTerminalStatus)(listeners); } }; @@ -1654,6 +1658,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I SymbolKind: extHostTypes.SymbolKind, SymbolTag: extHostTypes.SymbolTag, Task: extHostTypes.Task, + TaskEventKind: extHostTypes.TaskEventKind, TaskGroup: extHostTypes.TaskGroup, TaskPanelKind: extHostTypes.TaskPanelKind, TaskRevealKind: extHostTypes.TaskRevealKind, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0d1b41e637a..498c1c1cb1d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2555,6 +2555,7 @@ export interface ExtHostTaskShape { $onDidStartTaskProcess(value: tasks.ITaskProcessStartedDTO): void; $onDidEndTaskProcess(value: tasks.ITaskProcessEndedDTO): void; $OnDidEndTask(execution: tasks.ITaskExecutionDTO): void; + $onDidChangeTaskTerminalStatus(status: tasks.ITaskStatusDTO): void; $resolveVariables(workspaceFolder: UriComponents, toResolve: { process?: { name: string; cwd?: string }; variables: string[] }): Promise<{ process?: string; variables: { [key: string]: string } }>; $jsonTasksSupported(): Promise; $findExecutable(command: string, cwd?: string, paths?: string[]): Promise; diff --git a/src/vs/workbench/api/common/extHostTask.ts b/src/vs/workbench/api/common/extHostTask.ts index e0774e293a0..91de874b71b 100644 --- a/src/vs/workbench/api/common/extHostTask.ts +++ b/src/vs/workbench/api/common/extHostTask.ts @@ -29,6 +29,7 @@ import { IExtHostApiDeprecationService } from './extHostApiDeprecationService.js import { USER_TASKS_GROUP_KEY } from '../../contrib/tasks/common/tasks.js'; import { ErrorNoTelemetry, NotSupportedError } from '../../../base/common/errors.js'; import { asArray } from '../../../base/common/arrays.js'; +import { ITaskStatusDTO } from './shared/tasks.js'; export interface IExtHostTask extends ExtHostTaskShape { @@ -39,6 +40,7 @@ export interface IExtHostTask extends ExtHostTaskShape { onDidEndTask: Event; onDidStartTaskProcess: Event; onDidEndTaskProcess: Event; + onDidChangeTaskTerminalStatus: Event; // Fixed the event type back to Event registerTaskProvider(extension: IExtensionDescription, type: string, provider: vscode.TaskProvider): vscode.Disposable; registerTaskSystem(scheme: string, info: tasks.ITaskSystemInfoDTO): void; @@ -406,6 +408,7 @@ export abstract class ExtHostTaskBase implements ExtHostTaskShape, IExtHostTask protected readonly _onDidTaskProcessStarted: Emitter = new Emitter(); protected readonly _onDidTaskProcessEnded: Emitter = new Emitter(); + protected readonly _onDidChangeTaskTerminalStatus: Emitter = new Emitter(); constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @@ -540,6 +543,25 @@ export abstract class ExtHostTaskBase implements ExtHostTaskShape, IExtHostTask }); } + public get onDidChangeTaskTerminalStatus(): Event { + return this._onDidChangeTaskTerminalStatus.event; + } + + public async $onDidChangeTaskTerminalStatus(value: ITaskStatusDTO): Promise { + let execution; + try { + execution = await this.getTaskExecution(value.execution.id); + } catch (error) { + // The task execution is not available anymore + return; + } + + this._onDidChangeTaskTerminalStatus.fire({ + execution: execution, + eventKind: value.taskEventKind + }); + } + protected abstract provideTasksInternal(validTypes: { [key: string]: boolean }, taskIdPromises: Promise[], handler: HandlerData, value: vscode.Task[] | null | undefined): { tasks: tasks.ITaskDTO[]; extension: IExtensionDescription }; public $provideTasks(handle: number, validTypes: { [key: string]: boolean }): Promise { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index f1fcdc1ab1b..432ae5e6d25 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -2216,6 +2216,48 @@ export enum TaskRevealKind { Never = 3 } +export enum TaskEventKind { + /** Indicates a task's properties or configuration have changed */ + Changed = 'changed', + + /** Indicates a task has begun executing */ + ProcessStarted = 'processStarted', + + /** Indicates a task process has completed */ + ProcessEnded = 'processEnded', + + /** Indicates a task was terminated, either by user action or by the system */ + Terminated = 'terminated', + + /** Indicates a task has started running */ + Start = 'start', + + /** Indicates a task has acquired all needed input/variables to execute */ + AcquiredInput = 'acquiredInput', + + /** Indicates a dependent task has started */ + DependsOnStarted = 'dependsOnStarted', + + /** Indicates a task is actively running/processing */ + Active = 'active', + + /** Indicates a task is paused/waiting but not complete */ + Inactive = 'inactive', + + /** Indicates a task has completed fully */ + End = 'end', + + /** Indicates the task's problem matcher has started */ + ProblemMatcherStarted = 'problemMatcherStarted', + + /** Indicates the task's problem matcher has ended */ + ProblemMatcherEnded = 'problemMatcherEnded', + + /** Indicates the task's problem matcher has found errors */ + ProblemMatcherFoundErrors = 'problemMatcherFoundErrors' +} + + export enum TaskPanelKind { Shared = 1, diff --git a/src/vs/workbench/api/common/shared/tasks.ts b/src/vs/workbench/api/common/shared/tasks.ts index 67a3fe9e05d..da343180cb9 100644 --- a/src/vs/workbench/api/common/shared/tasks.ts +++ b/src/vs/workbench/api/common/shared/tasks.ts @@ -6,6 +6,7 @@ import { UriComponents } from '../../../../base/common/uri.js'; import { IExtensionDescription } from '../../../../platform/extensions/common/extensions.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; +import { ITaskExecution } from '../../../contrib/tasks/common/tasks.js'; export interface ITaskDefinitionDTO { type: string; @@ -50,6 +51,48 @@ export interface IShellQuotingOptionsDTO { weak?: string; } +export enum TaskEventKind { + /** Indicates that a task's properties or configuration have changed */ + Changed = 'changed', + + /** Indicates that a task has begun executing */ + ProcessStarted = 'processStarted', + + /** Indicates that a task process has completed */ + ProcessEnded = 'processEnded', + + /** Indicates that a task was terminated, either by user action or by the system */ + Terminated = 'terminated', + + /** Indicates that a task has started running */ + Start = 'start', + + /** Indicates that a task has acquired all needed input/variables to execute */ + AcquiredInput = 'acquiredInput', + + /** Indicates that a dependent task has started */ + DependsOnStarted = 'dependsOnStarted', + + /** Indicates that a task is actively running/processing */ + Active = 'active', + + /** Indicates that a task is paused/waiting but not complete */ + Inactive = 'inactive', + + /** Indicates that a task has completed fully */ + End = 'end', + + /** Indicates that a task's problem matcher has started */ + ProblemMatcherStarted = 'problemMatcherStarted', + + /** Indicates that a task's problem matcher has ended */ + ProblemMatcherEnded = 'problemMatcherEnded', + + /** Indicates that a task's problem matcher has found errors */ + ProblemMatcherFoundErrors = 'problemMatcherFoundErrors' +} + + export interface IShellExecutionOptionsDTO extends IExecutionOptionsDTO { executable?: string; shellArgs?: string[]; @@ -137,3 +180,13 @@ export interface ITaskSystemInfoDTO { authority: string; platform: string; } + +export interface ITaskStatus { + execution: ITaskExecution; + taskEventKind: TaskEventKind; +} + +export interface ITaskStatusDTO { + execution: ITaskExecutionDTO; + taskEventKind: TaskEventKind; +} diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index d6a12341d75..4672ffa2c9b 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -47,8 +47,8 @@ import { ITextFileService } from '../../../services/textfile/common/textfiles.js import { ITerminalGroupService, ITerminalService } from '../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../terminal/common/terminal.js'; -import { ConfiguringTask, ContributedTask, CustomTask, ExecutionEngine, InMemoryTask, ITaskEvent, ITaskIdentifier, ITaskSet, JsonSchemaVersion, KeyedTaskIdentifier, RuntimeType, Task, TASK_RUNNING_STATE, TaskDefinition, TaskEventKind, TaskGroup, TaskRunSource, TaskSettingId, TaskSorter, TaskSourceKind, TasksSchemaProperties, USER_TASKS_GROUP_KEY } from '../common/tasks.js'; -import { CustomExecutionSupportedContext, ICustomizationProperties, IProblemMatcherRunOptions, ITaskFilter, ITaskProvider, ITaskService, IWorkspaceFolderTaskResult, ProcessExecutionSupportedContext, ServerlessWebContext, ShellExecutionSupportedContext, TaskCommandsRegistered, TaskExecutionSupportedContext } from '../common/taskService.js'; +import { ConfiguringTask, ContributedTask, CustomTask, ExecutionEngine, InMemoryTask, ITaskEvent, ITaskIdentifier, ITaskSet, JsonSchemaVersion, KeyedTaskIdentifier, RuntimeType, Task, TASK_RUNNING_STATE, TaskDefinition, TaskGroup, TaskRunSource, TaskSettingId, TaskSorter, TaskSourceKind, TasksSchemaProperties, USER_TASKS_GROUP_KEY, TaskEventKind } from '../common/tasks.js'; +import { CustomExecutionSupportedContext, ICustomizationProperties, IProblemMatcherRunOptions, ITaskFilter, ITaskProvider, ITaskService, ITaskTerminalStatus, IWorkspaceFolderTaskResult, ProcessExecutionSupportedContext, ServerlessWebContext, ShellExecutionSupportedContext, TaskCommandsRegistered, TaskExecutionSupportedContext } from '../common/taskService.js'; import { ITaskExecuteResult, ITaskResolver, ITaskSummary, ITaskSystem, ITaskSystemInfo, ITaskTerminateResponse, TaskError, TaskErrors, TaskExecuteKind } from '../common/taskSystem.js'; import { getTemplates as getTaskTemplates } from '../common/taskTemplates.js'; @@ -237,6 +237,8 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer public get isReconnected(): boolean { return this._tasksReconnected; } private _onDidChangeTaskProviders = this._register(new Emitter()); public onDidChangeTaskProviders = this._onDidChangeTaskProviders.event; + private _onDidChangeTaskTerminalStatus: Emitter = new Emitter(); + public readonly onDidChangeTaskTerminalStatus: Event = this._onDidChangeTaskTerminalStatus.event; private _activatedTaskProviders: Set = new Set(); diff --git a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts index 98592cc01a3..8629aa38b0f 100644 --- a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts @@ -20,7 +20,7 @@ import { StatusbarAlignment, IStatusbarService, IStatusbarEntryAccessor, IStatus import { IOutputChannelRegistry, Extensions as OutputExt } from '../../../services/output/common/output.js'; -import { ITaskEvent, TaskEventKind, TaskGroup, TaskSettingId, TASKS_CATEGORY, TASK_RUNNING_STATE, TASK_TERMINAL_ACTIVE } from '../common/tasks.js'; +import { ITaskEvent, TaskGroup, TaskSettingId, TASKS_CATEGORY, TASK_RUNNING_STATE, TASK_TERMINAL_ACTIVE, TaskEventKind } from '../common/tasks.js'; import { ITaskService, TaskCommandsRegistered, TaskExecutionSupportedContext } from '../common/taskService.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 61d996724fe..31f4e203f54 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -833,15 +833,18 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { eventCounter++; this._busyTasks[mapKey] = task; this._fireTaskEvent(TaskEvent.general(TaskEventKind.Active, task, terminal?.instanceId)); + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherStarted, task, terminal?.instanceId)); } else if (event.kind === ProblemCollectorEventKind.BackgroundProcessingEnds) { eventCounter--; if (this._busyTasks[mapKey]) { delete this._busyTasks[mapKey]; } this._fireTaskEvent(TaskEvent.general(TaskEventKind.Inactive, task, terminal?.instanceId)); + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherEnded, task, terminal?.instanceId)); if (eventCounter === 0) { if ((watchingProblemMatcher.numberOfMatches > 0) && watchingProblemMatcher.maxMarkerSeverity && (watchingProblemMatcher.maxMarkerSeverity >= MarkerSeverity.Error)) { + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherFoundErrors, task, terminal?.instanceId)); const reveal = task.command.presentation!.reveal; const revealProblems = task.command.presentation!.revealProblems; if (revealProblems === RevealProblemKind.OnProblem) { @@ -981,7 +984,16 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService); this._terminalStatusManager.addTerminal(task, terminal, startStopProblemMatcher); - + startStopProblemMatcher.onDidStateChange((event) => { + if (event.kind === ProblemCollectorEventKind.BackgroundProcessingBegins) { + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherStarted, task, terminal?.instanceId)); + } else if (event.kind === ProblemCollectorEventKind.BackgroundProcessingEnds) { + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherEnded, task, terminal?.instanceId)); + if (startStopProblemMatcher.numberOfMatches) { + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherFoundErrors, task, terminal?.instanceId)); + } + } + }); let processStartedSignaled = false; terminal.processReady.then(() => { if (!processStartedSignaled) { @@ -1044,6 +1056,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { delete this._busyTasks[mapKey]; } this._fireTaskEvent(TaskEvent.general(TaskEventKind.Inactive, task, terminal?.instanceId)); + this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherEnded, task, terminal?.instanceId)); this._fireTaskEvent(TaskEvent.general(TaskEventKind.End, task, terminal?.instanceId)); resolve({ exitCode: exitCode ?? undefined }); }); diff --git a/src/vs/workbench/contrib/tasks/common/taskService.ts b/src/vs/workbench/contrib/tasks/common/taskService.ts index ac2f0c34c3b..ec687197dee 100644 --- a/src/vs/workbench/contrib/tasks/common/taskService.ts +++ b/src/vs/workbench/contrib/tasks/common/taskService.ts @@ -108,3 +108,8 @@ export interface ITaskService { extensionCallbackTaskComplete(task: Task, result: number | undefined): Promise; } + +export interface ITaskTerminalStatus { + terminalId: number; + status: string; +} diff --git a/src/vs/workbench/contrib/tasks/common/tasks.ts b/src/vs/workbench/contrib/tasks/common/tasks.ts index c78cb6d4d77..3c01d029ade 100644 --- a/src/vs/workbench/contrib/tasks/common/tasks.ts +++ b/src/vs/workbench/contrib/tasks/common/tasks.ts @@ -1102,18 +1102,6 @@ export class TaskSorter { } } -export const enum TaskEventKind { - DependsOnStarted = 'dependsOnStarted', - AcquiredInput = 'acquiredInput', - Start = 'start', - ProcessStarted = 'processStarted', - Active = 'active', - Inactive = 'inactive', - Changed = 'changed', - Terminated = 'terminated', - ProcessEnded = 'processEnded', - End = 'end' -} export const enum TaskRunType { @@ -1125,6 +1113,49 @@ export interface ITaskChangedEvent { kind: TaskEventKind.Changed; } + + +export enum TaskEventKind { + /** Indicates that a task's properties or configuration have changed */ + Changed = 'changed', + + /** Indicates that a task has begun executing */ + ProcessStarted = 'processStarted', + + /** Indicates that a task process has completed */ + ProcessEnded = 'processEnded', + + /** Indicates that a task was terminated, either by user action or by the system */ + Terminated = 'terminated', + + /** Indicates that a task has started running */ + Start = 'start', + + /** Indicates that a task has acquired all needed input/variables to execute */ + AcquiredInput = 'acquiredInput', + + /** Indicates that a dependent task has started */ + DependsOnStarted = 'dependsOnStarted', + + /** Indicates that a task is actively running/processing */ + Active = 'active', + + /** Indicates that a task is paused/waiting but not complete */ + Inactive = 'inactive', + + /** Indicates that a task has completed fully */ + End = 'end', + + /** Indicates that a task's problem matcher has started */ + ProblemMatcherStarted = 'problemMatcherStarted', + + /** Indicates that a task's problem matcher has ended */ + ProblemMatcherEnded = 'problemMatcherEnded', + + /** Indicates that a task's problem matcher has found errors */ + ProblemMatcherFoundErrors = 'problemMatcherFoundErrors' +} + interface ITaskCommon { taskId: string; runType: TaskRunType; @@ -1158,7 +1189,7 @@ export interface ITaskStartedEvent extends ITaskCommon { } export interface ITaskGeneralEvent extends ITaskCommon { - kind: TaskEventKind.AcquiredInput | TaskEventKind.DependsOnStarted | TaskEventKind.Active | TaskEventKind.Inactive | TaskEventKind.End; + kind: TaskEventKind.AcquiredInput | TaskEventKind.DependsOnStarted | TaskEventKind.Active | TaskEventKind.Inactive | TaskEventKind.End | TaskEventKind.ProblemMatcherEnded | TaskEventKind.ProblemMatcherStarted | TaskEventKind.ProblemMatcherFoundErrors; terminalId: number | undefined; } @@ -1224,7 +1255,7 @@ export namespace TaskEvent { }; } - export function general(kind: TaskEventKind.AcquiredInput | TaskEventKind.DependsOnStarted | TaskEventKind.Active | TaskEventKind.Inactive | TaskEventKind.End, task: Task, terminalId?: number): ITaskGeneralEvent { + export function general(kind: TaskEventKind.AcquiredInput | TaskEventKind.DependsOnStarted | TaskEventKind.Active | TaskEventKind.Inactive | TaskEventKind.End | TaskEventKind.ProblemMatcherEnded | TaskEventKind.ProblemMatcherStarted | TaskEventKind.ProblemMatcherFoundErrors, task: Task, terminalId?: number): ITaskGeneralEvent { return { ...common(task), kind, diff --git a/src/vscode-dts/vscode.proposed.taskStatus.d.ts b/src/vscode-dts/vscode.proposed.taskStatus.d.ts new file mode 100644 index 00000000000..64fd05f98b0 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.taskStatus.d.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + export enum TaskEventKind { + /** Indicates that a task's properties or configuration have changed */ + Changed = 'changed', + + /** Indicates that a task has begun executing */ + ProcessStarted = 'processStarted', + + /** Indicates that a task process has completed */ + ProcessEnded = 'processEnded', + + /** Indicates that a task was terminated, either by user action or by the system */ + Terminated = 'terminated', + + /** Indicates that a task has started running */ + Start = 'start', + + /** Indicates that a task has acquired all needed input/variables to execute */ + AcquiredInput = 'acquiredInput', + + /** Indicates that a dependent task has started */ + DependsOnStarted = 'dependsOnStarted', + + /** Indicates that a task is actively running/processing */ + Active = 'active', + + /** Indicates that a task is paused/waiting but not complete */ + Inactive = 'inactive', + + /** Indicates that a task has completed fully */ + End = 'end', + + /** Indicates that a task's problem matcher has started */ + ProblemMatcherStarted = 'problemMatcherStarted', + + /** Indicates that a task's problem matcher has ended */ + ProblemMatcherEnded = 'problemMatcherEnded', + + /** Indicates that a task's problem matcher has found errors */ + ProblemMatcherFoundErrors = 'problemMatcherFoundErrors' + } + + export interface TaskStatusEvent { + /** + * The task item representing the task for which the event occurred + */ + readonly execution: TaskExecution; + + /** + * The task event kind + */ + readonly eventKind: TaskEventKind; + } + + export namespace tasks { + + /** + * An event that is emitted when the status of a task changes. + */ + export const onDidChangeTaskStatus: Event; + } + +} From 8bbfc150ea07e053d5b4df60232ca2184c1e2355 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 12 Mar 2025 15:25:44 +0100 Subject: [PATCH 044/255] MCP work (#243351) * - fix enablement/showing of select tools command (partially) - show tools picker grouped by server * render MCP tools runs a little nicer --- .../chatConfirmationWidget.ts | 2 +- .../contrib/mcp/browser/mcpCommands.ts | 149 ++++++++++++++---- .../contrib/mcp/common/mcpContextKeys.ts | 6 +- .../workbench/contrib/mcp/common/mcpServer.ts | 4 +- .../contrib/mcp/common/mcpService.ts | 23 ++- .../workbench/contrib/mcp/common/mcpTypes.ts | 4 +- 6 files changed, 145 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts index 761e83ff51c..7bdb9b23499 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts @@ -54,7 +54,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { this._domNode = elements.root; this.markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {}); - const renderedTitle = this._register(this.markdownRenderer.render(new MarkdownString(title), { + const renderedTitle = this._register(this.markdownRenderer.render(new MarkdownString(title, { supportThemeIcons: true }), { asyncRenderCallback: () => this._onDidChangeHeight.fire(), })); elements.title.append(renderedTitle.element); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 364c5c2b4b4..0514c67931b 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -6,11 +6,11 @@ import { reset } from '../../../../base/browser/dom.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { groupBy } from '../../../../base/common/collections.js'; +import { diffSets, groupBy } from '../../../../base/common/collections.js'; import { Event } from '../../../../base/common/event.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, derived } from '../../../../base/common/observable.js'; +import { autorun, derived, transaction } from '../../../../base/common/observable.js'; import { assertType } from '../../../../base/common/types.js'; import { ILocalizedString, localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -26,7 +26,7 @@ import { CHAT_CATEGORY } from '../../chat/browser/actions/chatActions.js'; import { ChatAgentLocation } from '../../chat/common/chatAgents.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { McpContextKeys } from '../common/mcpContextKeys.js'; -import { IMcpService, IMcpTool, McpConnectionState } from '../common/mcpTypes.js'; +import { IMcpServer, IMcpService, IMcpTool, McpConnectionState } from '../common/mcpTypes.js'; // acroynms do not get localized const category: ILocalizedString = { @@ -180,12 +180,12 @@ export class AttachMCPToolsAction extends Action2 { f1: false, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( - McpContextKeys.serverCount.greater(0), + McpContextKeys.toolsCount.greater(0), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) ), menu: { when: ContextKeyExpr.and( - McpContextKeys.serverCount.greater(0), + McpContextKeys.toolsCount.greater(0), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) ), id: MenuId.ChatInputAttachmentToolbar, @@ -204,48 +204,131 @@ export class AttachMCPToolsAction extends Action2 { const quickPickService = accessor.get(IQuickInputService); const mcpService = accessor.get(IMcpService); - type IToolPick = IQuickPickItem & { tool: IMcpTool }; - const picks: (IToolPick | IQuickPickSeparator)[] = []; + type ToolPick = IQuickPickItem & { picked: boolean; tool: IMcpTool; parent: ServerPick }; + type ServerPick = IQuickPickItem & { picked: boolean; server: IMcpServer; toolPicks: ToolPick[] }; + type McpPick = ToolPick | ServerPick; + + function isServerPick(obj: any): obj is ServerPick { + return Boolean((obj as ServerPick).server); + } + function isToolPick(obj: any): obj is ToolPick { + return Boolean((obj as ToolPick).tool); + } + + const store = new DisposableStore(); + const picker = store.add(quickPickService.createQuickPick({ useSeparators: true })); + + const picks: (McpPick | IQuickPickSeparator)[] = []; for (const server of mcpService.servers.get()) { + const tools = server.tools.get(); + + if (tools.length === 0) { + continue; + } picks.push({ type: 'separator', - label: server.definition.label + label: server.collection.label }); - for (const tool of server.tools.get()) { - picks.push({ - type: 'item', - label: tool.definition.name, - detail: tool.definition.description, - tooltip: tool.definition.description, - picked: tool.enabled.get(), + const item: ServerPick = { + server, + type: 'item', + label: `$(server) ${server.definition.label}`, + description: McpConnectionState.toString(server.state.get()), + picked: tools.some(tool => tool.enabled.get()), + toolPicks: [] + }; + + picks.push(item); + + for (const tool of tools) { + const toolItem: ToolPick = { tool, - }); + parent: item, + type: 'item', + label: `$(tools) ${tool.definition.name}`, + description: tool.definition.description, + picked: tool.enabled.get() + }; + item.toolPicks.push(toolItem); + picks.push(toolItem); } } - const result = await quickPickService.pick(picks, { - placeHolder: localize('placeholder', "Select tools that are available to chat"), - canPickMany: true - }); + picker.placeholder = localize('placeholder', "Select tools that are available to chat"); + picker.canSelectMany = true; - if (!result) { - return; - } + let lastSelectedItems = new Set(); + let ignoreEvent = false; - const seen = new Set(); - for (const item of result) { - item.tool.updateEnablement(true); - seen.add(item.tool); - } - - for (const pick of picks) { - if (pick.type === 'item' && !seen.has(pick.tool)) { - pick.tool.updateEnablement(false); + const _update = () => { + ignoreEvent = true; + try { + const items = picks.filter((p): p is McpPick => p.type === 'item' && Boolean(p.picked)); + lastSelectedItems = new Set(items); + picker.items = picks; + picker.selectedItems = items; + } finally { + ignoreEvent = false; } - } + }; + + _update(); + picker.show(); + + store.add(picker.onDidChangeSelection(selectedPicks => { + if (ignoreEvent) { + return; + } + + const { added, removed } = diffSets(lastSelectedItems, new Set(selectedPicks)); + + for (const item of added) { + item.picked = true; + + if (isServerPick(item)) { + // add server -> add back tools + for (const toolPick of item.toolPicks) { + toolPick.picked = true; + } + } else if (isToolPick(item)) { + // add server when tool is picked + item.parent.picked = true; + } + } + + for (const item of removed) { + item.picked = false; + + if (isServerPick(item)) { + // removed server -> remove tools + for (const toolPick of item.toolPicks) { + toolPick.picked = false; + } + } else if (isToolPick(item) && item.parent.toolPicks.every(child => !child.picked)) { + // remove LAST tool -> remove server + item.parent.picked = false; + } + } + + transaction(tx => { + for (const item of picks) { + if (isToolPick(item)) { + item.tool.updateEnablement(item.picked, tx); + } + } + }); + + _update(); + })); + + + await Promise.race([Event.toPromise(Event.any(picker.onDidAccept, picker.onDidHide))]); + + store.dispose(); + } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts index c49fd058f55..545c678a5c1 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts @@ -15,11 +15,13 @@ import { IMcpService } from './mcpTypes.js'; export namespace McpContextKeys { export const serverCount = new RawContextKey('mcp.serverCount', undefined, { type: 'number', description: localize('mcp.serverCount.description', "Context key that has the number of registered MCP servers") }); + export const toolsCount = new RawContextKey('mcp.toolsCount', undefined, { type: 'number', description: localize('mcp.toolsCount.description', "Context key that has the number of registered MCP tools") }); } export class McpContextKeysController extends Disposable implements IWorkbenchContribution { - public static readonly ID = 'workbench.contrib.mcp.contextKey'; + + static readonly ID = 'workbench.contrib.mcp.contextKey'; constructor( @IMcpService mcpService: IMcpService, @@ -28,9 +30,11 @@ export class McpContextKeysController extends Disposable implements IWorkbenchCo super(); const ctxServerCount = McpContextKeys.serverCount.bindTo(contextKeyService); + const ctxToolsCount = McpContextKeys.toolsCount.bindTo(contextKeyService); this._store.add(autorun(r => { ctxServerCount.set(mcpService.servers.read(r).length); + ctxToolsCount.set(mcpService.servers.read(r).reduce((count, server) => count + server.tools.read(r).length, 0)); })); } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index f884386642d..082d62363f1 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -230,8 +230,8 @@ export class McpTool implements IMcpTool { this.id = `${_server.definition.id}_${definition.name}`.replaceAll('.', '_'); } - updateEnablement(value: boolean): void { - this._enabled.set(value, undefined); + updateEnablement(value: boolean, tx?: ITransaction): void { + this._enabled.set(value, tx); } call(params: Record, token?: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index f46e7fd57b5..f08e7af0f48 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, autorunWithStore, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { ILanguageModelToolsService, IToolResult } from '../../chat/common/languageModelToolsService.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; @@ -29,7 +31,8 @@ export class McpService extends Disposable implements IMcpService { @IInstantiationService instantiationService: IInstantiationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @ILanguageModelToolsService toolsService: ILanguageModelToolsService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @IProductService productService: IProductService ) { super(); @@ -96,6 +99,7 @@ export class McpService extends Disposable implements IMcpService { ctxInst.set(tool.enabled.read(reader)); })); + newStore.add(toolsService.registerToolData({ id: tool.id, displayName: tool.definition.name, @@ -107,11 +111,20 @@ export class McpService extends Disposable implements IMcpService { newStore.add(toolsService.registerToolImplementation(tool.id, { async prepareToolInvocation(parameters, token) { + + const mcpToolWarning = localize( + 'mcp.tool.warning', + "MCP servers or malicious conversation content may attempt to misuse '{0}' through the installed tools. Please carefully review any requested actions.", + productService.nameShort + ); + return { confirmationMessages: { - title: localize('aaa', "Run tool from {0}", server.definition.label), - message: localize('ddd', "Do you allow to run `{0}` from `{1}`?", tool.definition.name, server.definition.label) - } + title: localize('msg.title', "Run `{0}` from $(server) `{1}` (MCP server)", tool.definition.name, server.definition.label), + message: new MarkdownString(localize('msg.msg', "{0}\n\nInput:\n\n```json\n{1}\n```\n\n$(warning) {2}", tool.definition.description, JSON.stringify(parameters, undefined, 2), mcpToolWarning), { supportThemeIcons: true }) + }, + invocationMessage: new MarkdownString(localize('msg.run', "Running `{0}`", tool.definition.name, server.definition.label)), + pastTenseMessage: new MarkdownString(localize('msg.ran', "Ran `{0}` ", tool.definition.name, server.definition.label)) }; }, @@ -133,6 +146,8 @@ export class McpService extends Disposable implements IMcpService { } } + // result.toolResultMessage = new MarkdownString(localize('reuslt.pattern', "```json\n{0}\n```", JSON.stringify(callResult, undefined, 2))); + return result; }, })); diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 5baf2ca137d..ec1b2e00c4b 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -7,7 +7,7 @@ import { assertNever } from '../../../../base/common/assert.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { equals as objectsEqual } from '../../../../base/common/objects.js'; -import { IObservable } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; @@ -113,7 +113,7 @@ export interface IMcpTool { readonly enabled: IObservable; - updateEnablement(value: boolean): void; + updateEnablement(value: boolean, tx?: ITransaction): void; /** * Calls a tool From 38cc6fe8b4ae0f6ae3a85c1a7e13d2e7ec1de36a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 12 Mar 2025 10:26:38 -0400 Subject: [PATCH 045/255] add kind of completion to aria label (#243241) --- src/vs/editor/common/languages.ts | 37 +++++++++++++++++++ .../contrib/suggest/browser/suggestWidget.ts | 12 ++++-- .../suggest/browser/terminalSuggestAddon.ts | 16 ++++++++ .../suggest/browser/simpleCompletionItem.ts | 5 +++ .../suggest/browser/simpleSuggestWidget.ts | 10 +++-- 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 10f7119dd2c..6ce3c242173 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -431,6 +431,43 @@ export namespace CompletionItemKinds { return codicon; } + /** + * @internal + */ + export function toLabel(kind: CompletionItemKind): string { + switch (kind) { + case CompletionItemKind.Method: return localize('suggestWidget.kind.method', 'Method'); + case CompletionItemKind.Function: return localize('suggestWidget.kind.function', 'Function'); + case CompletionItemKind.Constructor: return localize('suggestWidget.kind.constructor', 'Constructor'); + case CompletionItemKind.Field: return localize('suggestWidget.kind.field', 'Field'); + case CompletionItemKind.Variable: return localize('suggestWidget.kind.variable', 'Variable'); + case CompletionItemKind.Class: return localize('suggestWidget.kind.class', 'Class'); + case CompletionItemKind.Struct: return localize('suggestWidget.kind.struct', 'Struct'); + case CompletionItemKind.Interface: return localize('suggestWidget.kind.interface', 'Interface'); + case CompletionItemKind.Module: return localize('suggestWidget.kind.module', 'Module'); + case CompletionItemKind.Property: return localize('suggestWidget.kind.property', 'Property'); + case CompletionItemKind.Event: return localize('suggestWidget.kind.event', 'Event'); + case CompletionItemKind.Operator: return localize('suggestWidget.kind.operator', 'Operator'); + case CompletionItemKind.Unit: return localize('suggestWidget.kind.unit', 'Unit'); + case CompletionItemKind.Value: return localize('suggestWidget.kind.value', 'Value'); + case CompletionItemKind.Constant: return localize('suggestWidget.kind.constant', 'Constant'); + case CompletionItemKind.Enum: return localize('suggestWidget.kind.enum', 'Enum'); + case CompletionItemKind.EnumMember: return localize('suggestWidget.kind.enumMember', 'Enum Member'); + case CompletionItemKind.Keyword: return localize('suggestWidget.kind.keyword', 'Keyword'); + case CompletionItemKind.Text: return localize('suggestWidget.kind.text', 'Text'); + case CompletionItemKind.Color: return localize('suggestWidget.kind.color', 'Color'); + case CompletionItemKind.File: return localize('suggestWidget.kind.file', 'File'); + case CompletionItemKind.Reference: return localize('suggestWidget.kind.reference', 'Reference'); + case CompletionItemKind.Customcolor: return localize('suggestWidget.kind.customcolor', 'Custom Color'); + case CompletionItemKind.Folder: return localize('suggestWidget.kind.folder', 'Folder'); + case CompletionItemKind.TypeParameter: return localize('suggestWidget.kind.typeParameter', 'Type Parameter'); + case CompletionItemKind.User: return localize('suggestWidget.kind.user', 'User'); + case CompletionItemKind.Issue: return localize('suggestWidget.kind.issue', 'Issue'); + case CompletionItemKind.Snippet: return localize('suggestWidget.kind.snippet', 'Snippet'); + default: return ''; + } + } + const data = new Map(); data.set('method', CompletionItemKind.Method); data.set('function', CompletionItemKind.Function); diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index fbaa5530d89..8c77c920b40 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -35,6 +35,7 @@ import { canExpandCompletionItem, SuggestDetailsOverlay, SuggestDetailsWidget } import { ItemRenderer } from './suggestWidgetRenderer.js'; import { getListStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { CompletionItemKinds } from '../../../common/languages.js'; /** * Suggest widget colors @@ -236,17 +237,19 @@ export class SuggestWidget implements IDisposable { getAriaLabel: (item: CompletionItem) => { let label = item.textLabel; + const kindLabel = CompletionItemKinds.toLabel(item.completion.kind); if (typeof item.completion.label !== 'string') { const { detail, description } = item.completion.label; if (detail && description) { - label = nls.localize('label.full', '{0} {1}, {2}', label, detail, description); + label = nls.localize('label.full', '{0} {1}, {2}, {3}', label, detail, description, kindLabel); } else if (detail) { - label = nls.localize('label.detail', '{0} {1}', label, detail); + label = nls.localize('label.detail', '{0} {1}, {2}', label, detail, kindLabel); } else if (description) { - label = nls.localize('label.desc', '{0}, {1}', label, description); + label = nls.localize('label.desc', '{0}, {1}, {2}', label, description, kindLabel); } + } else { + label = nls.localize('label', '{0}, {1}', label, kindLabel); } - if (!item.isResolved || !this._isDetailsVisible()) { return label; } @@ -1041,3 +1044,4 @@ export class SuggestContentWidget implements IContentWidget { this._position = position; } } + diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 7346f6d6938..2c854f922a5 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -36,6 +36,7 @@ import { GOLDEN_LINE_HEIGHT_RATIO, MINIMUM_LINE_HEIGHT } from '../../../../../ed import { TerminalCompletionModel } from './terminalCompletionModel.js'; import { TerminalCompletionItem, TerminalCompletionItemKind, type ITerminalCompletion } from './terminalCompletionItem.js'; import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; +import { localize } from '../../../../../nls.js'; export interface ISuggestController { isPasting: boolean; @@ -101,6 +102,19 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest [TerminalCompletionItemKind.InlineSuggestionAlwaysOnTop, Codicon.star], ]); + private _kindToTypeMap = new Map([ + [TerminalCompletionItemKind.File, localize('file', 'File')], + [TerminalCompletionItemKind.Folder, localize('folder', 'Folder')], + [TerminalCompletionItemKind.Method, localize('method', 'Method')], + [TerminalCompletionItemKind.Alias, localize('alias', 'Alias')], + [TerminalCompletionItemKind.Argument, localize('argument', 'Argument')], + [TerminalCompletionItemKind.Option, localize('option', 'Option')], + [TerminalCompletionItemKind.OptionValue, localize('optionValue', 'Option Value')], + [TerminalCompletionItemKind.Flag, localize('flag', 'Flag')], + [TerminalCompletionItemKind.InlineSuggestion, localize('inlineSuggestion', 'Inline Suggestion')], + [TerminalCompletionItemKind.InlineSuggestionAlwaysOnTop, localize('inlineSuggestionAlwaysOnTop', 'Inline Suggestion')], + ]); + private readonly _inlineCompletion: ITerminalCompletion = { label: '', // Right arrow is used to accept the completion. This is a common keybinding in pwsh, zsh @@ -111,6 +125,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest provider: 'core', detail: 'Inline suggestion', kind: TerminalCompletionItemKind.InlineSuggestion, + kindLabel: 'Inline suggestion', icon: this._kindToIconMap.get(TerminalCompletionItemKind.InlineSuggestion), }; private readonly _inlineCompletionItem = new TerminalCompletionItem(this._inlineCompletion); @@ -291,6 +306,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest for (const completion of completions) { if (!completion.icon && completion.kind !== undefined) { completion.icon = this._kindToIconMap.get(completion.kind); + completion.kindLabel = this._kindToTypeMap.get(completion.kind); } } diff --git a/src/vs/workbench/services/suggest/browser/simpleCompletionItem.ts b/src/vs/workbench/services/suggest/browser/simpleCompletionItem.ts index e89c53ae4b8..05ce1746f1b 100644 --- a/src/vs/workbench/services/suggest/browser/simpleCompletionItem.ts +++ b/src/vs/workbench/services/suggest/browser/simpleCompletionItem.ts @@ -29,6 +29,11 @@ export interface ISimpleCompletion { */ icon?: ThemeIcon; + /** + * The completion item's kind that will be included in the aria label. + */ + kindLabel?: string; + /** * The completion's detail which appears on the right of the list. */ diff --git a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts index 7c4db50d7ba..0c9752a960d 100644 --- a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts +++ b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts @@ -201,17 +201,19 @@ export class SimpleSuggestWidget, TI getWidgetRole: () => 'listbox', getAriaLabel: (item: SimpleCompletionItem) => { let label = item.textLabel; + const kindLabel = item.completion.kindLabel ?? ''; if (typeof item.completion.label !== 'string') { const { detail, description } = item.completion.label; if (detail && description) { - label = localize('label.full', '{0}{1}, {2}', label, detail, description); + label = localize('label.full', '{0}{1}, {2} {3}', label, detail, description, kindLabel); } else if (detail) { - label = localize('label.detail', '{0}{1}', label, detail); + label = localize('label.detail', '{0}{1} {2}', label, detail, kindLabel); } else if (description) { - label = localize('label.desc', '{0}, {1}', label, description); + label = localize('label.desc', '{0}, {1} {2}', label, description, kindLabel); } + } else { + label = localize('label', '{0}, {1}', label, kindLabel); } - const { documentation, detail } = item.completion; const docs = strings.format( '{0}{1}', From 6e2d27b9bb02865ee199f1f9baee2cdb977b9c67 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 08:15:06 -0700 Subject: [PATCH 046/255] Correct merge problem --- src/vs/workbench/contrib/terminal/browser/terminalView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index df6f8bc7ebe..42ff0ac1879 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -678,7 +678,7 @@ class SingleTabHoverDelegate implements IHoverDelegate { return; } const hoverInfo = getInstanceHoverInfo(instance, this._storageService); - return this._hoverService.getInstanceHoverInfo({ + return this._hoverService.showInstantHover({ ...options, content: hoverInfo.content, actions: hoverInfo.actions From 0aa5ce75151173f9721875965d9304610ea9c6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 12 Mar 2025 16:47:18 +0100 Subject: [PATCH 047/255] bump inno updater (#243364) --- build/win32/Cargo.lock | 2 +- build/win32/Cargo.toml | 2 +- build/win32/inno_updater.exe | Bin 437760 -> 439808 bytes 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index 4c169ba0f97..5437686ef94 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -95,7 +95,7 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "inno_updater" -version = "0.12.1" +version = "0.13.2" dependencies = [ "byteorder", "crc", diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index e2130dd2bfa..42958b3124a 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.12.1" +version = "0.13.2" authors = ["Microsoft "] build = "build.rs" diff --git a/build/win32/inno_updater.exe b/build/win32/inno_updater.exe index ef7cb934eb637a39ef4246012216e64dc6d5cc68..2ca110dea1fd05d9d96ac9c741f67ca6e5520234 100644 GIT binary patch delta 200459 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 YMKB8ZCfP=a1#sZ*j*^sVnN{xoFG;#H)c^nh delta 198321 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 z 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 Date: Wed, 12 Mar 2025 09:07:34 -0700 Subject: [PATCH 048/255] Don't duplicate body in md preview Fixes #243340 --- extensions/markdown-language-features/preview-src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 08c6759f05b..7e5aee5e277 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -68,7 +68,7 @@ onceDocumentLoaded(() => { getRawData('data-initial-md-content'), 'text/html' ); - document.body.appendChild(markDownHtml.body); + document.body.append(...markDownHtml.body.children); // Restore const scrollProgress = state.scrollProgress; From 7e4a7468c54e32790a7225007f170f5071d51b7e Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 12 Mar 2025 17:41:50 +0100 Subject: [PATCH 049/255] tweak quick pick for tools (#243370) --- src/vs/workbench/contrib/mcp/browser/mcpCommands.ts | 10 ++++++---- src/vs/workbench/contrib/mcp/browser/media/mcp.css | 8 ++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/browser/media/mcp.css diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 0514c67931b..3a766d4e4c0 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/mcp.css'; import { reset } from '../../../../base/browser/dom.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -229,14 +230,14 @@ export class AttachMCPToolsAction extends Action2 { } picks.push({ type: 'separator', - label: server.collection.label + label: localize('desc', "MCP Server - {0}", McpConnectionState.toString(server.state.get())) }); const item: ServerPick = { server, type: 'item', - label: `$(server) ${server.definition.label}`, - description: McpConnectionState.toString(server.state.get()), + label: `${server.definition.label}`, + description: localize('desc', "MCP Server - {0}", McpConnectionState.toString(server.state.get())), picked: tools.some(tool => tool.enabled.get()), toolPicks: [] }; @@ -250,7 +251,8 @@ export class AttachMCPToolsAction extends Action2 { type: 'item', label: `$(tools) ${tool.definition.name}`, description: tool.definition.description, - picked: tool.enabled.get() + picked: tool.enabled.get(), + iconClasses: ['mcp-tool'] }; item.toolPicks.push(toolItem); picks.push(toolItem); diff --git a/src/vs/workbench/contrib/mcp/browser/media/mcp.css b/src/vs/workbench/contrib/mcp/browser/media/mcp.css new file mode 100644 index 00000000000..f65fb9628f7 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/media/mcp.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.quick-input-list .quick-input-list-rows > .quick-input-list-row .monaco-icon-label.mcp-tool .codicon[class*='codicon-'] { + font-size: 14px; +} From 7209458e541cbb9f667ab40e98be1b34140d2f53 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:42:10 -0700 Subject: [PATCH 050/255] Remove undefined from TerminalShellIntegrationEnvironment.value (#243219) Remove | undefined from TerminalShellIntegrationEnvironment.value --- src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts b/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts index ff3ea00e765..e4bc845c46a 100644 --- a/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts +++ b/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts @@ -10,7 +10,7 @@ declare module 'vscode' { /** * The dictionary of environment variables. */ - value: { [key: string]: string | undefined } | undefined; + value: { [key: string]: string | undefined }; /** * Whether the environment came from a trusted source and is therefore safe to use its From 9e41fb2187e05be9086899a25411d2e53c875c1a Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:47:02 -0700 Subject: [PATCH 051/255] Use Object.freeze when exposing shell env api (#243262) Use Object.freeze when exposing extension API --- .../api/common/extHostTerminalShellIntegration.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index 8dcc6614301..af013c0c7c5 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -184,7 +184,13 @@ class InternalTerminalShellIntegration extends Disposable { return that._cwd; }, get env(): vscode.TerminalShellIntegrationEnvironment | undefined { - return that._env; + if (!that._env) { + return undefined; + } + return Object.freeze({ + isTrusted: that._env.isTrusted, + value: Object.freeze({ ...that._env.value }) + }); }, get hasRichCommandDetection(): boolean { return that._hasRichCommandDetection; From 97837b945ab54e88b5f61e42d9bbc6d09c6817b7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 12 Mar 2025 18:15:29 +0100 Subject: [PATCH 052/255] handle allRepositorySigned gallery capability (#243358) --- .../common/extensionGalleryService.ts | 3 ++- .../extensionManagement/common/extensionManagement.ts | 1 + .../node/extensionManagementService.ts | 10 +++++++++- .../extensions/browser/extensions.contribution.ts | 4 +++- .../contrib/extensions/browser/extensionsActions.ts | 6 ++++-- .../extensions/browser/extensionsWorkbenchService.ts | 2 +- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 2acd0d13ad5..9ec0ff32f90 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -645,7 +645,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle query: { sortBy, filters - } + }, + allRepositorySigned: !extensionGalleryManifest.capabilities.signing?.allRepositorySigned, }; } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index ffeed393a05..1dd20692b3a 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -380,6 +380,7 @@ export interface IExtensionGalleryCapabilities { readonly sortBy: readonly SortBy[]; readonly filters: readonly FilterType[]; }; + readonly allRepositorySigned: boolean; } export const IExtensionGalleryService = createDecorator('extensionGalleryService'); diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 11bb73f0848..28b058ee782 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -332,8 +332,15 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi verifySignature = isBoolean(value) ? value : true; } const { location, verificationStatus } = await this.extensionsDownloader.download(extension, operation, verifySignature, clientTargetPlatform); + const shouldRequireSignature = (await this.galleryService.getCapabilities()).allRepositorySigned; - if (verificationStatus !== ExtensionSignatureVerificationCode.Success && verificationStatus !== ExtensionSignatureVerificationCode.NotSigned && verifySignature && this.environmentService.isBuilt && !(isLinux && this.productService.quality === 'stable')) { + if ( + verificationStatus !== ExtensionSignatureVerificationCode.Success + && !(verificationStatus === ExtensionSignatureVerificationCode.NotSigned && !shouldRequireSignature) + && verifySignature + && this.environmentService.isBuilt + && !(isLinux && this.productService.quality === 'stable') + ) { try { await this.extensionsDownloader.delete(location); } catch (e) { @@ -356,6 +363,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi case ExtensionSignatureVerificationCode.CertificateRevoked: case ExtensionSignatureVerificationCode.SignatureIsNotValid: case ExtensionSignatureVerificationCode.SignatureArchiveHasTooManyEntries: + case ExtensionSignatureVerificationCode.NotSigned: throw new ExtensionManagementError(nls.localize('signature verification failed', "Signature verification failed with '{0}' error.", verificationStatus), ExtensionManagementErrorCode.SignatureVerificationFailed); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 3bc489fb79c..c1565b16c63 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -548,6 +548,7 @@ export const CONTEXT_HAS_REMOTE_SERVER = new RawContextKey('hasRemoteSe export const CONTEXT_HAS_WEB_SERVER = new RawContextKey('hasWebServer', false); const CONTEXT_GALLERY_SORT_CAPABILITIES = new RawContextKey('gallerySortCapabilities', ''); const CONTEXT_GALLERY_FILTER_CAPABILITIES = new RawContextKey('galleryFilterCapabilities', ''); +const CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED = new RawContextKey('galleryAllRepositorySigned', false); async function runAction(action: IAction): Promise { try { @@ -609,6 +610,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi const capabilities = await this.extensionGalleryService.getCapabilities(); CONTEXT_GALLERY_SORT_CAPABILITIES.bindTo(this.contextKeyService).set(`_${capabilities.query.sortBy.join('_')}_UpdateDate_`); CONTEXT_GALLERY_FILTER_CAPABILITIES.bindTo(this.contextKeyService).set(`_${capabilities.query.filters.join('_')}_`); + CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED.bindTo(this.contextKeyService).set(capabilities.allRepositorySigned); } private registerQuickAccessProvider(): void { @@ -1532,7 +1534,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi menu: { id: MenuId.ExtensionContext, group: '0_install', - when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('extensionIsUnsigned')), + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('extensionIsUnsigned'), CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED), order: 1 }, run: async (accessor: ServicesAccessor, extensionId: string) => { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 7c58defdf35..3c33b2614d3 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -420,6 +420,7 @@ export class InstallAction extends ExtensionAction { @ITelemetryService private readonly telemetryService: ITelemetryService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IAllowedExtensionsService private readonly allowedExtensionsService: IAllowedExtensionsService, + @IExtensionGalleryService private readonly galleryService: IExtensionGalleryService, ) { super('extensions.install', localize('install', "Install"), InstallAction.CLASS, false); this.hideOnDisabled = false; @@ -468,7 +469,7 @@ export class InstallAction extends ExtensionAction { return; } - if (this.extension.gallery && !this.extension.gallery.isSigned) { + if (this.extension.gallery && !this.extension.gallery.isSigned && (await this.galleryService.getCapabilities()).allRepositorySigned) { const { result } = await this.dialogService.prompt({ type: Severity.Warning, message: localize('not signed', "'{0}' is an extension from an unknown source. Are you sure you want to install?", this.extension.displayName), @@ -2523,6 +2524,7 @@ export class ExtensionStatusAction extends ExtensionAction { @IAllowedExtensionsService private readonly allowedExtensionsService: IAllowedExtensionsService, @IWorkbenchExtensionEnablementService private readonly workbenchExtensionEnablementService: IWorkbenchExtensionEnablementService, @IExtensionFeaturesManagementService private readonly extensionFeaturesManagementService: IExtensionFeaturesManagementService, + @IExtensionGalleryService private readonly galleryService: IExtensionGalleryService, ) { super('extensions.status', '', `${ExtensionStatusAction.CLASS} hide`, false); this._register(this.labelService.onDidChangeFormatters(() => this.update(), this)); @@ -2549,7 +2551,7 @@ export class ExtensionStatusAction extends ExtensionAction { return; } - if (this.extension.state === ExtensionState.Uninstalled && this.extension.gallery && !this.extension.gallery.isSigned) { + if (this.extension.state === ExtensionState.Uninstalled && this.extension.gallery && !this.extension.gallery.isSigned && (await this.galleryService.getCapabilities()).allRepositorySigned) { this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('not signed tooltip', "This extension is not signed by the Extension Marketplace.")) }, true); return; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 77f686949c4..b5a1450cca2 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -2278,7 +2278,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } if (extension.gallery) { - if (!extension.gallery.isSigned) { + if (!extension.gallery.isSigned && (await this.galleryService.getCapabilities()).allRepositorySigned) { return new MarkdownString().appendText(nls.localize('not signed', "This extension is not signed.")); } From 7e36f9a2401550f55adfdcb3f3377ad9cbcd8049 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 12 Mar 2025 10:23:29 -0700 Subject: [PATCH 053/255] fix hygenie --- .../workbench/contrib/mcp/test/common/mcpRegistry.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 57f2c727140..6f9f02f2a58 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -13,7 +13,9 @@ import { ConfigurationTarget } from '../../../../../platform/configuration/commo import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILoggerService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { TestLoggerService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; @@ -22,8 +24,6 @@ import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistry import { McpServerConnection } from '../../common/mcpServerConnection.js'; import { McpCollectionDefinition, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; -import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; -import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; class TestConfigurationResolverService implements Partial { declare readonly _serviceBrand: undefined; From 2b292c85334bed3e35937bfadab53a3e9f4272c7 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:47:10 -0700 Subject: [PATCH 054/255] Respond to VT DA1 request when using conpty I'm concerned about accidental regressions which is why this only happens in conpty. It's also hard to gauge the conformance of xterm.js as a whole and which extensions we properly support Fixes #241694 --- .../contrib/terminal/browser/terminalInstance.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index e8ff3493bc2..7893790b4c1 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -847,6 +847,18 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Init winpty compat and link handler after process creation as they rely on the // underlying process OS this._register(this._processManager.onProcessReady(async (processTraits) => { + // Respond to DA1 with basic conformance. Note that including this is required to avoid + // a long delay in conpty 1.22+ where it waits for the response. + // Reference: https://github.com/microsoft/terminal/blob/3760caed97fa9140a40777a8fbc1c95785e6d2ab/src/terminal/adapter/adaptDispatch.cpp#L1471-L1495 + if (processTraits?.windowsPty?.backend === 'conpty') { + this._register(xterm.raw.parser.registerCsiHandler({ final: 'c' }, params => { + if (params.length === 0 || params.length === 1 && params[0] === 0) { + this._processManager.write('\x1b[?61;4c'); + return true; + } + return false; + })); + } if (this._processManager.os) { lineDataEventAddon.setOperatingSystem(this._processManager.os); } From 146b31fbe91f1220538e78a8691eb8ce91346c59 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 12 Mar 2025 11:12:07 -0700 Subject: [PATCH 055/255] mcp: rm --add-mcp-to-workspace for now (#243377) This isn't supported in the IConfigEditingService, and is probably not a feature we need in the immdiate future anyhow. --- src/vs/code/node/cliProcessMain.ts | 2 +- src/vs/platform/environment/common/argv.ts | 1 - src/vs/platform/environment/node/argv.ts | 1 - .../platform/mcp/common/mcpManagementCli.ts | 36 ++----------------- 4 files changed, 3 insertions(+), 37 deletions(-) diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index 35ffa128d90..817baf73992 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -307,7 +307,7 @@ class CliMain extends Disposable { // Install MCP server else if (this.argv['add-mcp']) { - return instantiationService.createInstance(McpManagementCli, new ConsoleLogger(LogLevel.Info, false)).addMcpDefinitions(this.argv['add-mcp-to-workspace'], this.argv['add-mcp']); + return instantiationService.createInstance(McpManagementCli, new ConsoleLogger(LogLevel.Info, false)).addMcpDefinitions(this.argv['add-mcp']); } // Telemetry diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index a4f7464f934..1efc1b5de45 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -94,7 +94,6 @@ export interface NativeParsedArgs { 'export-default-configuration'?: string; 'install-source'?: string; 'add-mcp'?: string[]; - 'add-mcp-to-workspace'?: string; 'disable-updates'?: boolean; 'use-inmemory-secretstorage'?: boolean; 'password-store'?: string; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 2a69ae919cf..0025e4b5973 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -105,7 +105,6 @@ export const OPTIONS: OptionDescriptions> = { 'enable-proposed-api': { type: 'string[]', allowEmptyValue: true, cat: 'e', args: 'ext-id', description: localize('experimentalApis', "Enables proposed API features for extensions. Can receive one or more extension IDs to enable individually.") }, 'add-mcp': { type: 'string[]', cat: 'o', args: 'json', description: localize('addMcp', "Adds a Model Context Protocol server definition to the user profile, or workspace or folder when used with --mcp-workspace. Accepts JSON input in the form '{\"name\":\"server-name\",\"command\":...}'") }, - 'add-mcp-to-workspace': { type: 'string', cat: 'o', args: 'path', description: localize('addMcpWorkspace', "Folder or workspace in which to add Model Context Protocol servers, when used with '--add-mcp'") }, 'version': { type: 'boolean', cat: 't', alias: 'v', description: localize('version', "Print version.") }, 'verbose': { type: 'boolean', cat: 't', global: true, description: localize('verbose', "Print verbose output (implies --wait).") }, diff --git a/src/vs/platform/mcp/common/mcpManagementCli.ts b/src/vs/platform/mcp/common/mcpManagementCli.ts index 54ab50ee121..28ff0484bab 100644 --- a/src/vs/platform/mcp/common/mcpManagementCli.ts +++ b/src/vs/platform/mcp/common/mcpManagementCli.ts @@ -3,13 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from '../../../base/common/uri.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { ConfigurationService } from '../../configuration/common/configurationService.js'; -import { IFileService } from '../../files/common/files.js'; -import { ILogger, ILogService } from '../../log/common/log.js'; -import { IPolicyService } from '../../policy/common/policy.js'; -import { hasWorkspaceFileExtension } from '../../workspace/common/workspace.js'; +import { ILogger } from '../../log/common/log.js'; import { IMcpConfiguration, IMcpConfigurationServer } from './mcpPlatformTypes.js'; type ValidatedConfig = { name: string; config: IMcpConfigurationServer }; @@ -18,40 +13,13 @@ export class McpManagementCli { constructor( private readonly _logger: ILogger, @IConfigurationService private readonly _userConfigurationService: IConfigurationService, - @IFileService private readonly _fileService: IFileService, - @IPolicyService private readonly _policyService: IPolicyService, - @ILogService private readonly _logService: ILogService, ) { } async addMcpDefinitions( - workspace: string | undefined, definitions: string[], ) { const configs = definitions.map((config) => this.validateConfiguration(config)); - - if (workspace) { - // todo (see below comments) - throw new InvalidMcpOperationError(`Installing into workspaces is not yet supported`); - } - - if (!workspace) { - await this.updateMcpInConfig(this._userConfigurationService, configs); - } else if (hasWorkspaceFileExtension(workspace)) { - // This is not right because settings are nested in .code-workspace... - const workspaceConfigService = new ConfigurationService(URI.file(workspace), this._fileService, this._policyService, this._logService); - await this.updateMcpInConfig(workspaceConfigService, configs); - workspaceConfigService.dispose(); - } else { - // todo: this seems incorrect. IConfigurationService.getValue() fails if - // if we point it to mcp.json and call `sevice.getValue()` with no args - // but if we point to launch.json, it writes it there instead of the - // standalone config file. This technically works but is undesirable. - const workspaceFile = URI.joinPath(URI.file(workspace), '.vscode', 'settings.json'); - const workspaceFolderConfigService = new ConfigurationService(workspaceFile, this._fileService, this._policyService, this._logService); - await this.updateMcpInConfig(workspaceFolderConfigService, configs); - workspaceFolderConfigService.dispose(); - } - + await this.updateMcpInConfig(this._userConfigurationService, configs); this._logger.info(`Added MCP servers: ${configs.map(c => c.name).join(', ')}`); } From 3071862cc1a69506bd93ceab1117c8281037e1b9 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Tue, 25 Feb 2025 12:19:27 -0800 Subject: [PATCH 056/255] refactor out the markdown link parsers into a separate file --- .../codecs/markdownCodec/markdownDecoder.ts | 210 +---------------- .../markdownCodec/parsers/markdownLink.ts | 218 ++++++++++++++++++ 2 files changed, 221 insertions(+), 207 deletions(-) create mode 100644 src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts diff --git a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts index 3703fa1df29..71b8d648846 100644 --- a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts +++ b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts @@ -4,18 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { MarkdownLink } from './tokens/markdownLink.js'; -import { NewLine } from '../linesCodec/tokens/newLine.js'; -import { assert } from '../../../../base/common/assert.js'; -import { FormFeed } from '../simpleCodec/tokens/formFeed.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { VerticalTab } from '../simpleCodec/tokens/verticalTab.js'; +import { LeftBracket } from '../simpleCodec/tokens/brackets.js'; import { ReadableStream } from '../../../../base/common/stream.js'; -import { CarriageReturn } from '../linesCodec/tokens/carriageReturn.js'; import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; -import { LeftBracket, RightBracket } from '../simpleCodec/tokens/brackets.js'; import { SimpleDecoder, TSimpleToken } from '../simpleCodec/simpleDecoder.js'; -import { ParserBase, TAcceptTokenResult } from '../simpleCodec/parserBase.js'; -import { LeftParenthesis, RightParenthesis } from '../simpleCodec/tokens/parentheses.js'; +import { MarkdownLinkCaption, PartialMarkdownLink, PartialMarkdownLinkCaption } from './parsers/markdownLink.js'; /** * Tokens handled by this decoder. @@ -23,205 +17,7 @@ import { LeftParenthesis, RightParenthesis } from '../simpleCodec/tokens/parenth export type TMarkdownToken = MarkdownLink | TSimpleToken; /** - * List of characters that stop a markdown link sequence. - */ -const MARKDOWN_LINK_STOP_CHARACTERS: readonly string[] = [CarriageReturn, NewLine, VerticalTab, FormFeed] - .map((token) => { return token.symbol; }); - -/** - * The parser responsible for parsing a `markdown link caption` part of a markdown - * link (e.g., the `[caption text]` part of the `[caption text](./some/path)` link). - * - * The parsing process starts with single `[` token and collects all tokens until - * the first `]` token is encountered. In this successful case, the parser transitions - * into the {@linkcode MarkdownLinkCaption} parser type which continues the general - * parsing process of the markdown link. - * - * Otherwise, if one of the stop characters defined in the {@linkcode MARKDOWN_LINK_STOP_CHARACTERS} - * is encountered before the `]` token, the parsing process is aborted which is communicated to - * the caller by returning a `failure` result. In this case, the caller is assumed to be responsible - * for re-emitting the {@link tokens} accumulated so far as standalone entities since they are no - * longer represent a coherent token entity of a larger size. - */ -class PartialMarkdownLinkCaption extends ParserBase { - constructor(token: LeftBracket) { - super([token]); - } - - public accept(token: TSimpleToken): TAcceptTokenResult { - // any of stop characters is are breaking a markdown link caption sequence - if (MARKDOWN_LINK_STOP_CHARACTERS.includes(token.text)) { - return { - result: 'failure', - wasTokenConsumed: false, - }; - } - - // the `]` character ends the caption of a markdown link - if (token instanceof RightBracket) { - return { - result: 'success', - nextParser: new MarkdownLinkCaption([...this.tokens, token]), - wasTokenConsumed: true, - }; - } - - // otherwise, include the token in the sequence - // and keep the current parser object instance - this.currentTokens.push(token); - return { - result: 'success', - nextParser: this, - wasTokenConsumed: true, - }; - } -} - -/** - * The parser responsible for transitioning from a {@linkcode PartialMarkdownLinkCaption} - * parser to the {@link PartialMarkdownLink} one, therefore serves a parser glue between - * the `[caption]` and the `(./some/path)` parts of the `[caption](./some/path)` link. - * - * The only successful case of this parser is the `(` token that initiated the process - * of parsing the `reference` part of a markdown link and in this case the parser - * transitions into the `PartialMarkdownLink` parser type. - * - * Any other character is considered a failure result. In this case, the caller is assumed - * to be responsible for re-emitting the {@link tokens} accumulated so far as standalone - * entities since they are no longer represent a coherent token entity of a larger size. - */ -class MarkdownLinkCaption extends ParserBase { - public accept(token: TSimpleToken): TAcceptTokenResult { - // the `(` character starts the link part of a markdown link - // that is the only character that can follow the caption - if (token instanceof LeftParenthesis) { - return { - result: 'success', - wasTokenConsumed: true, - nextParser: new PartialMarkdownLink([...this.tokens], token), - }; - } - - return { - result: 'failure', - wasTokenConsumed: false, - }; - } -} - -/** - * The parser responsible for parsing a `link reference` part of a markdown link - * (e.g., the `(./some/path)` part of the `[caption text](./some/path)` link). - * - * The parsing process starts with tokens that represent the `[caption]` part of a markdown - * link, followed by the `(` token. The parser collects all subsequent tokens until final closing - * parenthesis (`)`) is encountered (*\*see [1] below*). In this successful case, the parser object - * transitions into the {@linkcode MarkdownLink} token type which signifies the end of the entire - * parsing process of the link text. - * - * Otherwise, if one of the stop characters defined in the {@linkcode MARKDOWN_LINK_STOP_CHARACTERS} - * is encountered before the final `)` token, the parsing process is aborted which is communicated to - * the caller by returning a `failure` result. In this case, the caller is assumed to be responsible - * for re-emitting the {@link tokens} accumulated so far as standalone entities since they are no - * longer represent a coherent token entity of a larger size. - * - * `[1]` The `reference` part of the markdown link can contain any number of nested parenthesis, e.g., - * `[caption](/some/p(th/file.md)` is a valid markdown link and a valid folder name, hence number - * of open parenthesis must match the number of closing ones and the path sequence is considered - * to be complete as soon as this requirement is met. Therefore the `final` word is used in - * the description comments above to highlight this important detail. - */ -class PartialMarkdownLink extends ParserBase { - /** - * Number of open parenthesis in the sequence. - * See comment in the {@linkcode accept} method for more details. - */ - private openParensCount: number = 1; - - constructor( - protected readonly captionTokens: TSimpleToken[], - token: LeftParenthesis, - ) { - super([token]); - } - - public override get tokens(): readonly TSimpleToken[] { - return [...this.captionTokens, ...this.currentTokens]; - } - - public accept(token: TSimpleToken): TAcceptTokenResult { - // markdown links allow for nested parenthesis inside the link reference part, but - // the number of open parenthesis must match the number of closing parenthesis, e.g.: - // - `[caption](/some/p()th/file.md)` is a valid markdown link - // - `[caption](/some/p(th/file.md)` is an invalid markdown link - // hence we use the `openParensCount` variable to keep track of the number of open - // parenthesis encountered so far; then upon encountering a closing parenthesis we - // decrement the `openParensCount` and if it reaches 0 - we consider the link reference - // to be complete - - if (token instanceof LeftParenthesis) { - this.openParensCount += 1; - } - - if (token instanceof RightParenthesis) { - this.openParensCount -= 1; - - // sanity check! this must alway hold true because we return a complete markdown - // link as soon as we encounter matching number of closing parenthesis, hence - // we must never have `openParensCount` that is less than 0 - assert( - this.openParensCount >= 0, - `Unexpected right parenthesis token encountered: '${token}'.`, - ); - - // the markdown link is complete as soon as we get the same number of closing parenthesis - if (this.openParensCount === 0) { - const { startLineNumber, startColumn } = this.captionTokens[0].range; - - // create link caption string - const caption = this.captionTokens - .map((token) => { return token.text; }) - .join(''); - - // create link reference string - this.currentTokens.push(token); - const reference = this.currentTokens - .map((token) => { return token.text; }).join(''); - - // return complete markdown link object - return { - result: 'success', - wasTokenConsumed: true, - nextParser: new MarkdownLink( - startLineNumber, - startColumn, - caption, - reference, - ), - }; - } - } - - // any of stop characters is are breaking a markdown link reference sequence - if (MARKDOWN_LINK_STOP_CHARACTERS.includes(token.text)) { - return { - result: 'failure', - wasTokenConsumed: false, - }; - } - - // the rest of the tokens can be included in the sequence - this.currentTokens.push(token); - return { - result: 'success', - nextParser: this, - wasTokenConsumed: true, - }; - } -} - -/** - * Decoder capable of parsing markdown entities (e.g., links) from a sequence of simplier tokens. + * Decoder capable of parsing markdown entities (e.g., links) from a sequence of simple tokens. */ export class MarkdownDecoder extends BaseDecoder { /** diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts new file mode 100644 index 00000000000..66dc2ce4f7e --- /dev/null +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownLink } from '../tokens/markdownLink.js'; +import { NewLine } from '../../linesCodec/tokens/newLine.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { FormFeed } from '../../simpleCodec/tokens/formFeed.js'; +import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; +import { VerticalTab } from '../../simpleCodec/tokens/verticalTab.js'; +import { CarriageReturn } from '../../linesCodec/tokens/carriageReturn.js'; +import { LeftBracket, RightBracket } from '../../simpleCodec/tokens/brackets.js'; +import { ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; +import { LeftParenthesis, RightParenthesis } from '../../simpleCodec/tokens/parentheses.js'; + +/** + * Tokens handled by this decoder. + */ +export type TMarkdownToken = MarkdownLink | TSimpleToken; + +/** + * List of characters that stop a markdown link sequence. + */ +const MARKDOWN_LINK_STOP_CHARACTERS: readonly string[] = [CarriageReturn, NewLine, VerticalTab, FormFeed] + .map((token) => { return token.symbol; }); + +/** + * The parser responsible for parsing a `markdown link caption` part of a markdown + * link (e.g., the `[caption text]` part of the `[caption text](./some/path)` link). + * + * The parsing process starts with single `[` token and collects all tokens until + * the first `]` token is encountered. In this successful case, the parser transitions + * into the {@linkcode MarkdownLinkCaption} parser type which continues the general + * parsing process of the markdown link. + * + * Otherwise, if one of the stop characters defined in the {@linkcode MARKDOWN_LINK_STOP_CHARACTERS} + * is encountered before the `]` token, the parsing process is aborted which is communicated to + * the caller by returning a `failure` result. In this case, the caller is assumed to be responsible + * for re-emitting the {@link tokens} accumulated so far as standalone entities since they are no + * longer represent a coherent token entity of a larger size. + */ +export class PartialMarkdownLinkCaption extends ParserBase { + constructor(token: LeftBracket) { + super([token]); + } + + public accept(token: TSimpleToken): TAcceptTokenResult { + // any of stop characters is are breaking a markdown link caption sequence + if (MARKDOWN_LINK_STOP_CHARACTERS.includes(token.text)) { + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // the `]` character ends the caption of a markdown link + if (token instanceof RightBracket) { + return { + result: 'success', + nextParser: new MarkdownLinkCaption([...this.tokens, token]), + wasTokenConsumed: true, + }; + } + + // otherwise, include the token in the sequence + // and keep the current parser object instance + this.currentTokens.push(token); + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } +} + +/** + * The parser responsible for transitioning from a {@linkcode PartialMarkdownLinkCaption} + * parser to the {@link PartialMarkdownLink} one, therefore serves a parser glue between + * the `[caption]` and the `(./some/path)` parts of the `[caption](./some/path)` link. + * + * The only successful case of this parser is the `(` token that initiated the process + * of parsing the `reference` part of a markdown link and in this case the parser + * transitions into the `PartialMarkdownLink` parser type. + * + * Any other character is considered a failure result. In this case, the caller is assumed + * to be responsible for re-emitting the {@link tokens} accumulated so far as standalone + * entities since they are no longer represent a coherent token entity of a larger size. + */ +export class MarkdownLinkCaption extends ParserBase { + public accept(token: TSimpleToken): TAcceptTokenResult { + // the `(` character starts the link part of a markdown link + // that is the only character that can follow the caption + if (token instanceof LeftParenthesis) { + return { + result: 'success', + wasTokenConsumed: true, + nextParser: new PartialMarkdownLink([...this.tokens], token), + }; + } + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} + +/** + * The parser responsible for parsing a `link reference` part of a markdown link + * (e.g., the `(./some/path)` part of the `[caption text](./some/path)` link). + * + * The parsing process starts with tokens that represent the `[caption]` part of a markdown + * link, followed by the `(` token. The parser collects all subsequent tokens until final closing + * parenthesis (`)`) is encountered (*\*see [1] below*). In this successful case, the parser object + * transitions into the {@linkcode MarkdownLink} token type which signifies the end of the entire + * parsing process of the link text. + * + * Otherwise, if one of the stop characters defined in the {@linkcode MARKDOWN_LINK_STOP_CHARACTERS} + * is encountered before the final `)` token, the parsing process is aborted which is communicated to + * the caller by returning a `failure` result. In this case, the caller is assumed to be responsible + * for re-emitting the {@link tokens} accumulated so far as standalone entities since they are no + * longer represent a coherent token entity of a larger size. + * + * `[1]` The `reference` part of the markdown link can contain any number of nested parenthesis, e.g., + * `[caption](/some/p(th/file.md)` is a valid markdown link and a valid folder name, hence number + * of open parenthesis must match the number of closing ones and the path sequence is considered + * to be complete as soon as this requirement is met. Therefore the `final` word is used in + * the description comments above to highlight this important detail. + */ +export class PartialMarkdownLink extends ParserBase { + /** + * Number of open parenthesis in the sequence. + * See comment in the {@linkcode accept} method for more details. + */ + private openParensCount: number = 1; + + constructor( + protected readonly captionTokens: TSimpleToken[], + token: LeftParenthesis, + ) { + super([token]); + } + + public override get tokens(): readonly TSimpleToken[] { + return [...this.captionTokens, ...this.currentTokens]; + } + + public accept(token: TSimpleToken): TAcceptTokenResult { + // markdown links allow for nested parenthesis inside the link reference part, but + // the number of open parenthesis must match the number of closing parenthesis, e.g.: + // - `[caption](/some/p()th/file.md)` is a valid markdown link + // - `[caption](/some/p(th/file.md)` is an invalid markdown link + // hence we use the `openParensCount` variable to keep track of the number of open + // parenthesis encountered so far; then upon encountering a closing parenthesis we + // decrement the `openParensCount` and if it reaches 0 - we consider the link reference + // to be complete + + if (token instanceof LeftParenthesis) { + this.openParensCount += 1; + } + + if (token instanceof RightParenthesis) { + this.openParensCount -= 1; + + // sanity check! this must alway hold true because we return a complete markdown + // link as soon as we encounter matching number of closing parenthesis, hence + // we must never have `openParensCount` that is less than 0 + assert( + this.openParensCount >= 0, + `Unexpected right parenthesis token encountered: '${token}'.`, + ); + + // the markdown link is complete as soon as we get the same number of closing parenthesis + if (this.openParensCount === 0) { + const { startLineNumber, startColumn } = this.captionTokens[0].range; + + // create link caption string + const caption = this.captionTokens + .map((token) => { return token.text; }) + .join(''); + + // create link reference string + this.currentTokens.push(token); + const reference = this.currentTokens + .map((token) => { return token.text; }).join(''); + + // return complete markdown link object + return { + result: 'success', + wasTokenConsumed: true, + nextParser: new MarkdownLink( + startLineNumber, + startColumn, + caption, + reference, + ), + }; + } + } + + // any of stop characters is are breaking a markdown link reference sequence + if (MARKDOWN_LINK_STOP_CHARACTERS.includes(token.text)) { + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // the rest of the tokens can be included in the sequence + this.currentTokens.push(token); + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } +} From 6aab0b1a86afa1fed7446762ea1e4179db52a002 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Tue, 25 Feb 2025 14:50:42 -0800 Subject: [PATCH 057/255] suport for `<` and `>` tokens in the simple decoder --- .../markdownCodec/parsers/markdownLink.ts | 2 +- .../codecs/simpleCodec/simpleDecoder.ts | 22 ++-- .../simpleCodec/tokens/angleBrackets.ts | 102 ++++++++++++++++++ .../codecs/simpleCodec/tokens/brackets.ts | 7 +- .../codecs/simpleCodec/tokens/parentheses.ts | 11 +- .../test/common/codecs/simpleDecoder.test.ts | 18 ++-- .../editor/test/common/utils/testDecoder.ts | 4 + 7 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts index 66dc2ce4f7e..808d40ff415 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts @@ -20,7 +20,7 @@ import { LeftParenthesis, RightParenthesis } from '../../simpleCodec/tokens/pare export type TMarkdownToken = MarkdownLink | TSimpleToken; /** - * List of characters that stop a markdown link sequence. + * List of characters that are not allowed in links so stop a markdown link sequence abruptly. */ const MARKDOWN_LINK_STOP_CHARACTERS: readonly string[] = [CarriageReturn, NewLine, VerticalTab, FormFeed] .map((token) => { return token.symbol; }); diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts index 88ad1298501..b4ad6465738 100644 --- a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -12,39 +12,41 @@ import { VerticalTab } from './tokens/verticalTab.js'; import { Space } from '../simpleCodec/tokens/space.js'; import { NewLine } from '../linesCodec/tokens/newLine.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { LeftBracket, RightBracket } from './tokens/brackets.js'; import { ReadableStream } from '../../../../base/common/stream.js'; import { CarriageReturn } from '../linesCodec/tokens/carriageReturn.js'; import { LinesDecoder, TLineToken } from '../linesCodec/linesDecoder.js'; +import { LeftBracket, RightBracket, TBracket } from './tokens/brackets.js'; import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; -import { LeftParenthesis, RightParenthesis } from './tokens/parentheses.js'; +import { LeftParenthesis, RightParenthesis, TParenthesis } from './tokens/parentheses.js'; +import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/angleBrackets.js'; /** * A token type that this decoder can handle. */ -export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed | CarriageReturn | LeftBracket - | RightBracket | LeftParenthesis | RightParenthesis | Colon | Hash; +export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed + | CarriageReturn | TBracket | TAngleBracket | TParenthesis | Colon | Hash; /** * List of well-known distinct tokens that this decoder emits (excluding * the word stop characters defined below). Everything else is considered * an arbitrary "text" sequence and is emitted as a single `Word` token. */ -const WELL_KNOWN_TOKENS = [ +const WELL_KNOWN_TOKENS = Object.freeze([ Space, Tab, VerticalTab, FormFeed, LeftBracket, RightBracket, + LeftAngleBracket, RightAngleBracket, LeftParenthesis, RightParenthesis, Colon, Hash, -]; +]); /** * Characters that stop a "word" sequence. * Note! the `\r` and `\n` are excluded from the list because this decoder based on `LinesDecoder` which * already handles the `carriagereturn`/`newline` cases and emits lines that don't contain them. */ -const WORD_STOP_CHARACTERS = [ +const WORD_STOP_CHARACTERS: readonly string[] = Object.freeze([ Space.symbol, Tab.symbol, VerticalTab.symbol, FormFeed.symbol, - LeftBracket.symbol, RightBracket.symbol, LeftParenthesis.symbol, - RightParenthesis.symbol, Colon.symbol, Hash.symbol, -]; + LeftBracket.symbol, RightBracket.symbol, LeftAngleBracket.symbol, RightAngleBracket.symbol, + LeftParenthesis.symbol, RightParenthesis.symbol, Colon.symbol, Hash.symbol, +]); /** * A decoder that can decode a stream of `Line`s into a stream diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts new file mode 100644 index 00000000000..70d264bdd99 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Range } from '../../../core/range.js'; +import { Position } from '../../../core/position.js'; +import { Line } from '../../linesCodec/tokens/line.js'; + +/** + * A token that represent a `<` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class LeftAngleBracket extends BaseToken { + /** + * The underlying symbol of the token. + */ + public static readonly symbol: string = '<'; + + /** + * Return text representation of the token. + */ + public get text(): string { + return LeftAngleBracket.symbol; + } + + /** + * Create new `LeftBracket` token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): LeftAngleBracket { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new LeftAngleBracket(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `left-angle-bracket${this.range}`; + } +} + +/** + * A token that represent a `>` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class RightAngleBracket extends BaseToken { + /** + * The underlying symbol of the token. + */ + public static readonly symbol: string = '>'; + + /** + * Return text representation of the token. + */ + public get text(): string { + return RightAngleBracket.symbol; + } + + /** + * Create new `RightAngleBracket` token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): RightAngleBracket { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new RightAngleBracket(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `right-angle-bracket${this.range}`; + } +} + +/** + * General angle bracket token type. + */ +export type TAngleBracket = LeftAngleBracket | RightAngleBracket; diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts index 5c6c1e46a5d..16165cf64a7 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts @@ -36,7 +36,6 @@ export class LeftBracket extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new LeftBracket(Range.fromPositions( @@ -81,7 +80,6 @@ export class RightBracket extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new RightBracket(Range.fromPositions( @@ -97,3 +95,8 @@ export class RightBracket extends BaseToken { return `right-bracket${this.range}`; } } + +/** + * General bracket token type. + */ +export type TBracket = LeftBracket | RightBracket; diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts index b67f4e10f5c..d3509824f53 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts @@ -14,7 +14,7 @@ import { Line } from '../../linesCodec/tokens/line.js'; */ export class LeftParenthesis extends BaseToken { /** - * The underlying symbol of the `LeftParenthesis` token. + * The underlying symbol of the token. */ public static readonly symbol: string = '('; @@ -36,7 +36,6 @@ export class LeftParenthesis extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new LeftParenthesis(Range.fromPositions( @@ -59,7 +58,7 @@ export class LeftParenthesis extends BaseToken { */ export class RightParenthesis extends BaseToken { /** - * The underlying symbol of the `RightParenthesis` token. + * The underlying symbol of the token. */ public static readonly symbol: string = ')'; @@ -81,7 +80,6 @@ export class RightParenthesis extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new RightParenthesis(Range.fromPositions( @@ -97,3 +95,8 @@ export class RightParenthesis extends BaseToken { return `right-parenthesis${this.range}`; } } + +/** + * General parenthesis token type. + */ +export type TParenthesis = LeftParenthesis | RightParenthesis; diff --git a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts index b0804a2fe5f..a13eb3fb502 100644 --- a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts @@ -19,6 +19,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { SimpleDecoder, TSimpleToken } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; +import { LeftAngleBracket, RightAngleBracket } from '../../../common/codecs/simpleCodec/tokens/angleBrackets.js'; /** * A reusable test utility that asserts that a `SimpleDecoder` instance @@ -60,7 +61,7 @@ suite('SimpleDecoder', () => { ); await test.run( - ' hello world\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=]\f \n\t\t🤗❤ \t\n hey\vthere\r\n\r\n', + ' hello world\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=]\f \n\t\t🤗❤ \t\n hey\vthere\r\n\r\n', [ // first line new Space(new Range(1, 1, 1, 2)), @@ -99,11 +100,16 @@ suite('SimpleDecoder', () => { new NewLine(new Range(4, 30, 4, 31)), // fifth line new Tab(new Range(5, 1, 5, 2)), - new Tab(new Range(5, 2, 5, 3)), - new Word(new Range(5, 3, 5, 6), '🤗❤'), - new Space(new Range(5, 6, 5, 7)), - new Tab(new Range(5, 7, 5, 8)), - new NewLine(new Range(5, 8, 5, 9)), + new LeftAngleBracket(new Range(5, 2, 5, 3)), + new Word(new Range(5, 3, 5, 5), 'hi'), + new Space(new Range(5, 5, 5, 6)), + new Word(new Range(5, 6, 5, 8), '👋'), + new RightAngleBracket(new Range(5, 8, 5, 9)), + new Tab(new Range(5, 9, 5, 10)), + new Word(new Range(5, 10, 5, 13), '🤗❤'), + new Space(new Range(5, 13, 5, 14)), + new Tab(new Range(5, 14, 5, 15)), + new NewLine(new Range(5, 15, 5, 16)), // sixth line new Space(new Range(6, 1, 6, 2)), new Word(new Range(6, 2, 6, 5), 'hey'), diff --git a/src/vs/editor/test/common/utils/testDecoder.ts b/src/vs/editor/test/common/utils/testDecoder.ts index a998a64e2cc..4f29a2f1814 100644 --- a/src/vs/editor/test/common/utils/testDecoder.ts +++ b/src/vs/editor/test/common/utils/testDecoder.ts @@ -129,6 +129,8 @@ export class TestDecoder> extends receivedTokens.push(token); }); + this.decoder.start(); + // in this case we also test the `settled` promise of the decoder await this.decoder.settled; @@ -158,6 +160,8 @@ export class TestDecoder> extends // add the tokens consume method to the error message so we // would know which method of consuming the tokens failed exactly error.message = `[${tokensConsumeMethod}] ${error.message}`; + + throw error; } } From 6af7fd5cc3980bcf3e68705d30f539825c1ffe42 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Tue, 25 Feb 2025 14:52:49 -0800 Subject: [PATCH 058/255] support dash token in simple decoder --- .../codecs/simpleCodec/simpleDecoder.ts | 17 ++++-- .../common/codecs/simpleCodec/tokens/colon.ts | 5 +- .../common/codecs/simpleCodec/tokens/dash.ts | 53 +++++++++++++++++++ .../common/codecs/simpleCodec/tokens/hash.ts | 5 +- .../common/codecs/simpleCodec/tokens/tab.ts | 5 +- .../test/common/codecs/simpleDecoder.test.ts | 20 ++++--- 6 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts index b4ad6465738..54089bfaa9e 100644 --- a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Hash } from './tokens/hash.js'; +import { Dash } from './tokens/dash.js'; import { Colon } from './tokens/colon.js'; import { FormFeed } from './tokens/formFeed.js'; import { Tab } from '../simpleCodec/tokens/tab.js'; @@ -20,11 +21,17 @@ import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; import { LeftParenthesis, RightParenthesis, TParenthesis } from './tokens/parentheses.js'; import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/angleBrackets.js'; +/** + * TODO: @legomushroom - list + * - add `!` token support + * - add `comment` token support in the markdown decoder + */ + /** * A token type that this decoder can handle. */ export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed - | CarriageReturn | TBracket | TAngleBracket | TParenthesis | Colon | Hash; + | CarriageReturn | TBracket | TAngleBracket | TParenthesis | Colon | Hash | Dash; /** * List of well-known distinct tokens that this decoder emits (excluding @@ -32,9 +39,9 @@ export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed * an arbitrary "text" sequence and is emitted as a single `Word` token. */ const WELL_KNOWN_TOKENS = Object.freeze([ - Space, Tab, VerticalTab, FormFeed, LeftBracket, RightBracket, - LeftAngleBracket, RightAngleBracket, - LeftParenthesis, RightParenthesis, Colon, Hash, + Space, Tab, VerticalTab, FormFeed, + LeftBracket, RightBracket, LeftAngleBracket, RightAngleBracket, + LeftParenthesis, RightParenthesis, Colon, Hash, Dash, ]); /** @@ -45,7 +52,7 @@ const WELL_KNOWN_TOKENS = Object.freeze([ const WORD_STOP_CHARACTERS: readonly string[] = Object.freeze([ Space.symbol, Tab.symbol, VerticalTab.symbol, FormFeed.symbol, LeftBracket.symbol, RightBracket.symbol, LeftAngleBracket.symbol, RightAngleBracket.symbol, - LeftParenthesis.symbol, RightParenthesis.symbol, Colon.symbol, Hash.symbol, + LeftParenthesis.symbol, RightParenthesis.symbol, Colon.symbol, Hash.symbol, Dash.symbol, ]); /** diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts index 2c4b89d9ce5..76e9f0cd2b4 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts @@ -14,7 +14,7 @@ import { Line } from '../../linesCodec/tokens/line.js'; */ export class Colon extends BaseToken { /** - * The underlying symbol of the `LeftBracket` token. + * The underlying symbol of the token. */ public static readonly symbol: string = ':'; @@ -26,7 +26,7 @@ export class Colon extends BaseToken { } /** - * Create new `LeftBracket` token with range inside + * Create new token with range inside * the given `Line` at the given `column number`. */ public static newOnLine( @@ -36,7 +36,6 @@ export class Colon extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new Colon(Range.fromPositions( diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts new file mode 100644 index 00000000000..ebc0179eeef --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Range } from '../../../core/range.js'; +import { Position } from '../../../core/position.js'; +import { Line } from '../../linesCodec/tokens/line.js'; + +/** + * A token that represent a `-` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Dash extends BaseToken { + /** + * The underlying symbol of the token. + */ + public static readonly symbol: string = '-'; + + /** + * Return text representation of the token. + */ + public get text(): string { + return Dash.symbol; + } + + /** + * Create new token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): Dash { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new Dash(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `dash${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts index 372e0b2ee3d..ddca12a2279 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts @@ -14,7 +14,7 @@ import { Line } from '../../linesCodec/tokens/line.js'; */ export class Hash extends BaseToken { /** - * The underlying symbol of the `LeftBracket` token. + * The underlying symbol of the token. */ public static readonly symbol: string = '#'; @@ -26,7 +26,7 @@ export class Hash extends BaseToken { } /** - * Create new `LeftBracket` token with range inside + * Create new token with range inside * the given `Line` at the given `column number`. */ public static newOnLine( @@ -36,7 +36,6 @@ export class Hash extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new Hash(Range.fromPositions( diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts index 7f511c2626b..c0d775ff8cd 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts @@ -14,7 +14,7 @@ import { Position } from '../../../../../editor/common/core/position.js'; */ export class Tab extends BaseToken { /** - * The underlying symbol of the `Tab` token. + * The underlying symbol of the token. */ public static readonly symbol: string = '\t'; @@ -26,7 +26,7 @@ export class Tab extends BaseToken { } /** - * Create new `Tab` token with range inside + * Create new token with range inside * the given `Line` at the given `column number`. */ public static newOnLine( @@ -36,7 +36,6 @@ export class Tab extends BaseToken { const { range } = line; const startPosition = new Position(range.startLineNumber, atColumnNumber); - // the tab token length is 1, hence `+ 1` const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); return new Tab(Range.fromPositions( diff --git a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts index a13eb3fb502..7ebc25e160f 100644 --- a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts @@ -10,6 +10,7 @@ import { newWriteableStream } from '../../../../base/common/stream.js'; import { Tab } from '../../../common/codecs/simpleCodec/tokens/tab.js'; import { Hash } from '../../../common/codecs/simpleCodec/tokens/hash.js'; import { Word } from '../../../common/codecs/simpleCodec/tokens/word.js'; +import { Dash } from '../../../common/codecs/simpleCodec/tokens/dash.js'; import { Space } from '../../../common/codecs/simpleCodec/tokens/space.js'; import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js'; @@ -61,7 +62,7 @@ suite('SimpleDecoder', () => { ); await test.run( - ' hello world\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=]\f \n\t\t🤗❤ \t\n hey\vthere\r\n\r\n', + ' hello world\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=-]\f \n\t\t🤗❤ \t\n hey\v-\tthere\r\n\r\n', [ // first line new Space(new Range(1, 1, 1, 2)), @@ -93,11 +94,12 @@ suite('SimpleDecoder', () => { new Word(new Range(4, 13, 4, 13 + 2), '!@'), new Hash(new Range(4, 15, 4, 16)), new Word(new Range(4, 16, 4, 16 + 10), '$%^🦄&*_+='), - new RightBracket(new Range(4, 26, 4, 27)), - new FormFeed(new Range(4, 27, 4, 28)), - new Space(new Range(4, 28, 4, 29)), + new Dash(new Range(4, 26, 4, 27)), + new RightBracket(new Range(4, 27, 4, 28)), + new FormFeed(new Range(4, 28, 4, 29)), new Space(new Range(4, 29, 4, 30)), - new NewLine(new Range(4, 30, 4, 31)), + new Space(new Range(4, 30, 4, 31)), + new NewLine(new Range(4, 31, 4, 32)), // fifth line new Tab(new Range(5, 1, 5, 2)), new LeftAngleBracket(new Range(5, 2, 5, 3)), @@ -114,9 +116,11 @@ suite('SimpleDecoder', () => { new Space(new Range(6, 1, 6, 2)), new Word(new Range(6, 2, 6, 5), 'hey'), new VerticalTab(new Range(6, 5, 6, 6)), - new Word(new Range(6, 6, 6, 11), 'there'), - new CarriageReturn(new Range(6, 11, 6, 12)), - new NewLine(new Range(6, 12, 6, 13)), + new Dash(new Range(6, 6, 6, 7)), + new Tab(new Range(6, 7, 6, 8)), + new Word(new Range(6, 8, 6, 13), 'there'), + new CarriageReturn(new Range(6, 13, 6, 14)), + new NewLine(new Range(6, 14, 6, 15)), // seventh line new CarriageReturn(new Range(7, 1, 7, 2)), new NewLine(new Range(7, 2, 7, 3)), From e767fe120b661f89c556e4eb78d425c14ff1fbf9 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Tue, 25 Feb 2025 16:08:47 -0800 Subject: [PATCH 059/255] add `!` token support --- .../codecs/simpleCodec/simpleDecoder.ts | 10 ++-- .../simpleCodec/tokens/exclamationMark.ts | 53 +++++++++++++++++++ .../test/common/codecs/simpleDecoder.test.ts | 9 ++-- 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts index 54089bfaa9e..80186a14cc5 100644 --- a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -13,6 +13,7 @@ import { VerticalTab } from './tokens/verticalTab.js'; import { Space } from '../simpleCodec/tokens/space.js'; import { NewLine } from '../linesCodec/tokens/newLine.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { ExclamationMark } from './tokens/exclamationMark.js'; import { ReadableStream } from '../../../../base/common/stream.js'; import { CarriageReturn } from '../linesCodec/tokens/carriageReturn.js'; import { LinesDecoder, TLineToken } from '../linesCodec/linesDecoder.js'; @@ -23,7 +24,6 @@ import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/ang /** * TODO: @legomushroom - list - * - add `!` token support * - add `comment` token support in the markdown decoder */ @@ -31,7 +31,8 @@ import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/ang * A token type that this decoder can handle. */ export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed - | CarriageReturn | TBracket | TAngleBracket | TParenthesis | Colon | Hash | Dash; + | CarriageReturn | TBracket | TAngleBracket | TParenthesis + | Colon | Hash | Dash | ExclamationMark; /** * List of well-known distinct tokens that this decoder emits (excluding @@ -41,7 +42,7 @@ export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed const WELL_KNOWN_TOKENS = Object.freeze([ Space, Tab, VerticalTab, FormFeed, LeftBracket, RightBracket, LeftAngleBracket, RightAngleBracket, - LeftParenthesis, RightParenthesis, Colon, Hash, Dash, + LeftParenthesis, RightParenthesis, Colon, Hash, Dash, ExclamationMark, ]); /** @@ -52,7 +53,8 @@ const WELL_KNOWN_TOKENS = Object.freeze([ const WORD_STOP_CHARACTERS: readonly string[] = Object.freeze([ Space.symbol, Tab.symbol, VerticalTab.symbol, FormFeed.symbol, LeftBracket.symbol, RightBracket.symbol, LeftAngleBracket.symbol, RightAngleBracket.symbol, - LeftParenthesis.symbol, RightParenthesis.symbol, Colon.symbol, Hash.symbol, Dash.symbol, + LeftParenthesis.symbol, RightParenthesis.symbol, + Colon.symbol, Hash.symbol, Dash.symbol, ExclamationMark.symbol, ]); /** diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts new file mode 100644 index 00000000000..025edf70291 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Range } from '../../../core/range.js'; +import { Position } from '../../../core/position.js'; +import { Line } from '../../linesCodec/tokens/line.js'; + +/** + * A token that represent a `!` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class ExclamationMark extends BaseToken { + /** + * The underlying symbol of the token. + */ + public static readonly symbol: string = '!'; + + /** + * Return text representation of the token. + */ + public get text(): string { + return ExclamationMark.symbol; + } + + /** + * Create new token with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + ): ExclamationMark { + const { range } = line; + + const startPosition = new Position(range.startLineNumber, atColumnNumber); + const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); + + return new ExclamationMark(Range.fromPositions( + startPosition, + endPosition, + )); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `exclamation-mark${this.range}`; + } +} diff --git a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts index 7ebc25e160f..16ae699708c 100644 --- a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts @@ -21,6 +21,7 @@ import { SimpleDecoder, TSimpleToken } from '../../../common/codecs/simpleCodec/ import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; import { LeftAngleBracket, RightAngleBracket } from '../../../common/codecs/simpleCodec/tokens/angleBrackets.js'; +import { ExclamationMark } from '../../../common/codecs/simpleCodec/tokens/exclamationMark.js'; /** * A reusable test utility that asserts that a `SimpleDecoder` instance @@ -62,14 +63,15 @@ suite('SimpleDecoder', () => { ); await test.run( - ' hello world\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=-]\f \n\t\t🤗❤ \t\n hey\v-\tthere\r\n\r\n', + ' hello world!\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=-]\f \n\t\t🤗❤ \t\n hey\v-\tthere\r\n\r\n', [ // first line new Space(new Range(1, 1, 1, 2)), new Word(new Range(1, 2, 1, 7), 'hello'), new Space(new Range(1, 7, 1, 8)), new Word(new Range(1, 8, 1, 13), 'world'), - new NewLine(new Range(1, 13, 1, 14)), + new ExclamationMark(new Range(1, 13, 1, 14)), + new NewLine(new Range(1, 14, 1, 15)), // second line new Word(new Range(2, 1, 2, 4), 'how'), new Space(new Range(2, 4, 2, 5)), @@ -91,7 +93,8 @@ suite('SimpleDecoder', () => { new Space(new Range(4, 10, 4, 11)), new Space(new Range(4, 11, 4, 12)), new LeftBracket(new Range(4, 12, 4, 13)), - new Word(new Range(4, 13, 4, 13 + 2), '!@'), + new ExclamationMark(new Range(4, 13, 4, 14)), + new Word(new Range(4, 14, 4, 15), '@'), new Hash(new Range(4, 15, 4, 16)), new Word(new Range(4, 16, 4, 16 + 10), '$%^🦄&*_+='), new Dash(new Range(4, 26, 4, 27)), From a4561a32dccdca7ad0afac66e722d8fc28418662 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Tue, 25 Feb 2025 16:15:36 -0800 Subject: [PATCH 060/255] update test cases for the markdown decoder to incorporate new token types --- .../codecs/simpleCodec/simpleDecoder.ts | 5 ---- .../common/codecs/markdownDecoder.test.ts | 30 +++++++++---------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts index 80186a14cc5..c32542f28da 100644 --- a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -22,11 +22,6 @@ import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; import { LeftParenthesis, RightParenthesis, TParenthesis } from './tokens/parentheses.js'; import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/angleBrackets.js'; -/** - * TODO: @legomushroom - list - * - add `comment` token support in the markdown decoder - */ - /** * A token type that this decoder can handle. */ diff --git a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts index bff4b428ae1..bd720971d79 100644 --- a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts @@ -61,7 +61,7 @@ suite('MarkdownDecoder', () => { ); await test.run( - ' hello world\nhow are\t you [caption text](./some/file/path/refer🎨nce.md)?\v\n\n[(example)](another/path/with[-and-]-chars/folder)\t \n\t[#file:something.txt](/absolute/path/to/something.txt)', + ' hello world\nhow are\t you [caption text](./some/file/path/refer🎨nce.md)?\v\n\n[(example!)](another/path/with[-and-]-chars/folder)\t \n\t[#file:something.txt](/absolute/path/to/something.txt)', [ // first line new Space(new Range(1, 1, 1, 2)), @@ -84,10 +84,10 @@ suite('MarkdownDecoder', () => { // third line new NewLine(new Range(3, 1, 3, 2)), // fourth line - new MarkdownLink(4, 1, '[(example)]', '(another/path/with[-and-]-chars/folder)'), - new Tab(new Range(4, 51, 4, 52)), - new Space(new Range(4, 52, 4, 53)), - new NewLine(new Range(4, 53, 4, 54)), + new MarkdownLink(4, 1, '[(example!)]', '(another/path/with[-and-]-chars/folder)'), + new Tab(new Range(4, 52, 4, 53)), + new Space(new Range(4, 53, 4, 54)), + new NewLine(new Range(4, 54, 4, 55)), // fifth line new Tab(new Range(5, 1, 5, 2)), new MarkdownLink(5, 2, '[#file:something.txt]', '(/absolute/path/to/something.txt)'), @@ -103,15 +103,15 @@ suite('MarkdownDecoder', () => { const inputLines = [ // tests that the link caption contain a chat prompt `#file:` reference, while // the file path can contain other `graphical characters` - '\v\t[#file:./another/path/to/file.txt](./real/filepath/file◆name.md)', + '\v\t[#file:./another/path/to/file.txt](./real/file!path/file◆name.md)', // tests that the link file path contain a chat prompt `#file:` reference, // `spaces`, `emojies`, and other `graphical characters` ' [reference ∘ label](/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)', // tests that link caption and file path can contain `parentheses`, `spaces`, and // `emojies` - '\f[!(hello)!](./w(())rld/nice-🦚-filen(a)me.git))\n\t', + '\f[!(hello)!](./w(())rld/nice-🦚-filen(a).git))\n\t', // tests that the link caption can be empty, while the file path can contain `square brackets` - '[](./s[]me/pa[h!) ', + '[](./s[]me/pa[h!) ', ]; await test.run( @@ -120,23 +120,23 @@ suite('MarkdownDecoder', () => { // `1st` line new VerticalTab(new Range(1, 1, 1, 2)), new Tab(new Range(1, 2, 1, 3)), - new MarkdownLink(1, 3, '[#file:./another/path/to/file.txt]', '(./real/filepath/file◆name.md)'), - new NewLine(new Range(1, 67, 1, 68)), + new MarkdownLink(1, 3, '[#file:./another/path/to/file.txt]', '(./real/file!path/file◆name.md)'), + new NewLine(new Range(1, 68, 1, 69)), // `2nd` line new Space(new Range(2, 1, 2, 2)), new MarkdownLink(2, 2, '[reference ∘ label]', '(/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)'), new NewLine(new Range(2, 67, 2, 68)), // `3rd` line new FormFeed(new Range(3, 1, 3, 2)), - new MarkdownLink(3, 2, '[!(hello)!]', '(./w(())rld/nice-🦚-filen(a)me.git)'), - new RightParenthesis(new Range(3, 48, 3, 49)), - new NewLine(new Range(3, 49, 3, 50)), + new MarkdownLink(3, 2, '[!(hello)!]', '(./w(())rld/nice-🦚-filen(a).git)'), + new RightParenthesis(new Range(3, 50, 3, 51)), + new NewLine(new Range(3, 51, 3, 52)), // `4th` line new Tab(new Range(4, 1, 4, 2)), new NewLine(new Range(4, 2, 4, 3)), // `5th` line - new MarkdownLink(5, 1, '[]', '(./s[]me/pa[h!)'), - new Space(new Range(5, 18, 5, 19)), + new MarkdownLink(5, 1, '[]', '(./s[]me/pa[h!)'), + new Space(new Range(5, 24, 5, 25)), ], ); }); From a635c118deef2cf03f33e2564750ff4f4d939d06 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 12 Mar 2025 11:28:49 -0700 Subject: [PATCH 061/255] mcp: warn on invalid properties, automatically migrate any 'mcpServers' (#243378) This keeps our 'servers' key but fixes up if people paste in other editors configuration into us --- src/vs/platform/mcp/common/mcpPlatformTypes.ts | 2 ++ .../contrib/mcp/common/discovery/configMcpDiscovery.ts | 9 ++++++++- src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/mcp/common/mcpPlatformTypes.ts b/src/vs/platform/mcp/common/mcpPlatformTypes.ts index 877e199ae06..bee43d9672b 100644 --- a/src/vs/platform/mcp/common/mcpPlatformTypes.ts +++ b/src/vs/platform/mcp/common/mcpPlatformTypes.ts @@ -5,6 +5,8 @@ export interface IMcpConfiguration { inputs: unknown[]; + /** @deprecated Only for rough cross-compat with other formats */ + mcpServers?: Record; servers: Record; } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts index 69c5df92371..80d83720622 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts @@ -91,7 +91,14 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { for (const src of this.configSources) { const collectionId = `mcp.config.${src.key}`; - const value = configurationKey[src.key]; + let value = configurationKey[src.key]; + + // If we see there are MCP servers, migrate them automatically + if (value?.mcpServers) { + value = { ...value, servers: { ...value.servers, ...value.mcpServers }, mcpServers: undefined }; + this._configurationService.updateValue(mcpConfigurationSection, value, {}, src.target, { donotNotifyError: true }); + } + const nextDefinitions = Object.entries(value?.servers || {}).map(([name, value]): McpServerDefinition => ({ id: `${collectionId}.${name}`, label: name, diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index b17da0d3313..25e58fa9485 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -32,6 +32,7 @@ export const mcpServerSchema: IJSONSchema = { title: localize('app.mcp.json.title', "Model Context Protocol Servers"), allowTrailingCommas: true, allowComments: true, + additionalProperties: false, properties: { servers: { examples: [mcpSchemaExampleServers], From df6adbae7c691093512fa71e40eb362a560cdb17 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:42:19 -0700 Subject: [PATCH 062/255] Onboard onto 1ES template's BinSkim (#243374) * Exclude Windows setup files from BinSkim * Debug: Scan everything but with TSA off * Narrow analyzeTargetGlob * Scan node and DLL files as well * Try excluding BinSkim from CustomSDL * Re-enable TSA * Clean up * Rename CustomSDL stage and job --- build/azure-pipelines/cli/cli-compile.yml | 16 ---------- build/azure-pipelines/product-build.yml | 6 ++-- .../win32/product-build-win32.yml | 8 ++--- .../azure-pipelines/win32/sdl-scan-win32.yml | 31 +------------------ 4 files changed, 9 insertions(+), 52 deletions(-) diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index 5982e42c5dd..a5d8bdc1a2c 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -119,22 +119,6 @@ steps: ArtifactServices.Symbol.UseAAD: false displayName: Publish Symbols - - task: CopyFiles@2 - inputs: - SourceFolder: $(Build.SourcesDirectory)/cli/target/${{ parameters.VSCODE_CLI_TARGET }}/release - Contents: 'code.*' - TargetFolder: $(Agent.TempDirectory)/binskim-cli - displayName: Copy files for BinSkim - - - task: BinSkim@4 - inputs: - InputType: Basic - Function: analyze - TargetPattern: guardianGlob - AnalyzeTargetGlob: $(Agent.TempDirectory)/binskim-cli/*.* - AnalyzeSymPath: $(Agent.TempDirectory)/binskim-cli - displayName: Run BinSkim - - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 4c39fe5134d..c3e1342359a 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -169,6 +169,8 @@ extends: tsa: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json + binskim: + analyzeTargetGlob: '+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.exe;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.node;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.dll;-:file|**/VSCodeSetup*.exe' codeql: runSourceLanguagesInSourceAnalysis: true compiled: @@ -316,13 +318,13 @@ extends: VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false)) }}: - - stage: CustomSDL + - stage: APIScan dependsOn: [] pool: name: 1es-windows-2019-x64 os: windows jobs: - - job: WindowsSDL + - job: WindowsAPIScan steps: - template: build/azure-pipelines/win32/sdl-scan-win32.yml@self parameters: diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index ed7f10048ca..f7389943dc7 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -145,7 +145,7 @@ steps: exec { npm run gulp "vscode-win32-$(VSCODE_ARCH)-min-ci" } exec { npm run gulp "vscode-win32-$(VSCODE_ARCH)-inno-updater" } echo "##vso[task.setvariable variable=BUILT_CLIENT]true" - echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(agent.builddirectory)/VSCode-win32-$(VSCODE_ARCH)" + echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH)" env: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Build client @@ -156,7 +156,7 @@ steps: exec { npm run gulp "vscode-reh-win32-$(VSCODE_ARCH)-min-ci" } mv ..\vscode-reh-win32-$(VSCODE_ARCH) ..\vscode-server-win32-$(VSCODE_ARCH) # TODO@joaomoreno echo "##vso[task.setvariable variable=BUILT_SERVER]true" - echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(agent.builddirectory)/vscode-server-win32-$(VSCODE_ARCH)" + echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)" env: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Build server @@ -196,10 +196,10 @@ steps: $ErrorActionPreference = "Stop" $ArtifactName = (gci -Path "$(Build.ArtifactStagingDirectory)/cli" | Select-Object -last 1).FullName Expand-Archive -Path $ArtifactName -DestinationPath "$(Build.ArtifactStagingDirectory)/cli" - $AppProductJson = Get-Content -Raw -Path "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)\resources\app\product.json" | ConvertFrom-Json + $AppProductJson = Get-Content -Raw -Path "$(Agent.BuildDirectory)\VSCode-win32-$(VSCODE_ARCH)\resources\app\product.json" | ConvertFrom-Json $CliAppName = $AppProductJson.tunnelApplicationName $AppName = $AppProductJson.applicationName - Move-Item -Path "$(Build.ArtifactStagingDirectory)/cli/$AppName.exe" -Destination "$(agent.builddirectory)/VSCode-win32-$(VSCODE_ARCH)/bin/$CliAppName.exe" + Move-Item -Path "$(Build.ArtifactStagingDirectory)/cli/$AppName.exe" -Destination "$(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH)/bin/$CliAppName.exe" displayName: Move VS Code CLI - task: UseDotNet@2 diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index def3cb53dfc..bf6819a4b47 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -102,13 +102,6 @@ steps: - powershell: npm run compile displayName: Compile - - powershell: | - Get-ChildItem '$(Build.SourcesDirectory)' -Recurse -Filter "*.exe" - Get-ChildItem '$(Build.SourcesDirectory)' -Recurse -Filter "*.dll" - Get-ChildItem '$(Build.SourcesDirectory)' -Recurse -Filter "*.node" - Get-ChildItem '$(Build.SourcesDirectory)' -Recurse -Filter "*.pdb" - displayName: List files - - powershell: npm run gulp "vscode-symbols-win32-${{ parameters.VSCODE_ARCH }}" env: GITHUB_TOKEN: "$(github-distro-mixin-password)" @@ -119,16 +112,7 @@ steps: Get-ChildItem '$(Agent.BuildDirectory)\scanbin' -Recurse -Filter "*.dll" Get-ChildItem '$(Agent.BuildDirectory)\scanbin' -Recurse -Filter "*.node" Get-ChildItem '$(Agent.BuildDirectory)\scanbin' -Recurse -Filter "*.pdb" - displayName: List files again - - - task: BinSkim@4 - inputs: - InputType: "Basic" - Function: "analyze" - TargetPattern: "guardianGlob" - AnalyzeIgnorePdbLoadError: true - AnalyzeTargetGlob: '$(Agent.BuildDirectory)\scanbin\**.dll;$(Agent.BuildDirectory)\scanbin\**.exe;$(Agent.BuildDirectory)\scanbin\**.node' - AnalyzeLocalSymbolDirectories: '$(Agent.BuildDirectory)\scanbin\VSCode-win32-${{ parameters.VSCODE_ARCH }}\pdb' + displayName: List files - task: CopyFiles@2 displayName: 'Collect Symbols for API Scan' @@ -139,19 +123,6 @@ steps: flattenFolders: true condition: succeeded() - - task: PublishSymbols@2 - inputs: - IndexSources: false - SymbolsFolder: '$(Agent.BuildDirectory)\symbols' - SearchPattern: '**\*.pdb' - SymbolServerType: TeamServices - SymbolsProduct: 'code' - ArtifactServices.Symbol.AccountName: microsoft - ArtifactServices.Symbol.PAT: $(System.AccessToken) - ArtifactServices.Symbol.UseAAD: false - displayName: Publish Symbols - condition: succeeded() - - task: APIScan@2 inputs: softwareFolder: $(Agent.BuildDirectory)\scanbin From 330361c639fb1cf0e1d733fe0a4acfa3837af2ac Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Wed, 12 Mar 2025 12:19:49 -0700 Subject: [PATCH 063/255] Implement chat transfer service and contribution for workspace trust management (#243381) --- .../chat/browser/actions/chatTransfer.ts | 19 ++++++++++ .../contrib/chat/browser/chat.contribution.ts | 4 +++ .../chat/common/chatTransferService.ts | 36 +++++++++++++++++++ .../browser/workspace.contribution.ts | 8 ----- .../workspaces/common/workspaceTrust.ts | 10 ------ 5 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatTransferService.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts new file mode 100644 index 00000000000..52bc3680170 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IChatTransferService } from '../../common/chatTransferService.js'; + +export class ChatTransferContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.chatTransfer'; + + constructor( + @IChatTransferService chatTransferService: IChatTransferService, + ) { + super(); + chatTransferService.checkAndSetWorkspaceTrust(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 005c3152255..da8e0c2ff4a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -102,6 +102,8 @@ import { Event } from '../../../../base/common/event.js'; import { ChatEditingNotebookFileSystemProviderContrib } from './chatEditing/notebook/chatEditingNotebookFileSystemProvider.js'; import { mcpConfigurationSection, mcpSchemaExampleServers } from '../../mcp/common/mcpConfiguration.js'; import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; +import { ChatTransferService, IChatTransferService } from '../common/chatTransferService.js'; +import { ChatTransferContribution } from './actions/chatTransfer.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -519,6 +521,7 @@ registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSetting registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); registerChatActions(); registerChatCopyActions(); @@ -559,5 +562,6 @@ registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, Instant registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); registerSingleton(IChatEntitlementService, ChatEntitlementService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); +registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/common/chatTransferService.ts b/src/vs/workbench/contrib/chat/common/chatTransferService.ts new file mode 100644 index 00000000000..22a2eb31aa8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatTransferService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { isChatTransferredWorkspace, areWorkspaceFoldersEmpty } from '../../../services/workspaces/common/workspaceUtils.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IChatTransferService = createDecorator('chatTransferService'); + +export interface IChatTransferService { + readonly _serviceBrand: undefined; + + checkAndSetWorkspaceTrust(): Promise; +} + +export class ChatTransferService implements IChatTransferService { + _serviceBrand: undefined; + + constructor( + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IStorageService private readonly storageService: IStorageService, + @IFileService private readonly fileService: IFileService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService + ) { } + + async checkAndSetWorkspaceTrust(): Promise { + const workspace = this.workspaceService.getWorkspace(); + if (isChatTransferredWorkspace(workspace, this.storageService) && await areWorkspaceFoldersEmpty(workspace, this.fileService)) { + await this.workspaceTrustManagementService.setWorkspaceTrust(true); + } + } +} diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index c973f5f191f..c512b648529 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -50,7 +50,6 @@ import { basename, dirname as uriDirname } from '../../../../base/common/resourc import { URI } from '../../../../base/common/uri.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { areWorkspaceFoldersEmpty, isChatTransferredWorkspace } from '../../../services/workspaces/common/workspaceUtils.js'; const BANNER_RESTRICTED_MODE = 'workbench.banner.restrictedMode'; const STARTUP_PROMPT_SHOWN_KEY = 'workspace.trust.startupPrompt.shown'; @@ -441,13 +440,6 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon return; } - // Don't show modal prompt for empty folders transferred from chat - const workspace = this.workspaceContextService.getWorkspace(); - if (isChatTransferredWorkspace(workspace, this.storageService) && await areWorkspaceFoldersEmpty(workspace, this.fileService)) { - this.updateWorkbenchIndicators(false); - return; - } - if (this.startupPromptSetting === 'never') { this.updateWorkbenchIndicators(false); return; diff --git a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts index 90e1141c192..e11ae3c24e2 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts @@ -24,7 +24,6 @@ import { isEqualAuthority } from '../../../../base/common/resources.js'; import { isWeb } from '../../../../base/common/platform.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { promiseWithResolvers } from '../../../../base/common/async.js'; -import { areWorkspaceFoldersEmpty, isChatTransferredWorkspace } from './workspaceUtils.js'; export const WORKSPACE_TRUST_ENABLED = 'security.workspace.trust.enabled'; export const WORKSPACE_TRUST_STARTUP_PROMPT = 'security.workspace.trust.startupPrompt'; @@ -334,15 +333,6 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork if (trusted === undefined) { await this.resolveCanonicalUris(); trusted = this.calculateWorkspaceTrust(); - - const workspace = this.workspaceService.getWorkspace(); - if (!trusted && - isChatTransferredWorkspace(workspace, this.storageService) && - await areWorkspaceFoldersEmpty(workspace, this.fileService)) { - - // Trust empty folders transferred from chat - trusted = true; - } } if (this.isWorkspaceTrusted() === trusted) { return; } From c67dcc317d410f57c4ffed55243395210b74cd8d Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Wed, 26 Feb 2025 11:39:43 -0800 Subject: [PATCH 064/255] II of markdown comment parser, add base @assertNotConsumed decorator --- extensions/git/src/decorators.ts | 2 +- .../markdownCodec/parsers/markdownComment.ts | 172 ++++++++++++++++++ .../markdownCodec/parsers/markdownLink.ts | 5 - .../markdownCodec/tokens/markdownComment.ts | 86 +++++++++ .../common/codecs/simpleCodec/parserBase.ts | 50 +++++ 5 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts create mode 100644 src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts diff --git a/extensions/git/src/decorators.ts b/extensions/git/src/decorators.ts index b1a25d4fd91..f89ff2327e9 100644 --- a/extensions/git/src/decorators.ts +++ b/extensions/git/src/decorators.ts @@ -98,4 +98,4 @@ export function debounce(delay: number): Function { this[timerKey] = setTimeout(() => fn.apply(this, args), delay); }; }); -} \ No newline at end of file +} diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts new file mode 100644 index 00000000000..87e218d8e6d --- /dev/null +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../core/range.js'; +import { Dash } from '../../simpleCodec/tokens/dash.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { MarkdownComment } from '../tokens/markdownComment.js'; +import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; +import { ExclamationMark } from '../../simpleCodec/tokens/exclamationMark.js'; +import { LeftAngleBracket, RightAngleBracket } from '../../simpleCodec/tokens/angleBrackets.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; + +/** + * The parser responsible for parsing the ``. If it does, + * then the parser transitions to the {@link MarkdownComment} token. + */ +export class MarkdownCommentStart extends ParserBase { + constructor(tokens: [LeftAngleBracket, ExclamationMark, Dash, Dash]) { + super(tokens); + } + + @assertNotConsumed + public accept(token: TSimpleToken): TAcceptTokenResult { + // if received `>` while current token sequence ends with `--`, + // then this is the end of the comment sequence + if (token instanceof RightAngleBracket && this.endsWithDashes) { + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this.asMarkdownComment(), + wasTokenConsumed: true, + }; + } + + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + /** + * Convert the current token sequence into a {@link MarkdownComment} token. + * + * Note! that this method marks the current parser object as "consumend" + * hence it should not be used after this method is called. + */ + public asMarkdownComment(): MarkdownComment { + this.isConsumed = true; + + return new MarkdownComment( + this.range, + Object.freeze(this.currentTokens), + ); + } + + /** + * Get range of current token sequence. + */ + private get range(): Range { + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + const range = new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ); + + return range; + } + + /** + * Whether the current token sequence ends with two dashes. + */ + private get endsWithDashes(): boolean { + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + if (!(lastToken instanceof Dash)) { + return false; + } + + const secondLastToken = this.currentTokens[this.currentTokens.length - 2]; + if (!(secondLastToken instanceof Dash)) { + return false; + } + + return true; + } +} diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts index 808d40ff415..e8163286fd2 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts @@ -14,11 +14,6 @@ import { LeftBracket, RightBracket } from '../../simpleCodec/tokens/brackets.js' import { ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; import { LeftParenthesis, RightParenthesis } from '../../simpleCodec/tokens/parentheses.js'; -/** - * Tokens handled by this decoder. - */ -export type TMarkdownToken = MarkdownLink | TSimpleToken; - /** * List of characters that are not allowed in links so stop a markdown link sequence abruptly. */ diff --git a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts new file mode 100644 index 00000000000..661a1be7076 --- /dev/null +++ b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { Range } from '../../../core/range.js'; +import { MarkdownToken } from './markdownToken.js'; +import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; + +/** + * A token that represent a `markdown comment` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class MarkdownComment extends MarkdownToken { + constructor( + range: Range, + public readonly tokens: readonly TSimpleToken[], + ) { + // TODO: @lego - validate tokens + super(range); + // assert( + // !isNaN(lineNumber), + // `The line number must not be a NaN.`, + // ); + + // assert( + // lineNumber > 0, + // `The line number must be >= 1, got "${lineNumber}".`, + // ); + + // assert( + // columnNumber > 0, + // `The column number must be >= 1, got "${columnNumber}".`, + // ); + + // assert( + // caption[0] === '[' && caption[caption.length - 1] === ']', + // `The caption must be enclosed in square brackets, got "${caption}".`, + // ); + + // assert( + // reference[0] === '(' && reference[reference.length - 1] === ')', + // `The reference must be enclosed in parentheses, got "${reference}".`, + // ); + + // super( + // new Range( + // lineNumber, + // columnNumber, + // lineNumber, + // columnNumber + caption.length + reference.length, + // ), + // ); + + // // set up the `isURL` flag based on the current + // try { + // new URL(this.path); + // this.isURL = true; + // } catch { + // this.isURL = false; + // } + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if (!(other instanceof MarkdownComment)) { + return false; + } + + return this.text === other.text; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `md-comment("${this.text}")${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts index 9e864177f9f..bb4fc927112 100644 --- a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts +++ b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { BaseToken } from '../baseToken.js'; +import { assert } from '../../../../base/common/assert.js'; /** * Common interface for a result of accepting a next token @@ -42,11 +43,47 @@ export interface IAcceptTokenFailure extends IAcceptTokenResult { */ export type TAcceptTokenResult = IAcceptTokenSuccess | IAcceptTokenFailure; +/** + * Decorator that validates that the current parser object was not yet consumed, + * hence can still be used to accept new tokens in the parsing process. + * + * @throws the resulting decorated method throws if the parser object was already consumed. + */ +export function assertNotConsumed>( + _target: T, + propertyKey: 'accept', + descriptor: PropertyDescriptor, +) { + // store the original method reference + const originalMethod = descriptor.value; + + // validate that the current parser object was not yet consumed + // before invoking the original accept method + descriptor.value = function ( + this: T, + ...args: Parameters + ): ReturnType { + assert( + this.isConsumed === false, + `The parser object is already consumed and should not be used anymore.`, + ); + + return originalMethod.apply(this, args); + }; + + return descriptor; +} + /** * An abstract parser class that is able to parse a sequence of * tokens into a new single entity. */ export abstract class ParserBase { + /** + * Whether the parser object was "consumed" and should not be used anymore. + */ + protected isConsumed: boolean = false; + constructor( /** * Set of tokens that were accumulated so far. @@ -70,4 +107,17 @@ export abstract class ParserBase { * @returns The parsing result. */ public abstract accept(token: TToken): TAcceptTokenResult; + + /** + * A helper method that validates that the current parser object was not yet consumed, + * hence can still be used to accept new tokens in the parsing process. + * + * @throws if the parser object is already consumed. + */ + protected assertNotConsumed(): void { + assert( + this.isConsumed === false, + `The parser object is already consumed and should not be used anymore.`, + ); + } } From 9e7505f5b1ba4bc600e71cfd7c81ec9ebb192d31 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Wed, 26 Feb 2025 12:07:15 -0800 Subject: [PATCH 065/255] II of `markdown comment` token --- .../markdownCodec/parsers/markdownComment.ts | 37 +++++++++++- .../markdownCodec/tokens/markdownComment.ts | 56 +++++-------------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts index 87e218d8e6d..58e3dfcae75 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -130,9 +130,13 @@ export class MarkdownCommentStart extends ParserBase( + key: TKeyName, +) => { + return (obj: TObject): TObject[TKeyName] => { + return obj[key]; + }; +}; diff --git a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts index 661a1be7076..f7875d957a2 100644 --- a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts @@ -6,7 +6,7 @@ import { BaseToken } from '../../baseToken.js'; import { Range } from '../../../core/range.js'; import { MarkdownToken } from './markdownToken.js'; -import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; +import { assert } from '../../../../../base/common/assert.js'; /** * A token that represent a `markdown comment` with a `range`. The `range` @@ -15,51 +15,21 @@ import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; export class MarkdownComment extends MarkdownToken { constructor( range: Range, - public readonly tokens: readonly TSimpleToken[], + public readonly text: string, ) { - // TODO: @lego - validate tokens + assert( + text.startsWith('`. + */ + public get hasEndMarker(): boolean { + return this.text.endsWith('-->'); } /** From fcb827a8cd920b1a483806dac0a93760c56ad665 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Wed, 26 Feb 2025 12:53:18 -0800 Subject: [PATCH 066/255] add basic unit tests for the markdown comments --- .../codecs/markdownCodec/markdownDecoder.ts | 38 +++++++---- .../markdownCodec/parsers/markdownComment.ts | 4 +- .../common/codecs/simpleCodec/parserBase.ts | 63 ++++++++++--------- .../common/codecs/markdownDecoder.test.ts | 40 +++++++++--- 4 files changed, 92 insertions(+), 53 deletions(-) diff --git a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts index 71b8d648846..ce5254ffee7 100644 --- a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts +++ b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts @@ -3,28 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { MarkdownLink } from './tokens/markdownLink.js'; +import { MarkdownToken } from './tokens/markdownToken.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { LeftBracket } from '../simpleCodec/tokens/brackets.js'; import { ReadableStream } from '../../../../base/common/stream.js'; +import { LeftAngleBracket } from '../simpleCodec/tokens/angleBrackets.js'; import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; import { SimpleDecoder, TSimpleToken } from '../simpleCodec/simpleDecoder.js'; +import { MarkdownCommentStart, PartialMarkdownCommentStart } from './parsers/markdownComment.js'; import { MarkdownLinkCaption, PartialMarkdownLink, PartialMarkdownLinkCaption } from './parsers/markdownLink.js'; /** * Tokens handled by this decoder. */ -export type TMarkdownToken = MarkdownLink | TSimpleToken; +export type TMarkdownToken = MarkdownToken | TSimpleToken; /** * Decoder capable of parsing markdown entities (e.g., links) from a sequence of simple tokens. */ export class MarkdownDecoder extends BaseDecoder { /** - * Current parser object that is responsible for parsing a sequence of tokens - * into some markdown entity. + * Current parser object that is responsible for parsing a sequence of tokens into + * some markdown entity. Set to `undefined` when no parsing is in progress at the moment. */ - private current?: PartialMarkdownLinkCaption | MarkdownLinkCaption | PartialMarkdownLink; + private current?: + PartialMarkdownLinkCaption | MarkdownLinkCaption | PartialMarkdownLink | + PartialMarkdownCommentStart | MarkdownCommentStart; constructor( stream: ReadableStream, @@ -41,9 +45,14 @@ export class MarkdownDecoder extends BaseDecoder { return; } - // if current parser was not initiated before, - we are not inside a - // sequence of tokens we care about, therefore re-emit the token - // immediately and continue to the next one + if (token instanceof LeftAngleBracket && !this.current) { + this.current = new PartialMarkdownCommentStart(token); + + return; + } + + // if current parser was not initiated before, - we are not inside a sequence + // of tokens we care about, therefore re-emit the token immediately and continue if (!this.current) { this._onData.fire(token); return; @@ -51,16 +60,19 @@ export class MarkdownDecoder extends BaseDecoder { // if there is a current parser object, submit the token to it // so it can progress with parsing the tokens sequence + // TODO: @lego - handle `accept` errors thrown const parseResult = this.current.accept(token); if (parseResult.result === 'success') { - // if got a parsed out `MarkdownLink` back, emit it - // then reset the current parser object - if (parseResult.nextParser instanceof MarkdownLink) { - this._onData.fire(parseResult.nextParser); + const { nextParser } = parseResult; + + // if got a fully parsed out token back, emit it and reset + // the current parser object so a new parsing process can start + if (nextParser instanceof MarkdownToken) { + this._onData.fire(nextParser); delete this.current; } else { // otherwise, update the current parser object - this.current = parseResult.nextParser; + this.current = nextParser; } } else { // if failed to parse a sequence of a tokens as a single markdown diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts index 58e3dfcae75..f415ae25cca 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -50,8 +50,8 @@ export class PartialMarkdownCommentStart extends ParserBase = IAcceptTokenSuccess | IAcceptTokenFailure; -/** - * Decorator that validates that the current parser object was not yet consumed, - * hence can still be used to accept new tokens in the parsing process. - * - * @throws the resulting decorated method throws if the parser object was already consumed. - */ -export function assertNotConsumed>( - _target: T, - propertyKey: 'accept', - descriptor: PropertyDescriptor, -) { - // store the original method reference - const originalMethod = descriptor.value; - - // validate that the current parser object was not yet consumed - // before invoking the original accept method - descriptor.value = function ( - this: T, - ...args: Parameters - ): ReturnType { - assert( - this.isConsumed === false, - `The parser object is already consumed and should not be used anymore.`, - ); - - return originalMethod.apply(this, args); - }; - - return descriptor; -} - /** * An abstract parser class that is able to parse a sequence of * tokens into a new single entity. @@ -121,3 +90,35 @@ export abstract class ParserBase { ); } } + +/** + * Decorator that validates that the current parser object was not yet consumed, + * hence can still be used to accept new tokens in the parsing process. + * + * @throws the resulting decorated method throws if the parser object was already consumed. + */ +// TODO: @legomushroom - use in all parsers +export function assertNotConsumed>( + _target: T, + propertyKey: 'accept', + descriptor: PropertyDescriptor, +) { + // store the original method reference + const originalMethod = descriptor.value; + + // validate that the current parser object was not yet consumed + // before invoking the original accept method + descriptor.value = function ( + this: T, + ...args: Parameters + ): ReturnType { + assert( + this.isConsumed === false, + `The parser object is already consumed and should not be used anymore.`, + ); + + return originalMethod.apply(this, args); + }; + + return descriptor; +} diff --git a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts index bd720971d79..bae943eab1e 100644 --- a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { TestDecoder } from '../utils/testDecoder.js'; import { Range } from '../../../common/core/range.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; @@ -11,15 +12,15 @@ import { Tab } from '../../../common/codecs/simpleCodec/tokens/tab.js'; import { Word } from '../../../common/codecs/simpleCodec/tokens/word.js'; import { Space } from '../../../common/codecs/simpleCodec/tokens/space.js'; import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; +import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js'; import { VerticalTab } from '../../../common/codecs/simpleCodec/tokens/verticalTab.js'; import { MarkdownLink } from '../../../common/codecs/markdownCodec/tokens/markdownLink.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { MarkdownDecoder, TMarkdownToken } from '../../../common/codecs/markdownCodec/markdownDecoder.js'; -import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js'; -import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; -import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; import { CarriageReturn } from '../../../common/codecs/linesCodec/tokens/carriageReturn.js'; -import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; +import { MarkdownDecoder, TMarkdownToken } from '../../../common/codecs/markdownCodec/markdownDecoder.js'; +import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; +import { MarkdownComment } from '../../../common/codecs/markdownCodec/tokens/markdownComment.js'; /** * A reusable test utility that asserts that a `TestMarkdownDecoder` instance @@ -61,7 +62,20 @@ suite('MarkdownDecoder', () => { ); await test.run( - ' hello world\nhow are\t you [caption text](./some/file/path/refer🎨nce.md)?\v\n\n[(example!)](another/path/with[-and-]-chars/folder)\t \n\t[#file:something.txt](/absolute/path/to/something.txt)', + [ + // basic text + ' hello world', + // text with markdown link and special characters in the filename + 'how are\t you [caption text](./some/file/path/refer🎨nce.md)?\v', + // empty line + '', + // markdown link with special characters in the link caption and path + '[(example!)](another/path/with[-and-]-chars/folder)\t ', + // markdown link `#file` variable in the caption and with absolute path + '\t[#file:something.txt](/absolute/path/to/something.txt)', + // text with a commented out markdown link + '\v\f machines must suffer', + ], [ // first line new Space(new Range(1, 1, 1, 2)), @@ -91,6 +105,18 @@ suite('MarkdownDecoder', () => { // fifth line new Tab(new Range(5, 1, 5, 2)), new MarkdownLink(5, 2, '[#file:something.txt]', '(/absolute/path/to/something.txt)'), + new NewLine(new Range(5, 56, 5, 57)), + // sixth line + new VerticalTab(new Range(6, 1, 6, 2)), + new FormFeed(new Range(6, 2, 6, 3)), + new Space(new Range(6, 3, 6, 4)), + new Word(new Range(6, 4, 6, 12), 'machines'), + new Space(new Range(6, 12, 6, 13)), + new Word(new Range(6, 13, 6, 17), 'must'), + new Space(new Range(6, 17, 6, 18)), + new MarkdownComment(new Range(6, 18, 6, 18 + 41), ''), + new Space(new Range(6, 59, 6, 60)), + new Word(new Range(6, 60, 6, 66), 'suffer'), ], ); }); From 298766c4a2c60706635e75891451b778fb887355 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Wed, 26 Feb 2025 13:27:47 -0800 Subject: [PATCH 067/255] add more unit tests for makrdown comment parsing --- .../codecs/markdownCodec/markdownDecoder.ts | 13 +- .../common/codecs/markdownDecoder.test.ts | 595 ++++++++++-------- 2 files changed, 338 insertions(+), 270 deletions(-) diff --git a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts index ce5254ffee7..61fc3c6e301 100644 --- a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts +++ b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts @@ -60,7 +60,6 @@ export class MarkdownDecoder extends BaseDecoder { // if there is a current parser object, submit the token to it // so it can progress with parsing the tokens sequence - // TODO: @lego - handle `accept` errors thrown const parseResult = this.current.accept(token); if (parseResult.result === 'success') { const { nextParser } = parseResult; @@ -94,8 +93,18 @@ export class MarkdownDecoder extends BaseDecoder { protected override onStreamEnd(): void { // if the stream has ended and there is a current incomplete parser - // object present, then re-emit its tokens as standalone entities + // object present, handle the remaining parser object if (this.current) { + // if a `markdown comment` does not have an end marker `-->` + // it is still a comment that extends to the end of the file + // so re-emit the current parser as a comment token + if (this.current instanceof MarkdownCommentStart) { + this._onData.fire(this.current.asMarkdownComment()); + delete this.current; + return this.onStreamEnd(); + } + + // in all other cases, re-emit existing parser tokens const { tokens } = this.current; delete this.current; diff --git a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts index bae943eab1e..d31907d2277 100644 --- a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts @@ -56,303 +56,362 @@ export class TestMarkdownDecoder extends TestDecoder { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('produces expected tokens', async () => { - const test = testDisposables.add( - new TestMarkdownDecoder(), - ); + suite('general', () => { + test('produces expected tokens', async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); - await test.run( - [ - // basic text - ' hello world', - // text with markdown link and special characters in the filename - 'how are\t you [caption text](./some/file/path/refer🎨nce.md)?\v', - // empty line - '', - // markdown link with special characters in the link caption and path - '[(example!)](another/path/with[-and-]-chars/folder)\t ', - // markdown link `#file` variable in the caption and with absolute path - '\t[#file:something.txt](/absolute/path/to/something.txt)', - // text with a commented out markdown link - '\v\f machines must suffer', - ], - [ - // first line - new Space(new Range(1, 1, 1, 2)), - new Word(new Range(1, 2, 1, 7), 'hello'), - new Space(new Range(1, 7, 1, 8)), - new Word(new Range(1, 8, 1, 13), 'world'), - new NewLine(new Range(1, 13, 1, 14)), - // second line - new Word(new Range(2, 1, 2, 4), 'how'), - new Space(new Range(2, 4, 2, 5)), - new Word(new Range(2, 5, 2, 8), 'are'), - new Tab(new Range(2, 8, 2, 9)), - new Space(new Range(2, 9, 2, 10)), - new Word(new Range(2, 10, 2, 13), 'you'), - new Space(new Range(2, 13, 2, 14)), - new MarkdownLink(2, 14, '[caption text]', '(./some/file/path/refer🎨nce.md)'), - new Word(new Range(2, 60, 2, 61), '?'), - new VerticalTab(new Range(2, 61, 2, 62)), - new NewLine(new Range(2, 62, 2, 63)), - // third line - new NewLine(new Range(3, 1, 3, 2)), - // fourth line - new MarkdownLink(4, 1, '[(example!)]', '(another/path/with[-and-]-chars/folder)'), - new Tab(new Range(4, 52, 4, 53)), - new Space(new Range(4, 53, 4, 54)), - new NewLine(new Range(4, 54, 4, 55)), - // fifth line - new Tab(new Range(5, 1, 5, 2)), - new MarkdownLink(5, 2, '[#file:something.txt]', '(/absolute/path/to/something.txt)'), - new NewLine(new Range(5, 56, 5, 57)), - // sixth line - new VerticalTab(new Range(6, 1, 6, 2)), - new FormFeed(new Range(6, 2, 6, 3)), - new Space(new Range(6, 3, 6, 4)), - new Word(new Range(6, 4, 6, 12), 'machines'), - new Space(new Range(6, 12, 6, 13)), - new Word(new Range(6, 13, 6, 17), 'must'), - new Space(new Range(6, 17, 6, 18)), - new MarkdownComment(new Range(6, 18, 6, 18 + 41), ''), - new Space(new Range(6, 59, 6, 60)), - new Word(new Range(6, 60, 6, 66), 'suffer'), - ], - ); - }); + await test.run( + [ + // basic text + ' hello world', + // text with markdown link and special characters in the filename + 'how are\t you [caption text](./some/file/path/refer🎨nce.md)?\v', + // empty line + '', + // markdown link with special characters in the link caption and path + '[(example!)](another/path/with[-and-]-chars/folder)\t ', + // markdown link `#file` variable in the caption and with absolute path + '\t[#file:something.txt](/absolute/path/to/something.txt)', + // text with a commented out markdown link + '\v\f machines must suffer', + ], + [ + // first line + new Space(new Range(1, 1, 1, 2)), + new Word(new Range(1, 2, 1, 7), 'hello'), + new Space(new Range(1, 7, 1, 8)), + new Word(new Range(1, 8, 1, 13), 'world'), + new NewLine(new Range(1, 13, 1, 14)), + // second line + new Word(new Range(2, 1, 2, 4), 'how'), + new Space(new Range(2, 4, 2, 5)), + new Word(new Range(2, 5, 2, 8), 'are'), + new Tab(new Range(2, 8, 2, 9)), + new Space(new Range(2, 9, 2, 10)), + new Word(new Range(2, 10, 2, 13), 'you'), + new Space(new Range(2, 13, 2, 14)), + new MarkdownLink(2, 14, '[caption text]', '(./some/file/path/refer🎨nce.md)'), + new Word(new Range(2, 60, 2, 61), '?'), + new VerticalTab(new Range(2, 61, 2, 62)), + new NewLine(new Range(2, 62, 2, 63)), + // third line + new NewLine(new Range(3, 1, 3, 2)), + // fourth line + new MarkdownLink(4, 1, '[(example!)]', '(another/path/with[-and-]-chars/folder)'), + new Tab(new Range(4, 52, 4, 53)), + new Space(new Range(4, 53, 4, 54)), + new NewLine(new Range(4, 54, 4, 55)), + // fifth line + new Tab(new Range(5, 1, 5, 2)), + new MarkdownLink(5, 2, '[#file:something.txt]', '(/absolute/path/to/something.txt)'), + new NewLine(new Range(5, 56, 5, 57)), + // sixth line + new VerticalTab(new Range(6, 1, 6, 2)), + new FormFeed(new Range(6, 2, 6, 3)), + new Space(new Range(6, 3, 6, 4)), + new Word(new Range(6, 4, 6, 12), 'machines'), + new Space(new Range(6, 12, 6, 13)), + new Word(new Range(6, 13, 6, 17), 'must'), + new Space(new Range(6, 17, 6, 18)), + new MarkdownComment(new Range(6, 18, 6, 18 + 41), ''), + new Space(new Range(6, 59, 6, 60)), + new Word(new Range(6, 60, 6, 66), 'suffer'), + ], + ); + }); - test('handles complex cases', async () => { - const test = testDisposables.add( - new TestMarkdownDecoder(), - ); - - const inputLines = [ - // tests that the link caption contain a chat prompt `#file:` reference, while - // the file path can contain other `graphical characters` - '\v\t[#file:./another/path/to/file.txt](./real/file!path/file◆name.md)', - // tests that the link file path contain a chat prompt `#file:` reference, - // `spaces`, `emojies`, and other `graphical characters` - ' [reference ∘ label](/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)', - // tests that link caption and file path can contain `parentheses`, `spaces`, and - // `emojies` - '\f[!(hello)!](./w(())rld/nice-🦚-filen(a).git))\n\t', - // tests that the link caption can be empty, while the file path can contain `square brackets` - '[](./s[]me/pa[h!) ', - ]; - - await test.run( - inputLines, - [ - // `1st` line - new VerticalTab(new Range(1, 1, 1, 2)), - new Tab(new Range(1, 2, 1, 3)), - new MarkdownLink(1, 3, '[#file:./another/path/to/file.txt]', '(./real/file!path/file◆name.md)'), - new NewLine(new Range(1, 68, 1, 69)), - // `2nd` line - new Space(new Range(2, 1, 2, 2)), - new MarkdownLink(2, 2, '[reference ∘ label]', '(/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)'), - new NewLine(new Range(2, 67, 2, 68)), - // `3rd` line - new FormFeed(new Range(3, 1, 3, 2)), - new MarkdownLink(3, 2, '[!(hello)!]', '(./w(())rld/nice-🦚-filen(a).git)'), - new RightParenthesis(new Range(3, 50, 3, 51)), - new NewLine(new Range(3, 51, 3, 52)), - // `4th` line - new Tab(new Range(4, 1, 4, 2)), - new NewLine(new Range(4, 2, 4, 3)), - // `5th` line - new MarkdownLink(5, 1, '[]', '(./s[]me/pa[h!)'), - new Space(new Range(5, 24, 5, 25)), - ], - ); - }); - - suite('broken links', () => { - test('incomplete/invalid links', async () => { + test('handles complex cases', async () => { const test = testDisposables.add( new TestMarkdownDecoder(), ); const inputLines = [ - // incomplete link reference with empty caption - '[ ](./real/file path/file⇧name.md', - // space between caption and reference is disallowed - '[link text] (./file path/name.txt)', + // tests that the link caption contain a chat prompt `#file:` reference, while + // the file path can contain other `graphical characters` + '\v\t[#file:./another/path/to/file.txt](./real/file!path/file◆name.md)', + // tests that the link file path contain a chat prompt `#file:` reference, + // `spaces`, `emojies`, and other `graphical characters` + ' [reference ∘ label](/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)', + // tests that link caption and file path can contain `parentheses`, `spaces`, and + // `emojies` + '\f[!(hello)!](./w(())rld/nice-🦚-filen(a).git))\n\t', + // tests that the link caption can be empty, while the file path can contain `square brackets` + '[](./s[]me/pa[h!) ', ]; await test.run( inputLines, [ // `1st` line - new LeftBracket(new Range(1, 1, 1, 2)), - new Space(new Range(1, 2, 1, 3)), - new RightBracket(new Range(1, 3, 1, 4)), - new LeftParenthesis(new Range(1, 4, 1, 5)), - new Word(new Range(1, 5, 1, 5 + 11), './real/file'), - new Space(new Range(1, 16, 1, 17)), - new Word(new Range(1, 17, 1, 17 + 17), 'path/file⇧name.md'), - new NewLine(new Range(1, 34, 1, 35)), + new VerticalTab(new Range(1, 1, 1, 2)), + new Tab(new Range(1, 2, 1, 3)), + new MarkdownLink(1, 3, '[#file:./another/path/to/file.txt]', '(./real/file!path/file◆name.md)'), + new NewLine(new Range(1, 68, 1, 69)), // `2nd` line - new LeftBracket(new Range(2, 1, 2, 2)), - new Word(new Range(2, 2, 2, 2 + 4), 'link'), - new Space(new Range(2, 6, 2, 7)), - new Word(new Range(2, 7, 2, 7 + 4), 'text'), - new RightBracket(new Range(2, 11, 2, 12)), - new Space(new Range(2, 12, 2, 13)), - new LeftParenthesis(new Range(2, 13, 2, 14)), - new Word(new Range(2, 14, 2, 14 + 6), './file'), - new Space(new Range(2, 20, 2, 21)), - new Word(new Range(2, 21, 2, 21 + 13), 'path/name.txt'), - new RightParenthesis(new Range(2, 34, 2, 35)), + new Space(new Range(2, 1, 2, 2)), + new MarkdownLink(2, 2, '[reference ∘ label]', '(/absolute/pa th/to-#file:file.txt/f🥸⚡️le.md)'), + new NewLine(new Range(2, 67, 2, 68)), + // `3rd` line + new FormFeed(new Range(3, 1, 3, 2)), + new MarkdownLink(3, 2, '[!(hello)!]', '(./w(())rld/nice-🦚-filen(a).git)'), + new RightParenthesis(new Range(3, 50, 3, 51)), + new NewLine(new Range(3, 51, 3, 52)), + // `4th` line + new Tab(new Range(4, 1, 4, 2)), + new NewLine(new Range(4, 2, 4, 3)), + // `5th` line + new MarkdownLink(5, 1, '[]', '(./s[]me/pa[h!)'), + new Space(new Range(5, 24, 5, 25)), ], ); }); + }); - suite('stop characters inside caption/reference (new lines)', () => { - for (const stopCharacter of [CarriageReturn, NewLine]) { - let characterName = ''; - - if (stopCharacter === CarriageReturn) { - characterName = '\\r'; - } - if (stopCharacter === NewLine) { - characterName = '\\n'; - } - - assert( - characterName !== '', - 'The "characterName" must be set, got "empty line".', + suite('links', () => { + suite('broken links', () => { + test('incomplete/invalid links', async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), ); - test(`stop character - "${characterName}"`, async () => { - const test = testDisposables.add( - new TestMarkdownDecoder(), + const inputLines = [ + // incomplete link reference with empty caption + '[ ](./real/file path/file⇧name.md', + // space between caption and reference is disallowed + '[link text] (./file path/name.txt)', + ]; + + await test.run( + inputLines, + [ + // `1st` line + new LeftBracket(new Range(1, 1, 1, 2)), + new Space(new Range(1, 2, 1, 3)), + new RightBracket(new Range(1, 3, 1, 4)), + new LeftParenthesis(new Range(1, 4, 1, 5)), + new Word(new Range(1, 5, 1, 5 + 11), './real/file'), + new Space(new Range(1, 16, 1, 17)), + new Word(new Range(1, 17, 1, 17 + 17), 'path/file⇧name.md'), + new NewLine(new Range(1, 34, 1, 35)), + // `2nd` line + new LeftBracket(new Range(2, 1, 2, 2)), + new Word(new Range(2, 2, 2, 2 + 4), 'link'), + new Space(new Range(2, 6, 2, 7)), + new Word(new Range(2, 7, 2, 7 + 4), 'text'), + new RightBracket(new Range(2, 11, 2, 12)), + new Space(new Range(2, 12, 2, 13)), + new LeftParenthesis(new Range(2, 13, 2, 14)), + new Word(new Range(2, 14, 2, 14 + 6), './file'), + new Space(new Range(2, 20, 2, 21)), + new Word(new Range(2, 21, 2, 21 + 13), 'path/name.txt'), + new RightParenthesis(new Range(2, 34, 2, 35)), + ], + ); + }); + + suite('stop characters inside caption/reference (new lines)', () => { + for (const stopCharacter of [CarriageReturn, NewLine]) { + let characterName = ''; + + if (stopCharacter === CarriageReturn) { + characterName = '\\r'; + } + if (stopCharacter === NewLine) { + characterName = '\\n'; + } + + assert( + characterName !== '', + 'The "characterName" must be set, got "empty line".', ); - const inputLines = [ - // stop character inside link caption - `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, - // stop character inside link reference - `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, - // stop character between line caption and link reference is disallowed - `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, - ]; + test(`stop character - "${characterName}"`, async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); + + const inputLines = [ + // stop character inside link caption + `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, + // stop character inside link reference + `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, + // stop character between line caption and link reference is disallowed + `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, + ]; - await test.run( - inputLines, - [ - // `1st` input line - new LeftBracket(new Range(1, 1, 1, 2)), - new Word(new Range(1, 2, 1, 2 + 3), 'haa'), - new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character - new Word(new Range(2, 1, 2, 1 + 3), 'loů'), - new RightBracket(new Range(2, 4, 2, 5)), - new LeftParenthesis(new Range(2, 5, 2, 6)), - new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.txt'), - new RightParenthesis(new Range(2, 24, 2, 25)), - new NewLine(new Range(2, 25, 2, 26)), - // `2nd` input line - new LeftBracket(new Range(3, 1, 3, 2)), - new Word(new Range(3, 2, 3, 2 + 3), 'ref'), - new Space(new Range(3, 5, 3, 6)), - new Word(new Range(3, 6, 3, 6 + 4), 'text'), - new RightBracket(new Range(3, 10, 3, 11)), - new LeftParenthesis(new Range(3, 11, 3, 12)), - new Word(new Range(3, 12, 3, 12 + 8), '/etc/pat'), - new stopCharacter(new Range(3, 20, 3, 21)), // <- stop character - new Word(new Range(4, 1, 4, 1 + 12), 'h/to/file.md'), - new RightParenthesis(new Range(4, 13, 4, 14)), - new NewLine(new Range(4, 14, 4, 15)), - // `3nd` input line - new LeftBracket(new Range(5, 1, 5, 2)), - new Word(new Range(5, 2, 5, 2 + 4), 'text'), - new RightBracket(new Range(5, 6, 5, 7)), - new stopCharacter(new Range(5, 7, 5, 8)), // <- stop character - new LeftParenthesis(new Range(6, 1, 6, 2)), - new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), - new Space(new Range(6, 7, 6, 8)), - new Word(new Range(6, 8, 6, 8 + 12), 'path/file.md'), - new RightParenthesis(new Range(6, 20, 6, 21)), - ], + await test.run( + inputLines, + [ + // `1st` input line + new LeftBracket(new Range(1, 1, 1, 2)), + new Word(new Range(1, 2, 1, 2 + 3), 'haa'), + new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character + new Word(new Range(2, 1, 2, 1 + 3), 'loů'), + new RightBracket(new Range(2, 4, 2, 5)), + new LeftParenthesis(new Range(2, 5, 2, 6)), + new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.txt'), + new RightParenthesis(new Range(2, 24, 2, 25)), + new NewLine(new Range(2, 25, 2, 26)), + // `2nd` input line + new LeftBracket(new Range(3, 1, 3, 2)), + new Word(new Range(3, 2, 3, 2 + 3), 'ref'), + new Space(new Range(3, 5, 3, 6)), + new Word(new Range(3, 6, 3, 6 + 4), 'text'), + new RightBracket(new Range(3, 10, 3, 11)), + new LeftParenthesis(new Range(3, 11, 3, 12)), + new Word(new Range(3, 12, 3, 12 + 8), '/etc/pat'), + new stopCharacter(new Range(3, 20, 3, 21)), // <- stop character + new Word(new Range(4, 1, 4, 1 + 12), 'h/to/file.md'), + new RightParenthesis(new Range(4, 13, 4, 14)), + new NewLine(new Range(4, 14, 4, 15)), + // `3nd` input line + new LeftBracket(new Range(5, 1, 5, 2)), + new Word(new Range(5, 2, 5, 2 + 4), 'text'), + new RightBracket(new Range(5, 6, 5, 7)), + new stopCharacter(new Range(5, 7, 5, 8)), // <- stop character + new LeftParenthesis(new Range(6, 1, 6, 2)), + new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), + new Space(new Range(6, 7, 6, 8)), + new Word(new Range(6, 8, 6, 8 + 12), 'path/file.md'), + new RightParenthesis(new Range(6, 20, 6, 21)), + ], + ); + }); + } + }); + + /** + * Same as above but these stop characters do not move the caret to the next line. + */ + suite('stop characters inside caption/reference (same line)', () => { + for (const stopCharacter of [VerticalTab, FormFeed]) { + let characterName = ''; + + if (stopCharacter === VerticalTab) { + characterName = '\\v'; + } + if (stopCharacter === FormFeed) { + characterName = '\\f'; + } + + assert( + characterName !== '', + 'The "characterName" must be set, got "empty line".', ); - }); - } + + test(`stop character - "${characterName}"`, async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); + + const inputLines = [ + // stop character inside link caption + `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, + // stop character inside link reference + `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, + // stop character between line caption and link reference is disallowed + `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, + ]; + + + await test.run( + inputLines, + [ + // `1st` input line + new LeftBracket(new Range(1, 1, 1, 2)), + new Word(new Range(1, 2, 1, 2 + 3), 'haa'), + new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character + new Word(new Range(1, 6, 1, 6 + 3), 'loů'), + new RightBracket(new Range(1, 9, 1, 10)), + new LeftParenthesis(new Range(1, 10, 1, 11)), + new Word(new Range(1, 11, 1, 11 + 18), './real/💁/name.txt'), + new RightParenthesis(new Range(1, 29, 1, 30)), + new NewLine(new Range(1, 30, 1, 31)), + // `2nd` input line + new LeftBracket(new Range(2, 1, 2, 2)), + new Word(new Range(2, 2, 2, 2 + 3), 'ref'), + new Space(new Range(2, 5, 2, 6)), + new Word(new Range(2, 6, 2, 6 + 4), 'text'), + new RightBracket(new Range(2, 10, 2, 11)), + new LeftParenthesis(new Range(2, 11, 2, 12)), + new Word(new Range(2, 12, 2, 12 + 8), '/etc/pat'), + new stopCharacter(new Range(2, 20, 2, 21)), // <- stop character + new Word(new Range(2, 21, 2, 21 + 12), 'h/to/file.md'), + new RightParenthesis(new Range(2, 33, 2, 34)), + new NewLine(new Range(2, 34, 2, 35)), + // `3nd` input line + new LeftBracket(new Range(3, 1, 3, 2)), + new Word(new Range(3, 2, 3, 2 + 4), 'text'), + new RightBracket(new Range(3, 6, 3, 7)), + new stopCharacter(new Range(3, 7, 3, 8)), // <- stop character + new LeftParenthesis(new Range(3, 8, 3, 9)), + new Word(new Range(3, 9, 3, 9 + 5), '/etc/'), + new Space(new Range(3, 14, 3, 15)), + new Word(new Range(3, 15, 3, 15 + 12), 'path/file.md'), + new RightParenthesis(new Range(3, 27, 3, 28)), + ], + ); + }); + } + }); }); + }); - /** - * Same as above but these stop characters do not move the caret to the next line. - */ - suite('stop characters inside caption/reference (same line)', () => { - for (const stopCharacter of [VerticalTab, FormFeed]) { - let characterName = ''; + suite('comments', () => { + test('base cases', async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); - if (stopCharacter === VerticalTab) { - characterName = '\\v'; - } - if (stopCharacter === FormFeed) { - characterName = '\\f'; - } + const inputData = [ + // markdown comment with text inside it + '\t', + // markdown comment with a link inside + 'some text and more text ', + // markdown comment new lines inside it + ' usual text follows', + // markdown comment that was not closed properly + 'haalo\t'), + new NewLine(new Range(1, 22, 1, 23)), + // `2nd` + new Word(new Range(2, 1, 2, 5), 'some'), + new Space(new Range(2, 5, 2, 6)), + new Word(new Range(2, 6, 2, 10), 'text'), + new MarkdownComment(new Range(2, 10, 2, 10 + 46), ''), + new Space(new Range(2, 56, 2, 57)), + new Word(new Range(2, 57, 2, 60), 'and'), + new Space(new Range(2, 60, 2, 61)), + new Word(new Range(2, 61, 2, 65), 'more'), + new Space(new Range(2, 65, 2, 66)), + new Word(new Range(2, 66, 2, 70), 'text'), + new Space(new Range(2, 70, 2, 71)), + new NewLine(new Range(2, 71, 2, 72)), + // `3rd` + new MarkdownComment(new Range(3, 1, 3 + 3, 1 + 13), ''), + new Space(new Range(6, 14, 6, 15)), + new Word(new Range(6, 15, 6, 15 + 5), 'usual'), + new Space(new Range(6, 20, 6, 21)), + new Word(new Range(6, 21, 6, 21 + 4), 'text'), + new Space(new Range(6, 25, 6, 26)), + new Word(new Range(6, 26, 6, 26 + 7), 'follows'), + new NewLine(new Range(6, 33, 6, 34)), + // `4rd` + new Word(new Range(7, 1, 7, 6), 'haalo'), + new Tab(new Range(7, 6, 7, 7)), + new MarkdownComment(new Range(7, 7, 7, 7 + 40), '', + // markdown comment with a link inside + 'some text and more text ', + // markdown comment new lines inside it + ' usual text follows', + // an empty comment + '\t\t', + // markdown comment that was not closed properly + 'haalo\t'), + new NewLine(new Range(1, 22, 1, 23)), + // `2nd` + new Word(new Range(2, 1, 2, 5), 'some'), + new Space(new Range(2, 5, 2, 6)), + new Word(new Range(2, 6, 2, 10), 'text'), + new MarkdownComment(new Range(2, 10, 2, 10 + 46), ''), + new Space(new Range(2, 56, 2, 57)), + new Word(new Range(2, 57, 2, 60), 'and'), + new Space(new Range(2, 60, 2, 61)), + new Word(new Range(2, 61, 2, 65), 'more'), + new Space(new Range(2, 65, 2, 66)), + new Word(new Range(2, 66, 2, 70), 'text'), + new Space(new Range(2, 70, 2, 71)), + new NewLine(new Range(2, 71, 2, 72)), + // `3rd` + new MarkdownComment(new Range(3, 1, 3 + 3, 1 + 13), ''), + new Space(new Range(6, 14, 6, 15)), + new Word(new Range(6, 15, 6, 15 + 5), 'usual'), + new Space(new Range(6, 20, 6, 21)), + new Word(new Range(6, 21, 6, 21 + 4), 'text'), + new Space(new Range(6, 25, 6, 26)), + new Word(new Range(6, 26, 6, 26 + 7), 'follows'), + new NewLine(new Range(6, 33, 6, 34)), + // `4rd` + new Tab(new Range(7, 1, 7, 2)), + new MarkdownComment(new Range(7, 2, 7, 2 + 7), ''), + new Tab(new Range(7, 9, 7, 10)), + new NewLine(new Range(7, 10, 7, 11)), + // `5th` + new Word(new Range(8, 1, 8, 6), 'haalo'), + new Tab(new Range(8, 6, 8, 7)), + new MarkdownComment(new Range(8, 7, 8, 7 + 40), '', - // markdown comment with a link inside - 'some text and more text ', - // markdown comment new lines inside it - ' usual text follows', - // markdown comment that was not closed properly - 'haalo\t ', + ' < !-- світ -->\t', + '\v\f', + ''), - new NewLine(new Range(1, 22, 1, 23)), + new LeftAngleBracket(new Range(1, 2, 1, 3)), + new ExclamationMark(new Range(1, 3, 1, 4)), + new Space(new Range(1, 4, 1, 5)), + new Dash(new Range(1, 5, 1, 6)), + new Dash(new Range(1, 6, 1, 7)), + new Space(new Range(1, 7, 1, 8)), + new Word(new Range(1, 8, 1, 8 + 5), 'mondo'), + new Space(new Range(1, 13, 1, 14)), + new Dash(new Range(1, 14, 1, 15)), + new Dash(new Range(1, 15, 1, 16)), + new RightAngleBracket(new Range(1, 16, 1, 17)), + new Space(new Range(1, 17, 1, 18)), + new NewLine(new Range(1, 18, 1, 19)), // `2nd` - new Word(new Range(2, 1, 2, 5), 'some'), - new Space(new Range(2, 5, 2, 6)), - new Word(new Range(2, 6, 2, 10), 'text'), - new MarkdownComment(new Range(2, 10, 2, 10 + 46), ''), - new Space(new Range(2, 56, 2, 57)), - new Word(new Range(2, 57, 2, 60), 'and'), - new Space(new Range(2, 60, 2, 61)), - new Word(new Range(2, 61, 2, 65), 'more'), - new Space(new Range(2, 65, 2, 66)), - new Word(new Range(2, 66, 2, 70), 'text'), - new Space(new Range(2, 70, 2, 71)), - new NewLine(new Range(2, 71, 2, 72)), + new Space(new Range(2, 1, 2, 2)), + new LeftAngleBracket(new Range(2, 2, 2, 3)), + new Space(new Range(2, 3, 2, 4)), + new ExclamationMark(new Range(2, 4, 2, 5)), + new Dash(new Range(2, 5, 2, 6)), + new Dash(new Range(2, 6, 2, 7)), + new Space(new Range(2, 7, 2, 8)), + new Word(new Range(2, 8, 2, 8 + 4), 'світ'), + new Space(new Range(2, 12, 2, 13)), + new Dash(new Range(2, 13, 2, 14)), + new Dash(new Range(2, 14, 2, 15)), + new RightAngleBracket(new Range(2, 15, 2, 16)), + new Tab(new Range(2, 16, 2, 17)), + new NewLine(new Range(2, 17, 2, 18)), // `3rd` - new MarkdownComment(new Range(3, 1, 3 + 3, 1 + 13), ''), - new Space(new Range(6, 14, 6, 15)), - new Word(new Range(6, 15, 6, 15 + 5), 'usual'), - new Space(new Range(6, 20, 6, 21)), - new Word(new Range(6, 21, 6, 21 + 4), 'text'), - new Space(new Range(6, 25, 6, 26)), - new Word(new Range(6, 26, 6, 26 + 7), 'follows'), - new NewLine(new Range(6, 33, 6, 34)), + new VerticalTab(new Range(3, 1, 3, 2)), + new LeftAngleBracket(new Range(3, 2, 3, 3)), + new ExclamationMark(new Range(3, 3, 3, 4)), + new Dash(new Range(3, 4, 3, 5)), + new Space(new Range(3, 5, 3, 6)), + new Dash(new Range(3, 6, 3, 7)), + new Space(new Range(3, 7, 3, 8)), + new Word(new Range(3, 8, 3, 8 + 5), 'terra'), + new Space(new Range(3, 13, 3, 14)), + new Dash(new Range(3, 14, 3, 15)), + new Dash(new Range(3, 15, 3, 16)), + new RightAngleBracket(new Range(3, 16, 3, 17)), + new FormFeed(new Range(3, 17, 3, 18)), + new NewLine(new Range(3, 18, 3, 19)), // `4rd` - new Word(new Range(7, 1, 7, 6), 'haalo'), - new Tab(new Range(7, 6, 7, 7)), - new MarkdownComment(new Range(7, 7, 7, 7 + 40), '`, hence the comment extends + // to the end of the text, and therefore includes the `space` at the end + new MarkdownComment(new Range(4, 1, 4, 1 + 15), '', - // markdown comment with a link inside + // comment with a link inside 'some text and more text ', - // markdown comment new lines inside it + // comment new lines inside it ' usual text follows', // an empty comment '\t\t', - // markdown comment that was not closed properly + // comment that was not closed properly 'haalo\t>', + // comment contains `<[]>` brackets and `!` + '\t\t', + // comment contains `\t\t', + // comment contains `'), + new RightAngleBracket(new Range(1, 19, 1, 20)), + new NewLine(new Range(1, 20, 1, 21)), + // `2nd` + new MarkdownComment(new Range(2, 1, 2, 1 + 21), ''), + new Tab(new Range(2, 22, 2, 23)), + new Tab(new Range(2, 23, 2, 24)), + new NewLine(new Range(2, 24, 2, 25)), + // `3rd` + new VerticalTab(new Range(3, 1, 3, 2)), + new MarkdownComment(new Range(3, 2, 3 + 3, 1 + 7), ''), + new Tab(new Range(6, 8, 6, 9)), + new Tab(new Range(6, 9, 6, 10)), + new NewLine(new Range(6, 10, 6, 11)), + // `4rd` + new Space(new Range(7, 1, 7, 2)), + // note! comment does not have correct closing `-->`, hence the comment extends + // to the end of the text, and therefore includes the \t\v\f and space at the end + new MarkdownComment(new Range(7, 2, 8, 1 + 12), '`, hence the comment extends + // note! comment does not have correct closing `-->`, hence the comment extends // to the end of the text, and therefore includes the `space` at the end new MarkdownComment(new Range(4, 1, 4, 1 + 15), ' usual text follows', + // '\t\t', + // 'haalo\t>', + // // comment contains `<[]>` brackets and `!` + // '\t\t', + // // comment contains `\t\t', + // // comment contains `'), + // new RightAngleBracket(new Range(1, 19, 1, 20)), + // new NewLine(new Range(1, 20, 1, 21)), + // // `2nd` + // new MarkdownComment(new Range(2, 1, 2, 1 + 21), ''), + // new Tab(new Range(2, 22, 2, 23)), + // new Tab(new Range(2, 23, 2, 24)), + // new NewLine(new Range(2, 24, 2, 25)), + // // `3rd` + // new VerticalTab(new Range(3, 1, 3, 2)), + // new MarkdownComment(new Range(3, 2, 3 + 3, 1 + 7), ''), + // new Tab(new Range(6, 8, 6, 9)), + // new Tab(new Range(6, 9, 6, 10)), + // new NewLine(new Range(6, 10, 6, 11)), + // // `4rd` + // new Space(new Range(7, 1, 7, 2)), + // // note! comment does not have correct closing `-->`, hence the comment extends + // // to the end of the text, and therefore includes the \t\v\f and space at the end + // new MarkdownComment(new Range(7, 2, 8, 1 + 12), ' usual text follows', - // '\t\t', - // 'haalo\t>', - // // comment contains `<[]>` brackets and `!` - // '\t\t', - // // comment contains `\t\t', - // // comment contains `](./s☻me/path/to/file.jpeg) ', + 'raw text \f![(/1.png)](./image-🥸.png)\v and more text', + // '![](/var/images/default) following text', + ]; - // await test.run( - // inputData, - // [ - // // `1st` - // new Space(new Range(1, 1, 1, 2)), - // new FormFeed(new Range(1, 2, 1, 3)), - // new Space(new Range(1, 3, 1, 4)), - // new LeftAngleBracket(new Range(1, 4, 1, 5)), - // new MarkdownComment(new Range(1, 5, 1, 5 + 14), ''), - // new RightAngleBracket(new Range(1, 19, 1, 20)), - // new NewLine(new Range(1, 20, 1, 21)), - // // `2nd` - // new MarkdownComment(new Range(2, 1, 2, 1 + 21), ''), - // new Tab(new Range(2, 22, 2, 23)), - // new Tab(new Range(2, 23, 2, 24)), - // new NewLine(new Range(2, 24, 2, 25)), - // // `3rd` - // new VerticalTab(new Range(3, 1, 3, 2)), - // new MarkdownComment(new Range(3, 2, 3 + 3, 1 + 7), ''), - // new Tab(new Range(6, 8, 6, 9)), - // new Tab(new Range(6, 9, 6, 10)), - // new NewLine(new Range(6, 10, 6, 11)), - // // `4rd` - // new Space(new Range(7, 1, 7, 2)), - // // note! comment does not have correct closing `-->`, hence the comment extends - // // to the end of the text, and therefore includes the \t\v\f and space at the end - // new MarkdownComment(new Range(7, 2, 8, 1 + 12), ']', '(./s☻me/path/to/file.jpeg)'), + new Space(new Range(1, 47, 1, 48)), + new NewLine(new Range(1, 48, 1, 49)), + // `2nd` + new Word(new Range(2, 1, 2, 4), 'raw'), + new Space(new Range(2, 4, 2, 5)), + new Word(new Range(2, 5, 2, 9), 'text'), + new Space(new Range(2, 9, 2, 10)), + new FormFeed(new Range(2, 10, 2, 11)), + new MarkdownImage(2, 11, '![(/1.png)]', '(./image-🥸.png)'), + new VerticalTab(new Range(2, 38, 2, 39)), + new Space(new Range(2, 39, 2, 40)), + new Word(new Range(2, 40, 2, 43), 'and'), + new Space(new Range(2, 43, 2, 44)), + new Word(new Range(2, 44, 2, 48), 'more'), + new Space(new Range(2, 48, 2, 49)), + new Word(new Range(2, 49, 2, 53), 'text'), + ], + ); + }); }); - - // suite('• broken', () => { - // test('• invalid', async () => { - // const test = testDisposables.add( - // new TestMarkdownDecoder(), - // ); - - // const inputLines = [ - // // incomplete link reference with empty caption - // '[ ](./real/file path/file⇧name.md', - // // space between caption and reference is disallowed - // '[link text] (./file path/name.txt)', - // ]; - - // await test.run( - // inputLines, - // [ - // // `1st` line - // new LeftBracket(new Range(1, 1, 1, 2)), - // new Space(new Range(1, 2, 1, 3)), - // new RightBracket(new Range(1, 3, 1, 4)), - // new LeftParenthesis(new Range(1, 4, 1, 5)), - // new Word(new Range(1, 5, 1, 5 + 11), './real/file'), - // new Space(new Range(1, 16, 1, 17)), - // new Word(new Range(1, 17, 1, 17 + 17), 'path/file⇧name.md'), - // new NewLine(new Range(1, 34, 1, 35)), - // // `2nd` line - // new LeftBracket(new Range(2, 1, 2, 2)), - // new Word(new Range(2, 2, 2, 2 + 4), 'link'), - // new Space(new Range(2, 6, 2, 7)), - // new Word(new Range(2, 7, 2, 7 + 4), 'text'), - // new RightBracket(new Range(2, 11, 2, 12)), - // new Space(new Range(2, 12, 2, 13)), - // new LeftParenthesis(new Range(2, 13, 2, 14)), - // new Word(new Range(2, 14, 2, 14 + 6), './file'), - // new Space(new Range(2, 20, 2, 21)), - // new Word(new Range(2, 21, 2, 21 + 13), 'path/name.txt'), - // new RightParenthesis(new Range(2, 34, 2, 35)), - // ], - // ); - // }); - - // suite('• stop characters inside caption/reference (new lines)', () => { - // for (const stopCharacter of [CarriageReturn, NewLine]) { - // let characterName = ''; - - // if (stopCharacter === CarriageReturn) { - // characterName = '\\r'; - // } - // if (stopCharacter === NewLine) { - // characterName = '\\n'; - // } - - // assert( - // characterName !== '', - // 'The "characterName" must be set, got "empty line".', - // ); - - // test(`• stop character - "${characterName}"`, async () => { - // const test = testDisposables.add( - // new TestMarkdownDecoder(), - // ); - - // const inputLines = [ - // // stop character inside link caption - // `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, - // // stop character inside link reference - // `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, - // // stop character between line caption and link reference is disallowed - // `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, - // ]; - - - // await test.run( - // inputLines, - // [ - // // `1st` input line - // new LeftBracket(new Range(1, 1, 1, 2)), - // new Word(new Range(1, 2, 1, 2 + 3), 'haa'), - // new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character - // new Word(new Range(2, 1, 2, 1 + 3), 'loů'), - // new RightBracket(new Range(2, 4, 2, 5)), - // new LeftParenthesis(new Range(2, 5, 2, 6)), - // new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.txt'), - // new RightParenthesis(new Range(2, 24, 2, 25)), - // new NewLine(new Range(2, 25, 2, 26)), - // // `2nd` input line - // new LeftBracket(new Range(3, 1, 3, 2)), - // new Word(new Range(3, 2, 3, 2 + 3), 'ref'), - // new Space(new Range(3, 5, 3, 6)), - // new Word(new Range(3, 6, 3, 6 + 4), 'text'), - // new RightBracket(new Range(3, 10, 3, 11)), - // new LeftParenthesis(new Range(3, 11, 3, 12)), - // new Word(new Range(3, 12, 3, 12 + 8), '/etc/pat'), - // new stopCharacter(new Range(3, 20, 3, 21)), // <- stop character - // new Word(new Range(4, 1, 4, 1 + 12), 'h/to/file.md'), - // new RightParenthesis(new Range(4, 13, 4, 14)), - // new NewLine(new Range(4, 14, 4, 15)), - // // `3nd` input line - // new LeftBracket(new Range(5, 1, 5, 2)), - // new Word(new Range(5, 2, 5, 2 + 4), 'text'), - // new RightBracket(new Range(5, 6, 5, 7)), - // new stopCharacter(new Range(5, 7, 5, 8)), // <- stop character - // new LeftParenthesis(new Range(6, 1, 6, 2)), - // new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), - // new Space(new Range(6, 7, 6, 8)), - // new Word(new Range(6, 8, 6, 8 + 12), 'path/file.md'), - // new RightParenthesis(new Range(6, 20, 6, 21)), - // ], - // ); - // }); - // } - // }); - - // /** - // * Same as above but these stop characters do not move the caret to the next line. - // */ - // suite('• stop characters inside caption/reference (same line)', () => { - // for (const stopCharacter of [VerticalTab, FormFeed]) { - // let characterName = ''; - - // if (stopCharacter === VerticalTab) { - // characterName = '\\v'; - // } - // if (stopCharacter === FormFeed) { - // characterName = '\\f'; - // } - - // assert( - // characterName !== '', - // 'The "characterName" must be set, got "empty line".', - // ); - - // test(`• stop character - "${characterName}"`, async () => { - // const test = testDisposables.add( - // new TestMarkdownDecoder(), - // ); - - // const inputLines = [ - // // stop character inside link caption - // `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, - // // stop character inside link reference - // `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, - // // stop character between line caption and link reference is disallowed - // `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, - // ]; - - - // await test.run( - // inputLines, - // [ - // // `1st` input line - // new LeftBracket(new Range(1, 1, 1, 2)), - // new Word(new Range(1, 2, 1, 2 + 3), 'haa'), - // new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character - // new Word(new Range(1, 6, 1, 6 + 3), 'loů'), - // new RightBracket(new Range(1, 9, 1, 10)), - // new LeftParenthesis(new Range(1, 10, 1, 11)), - // new Word(new Range(1, 11, 1, 11 + 18), './real/💁/name.txt'), - // new RightParenthesis(new Range(1, 29, 1, 30)), - // new NewLine(new Range(1, 30, 1, 31)), - // // `2nd` input line - // new LeftBracket(new Range(2, 1, 2, 2)), - // new Word(new Range(2, 2, 2, 2 + 3), 'ref'), - // new Space(new Range(2, 5, 2, 6)), - // new Word(new Range(2, 6, 2, 6 + 4), 'text'), - // new RightBracket(new Range(2, 10, 2, 11)), - // new LeftParenthesis(new Range(2, 11, 2, 12)), - // new Word(new Range(2, 12, 2, 12 + 8), '/etc/pat'), - // new stopCharacter(new Range(2, 20, 2, 21)), // <- stop character - // new Word(new Range(2, 21, 2, 21 + 12), 'h/to/file.md'), - // new RightParenthesis(new Range(2, 33, 2, 34)), - // new NewLine(new Range(2, 34, 2, 35)), - // // `3nd` input line - // new LeftBracket(new Range(3, 1, 3, 2)), - // new Word(new Range(3, 2, 3, 2 + 4), 'text'), - // new RightBracket(new Range(3, 6, 3, 7)), - // new stopCharacter(new Range(3, 7, 3, 8)), // <- stop character - // new LeftParenthesis(new Range(3, 8, 3, 9)), - // new Word(new Range(3, 9, 3, 9 + 5), '/etc/'), - // new Space(new Range(3, 14, 3, 15)), - // new Word(new Range(3, 15, 3, 15 + 12), 'path/file.md'), - // new RightParenthesis(new Range(3, 27, 3, 28)), - // ], - // ); - // }); - // } - // }); - // }); }); suite('• comments', () => { From 222fa26881d5eb8507f9426444afc508f69b9ce0 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Mon, 3 Mar 2025 09:04:14 -0800 Subject: [PATCH 075/255] add unit tests for the markdown image "invalid" cases --- .../common/codecs/simpleCodec/tokens/word.ts | 2 +- .../common/codecs/markdownDecoder.test.ts | 215 +++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts index fc3cefa79be..2ca5598ac4b 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts @@ -67,6 +67,6 @@ export class Word extends BaseToken { * Returns a string representation of the token. */ public override toString(): string { - return `word("${this.text.slice(0, 8)}")${this.range}`; + return `word("${this.text}")${this.range}`; } } diff --git a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts index f3bd57916df..d0ea73d7fa2 100644 --- a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts @@ -17,6 +17,7 @@ import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js' import { VerticalTab } from '../../../common/codecs/simpleCodec/tokens/verticalTab.js'; import { MarkdownLink } from '../../../common/codecs/markdownCodec/tokens/markdownLink.js'; import { CarriageReturn } from '../../../common/codecs/linesCodec/tokens/carriageReturn.js'; +import { MarkdownImage } from '../../../common/codecs/markdownCodec/tokens/markdownImage.js'; import { ExclamationMark } from '../../../common/codecs/simpleCodec/tokens/exclamationMark.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { MarkdownComment } from '../../../common/codecs/markdownCodec/tokens/markdownComment.js'; @@ -24,7 +25,6 @@ import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/to import { MarkdownDecoder, TMarkdownToken } from '../../../common/codecs/markdownCodec/markdownDecoder.js'; import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; import { LeftAngleBracket, RightAngleBracket } from '../../../common/codecs/simpleCodec/tokens/angleBrackets.js'; -import { MarkdownImage } from '../../../common/codecs/markdownCodec/tokens/markdownImage.js'; /** * A reusable test utility that asserts that a `TestMarkdownDecoder` instance @@ -448,6 +448,219 @@ suite('MarkdownDecoder', () => { ); }); }); + + suite('• broken', () => { + test('• invalid', async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); + + const inputLines = [ + // incomplete link reference with empty caption + '![ ](./real/file path/file★name.webp', + // space between caption and reference is disallowed + '\f![link text] (./file path/name.jpg)', + // new line inside the link reference + '\v![ ](./file\npath/name.jpg )', + ]; + + await test.run( + inputLines, + [ + // `1st` line + new ExclamationMark(new Range(1, 1, 1, 2)), + new LeftBracket(new Range(1, 2, 1, 3)), + new Space(new Range(1, 3, 1, 4)), + new RightBracket(new Range(1, 4, 1, 5)), + new LeftParenthesis(new Range(1, 5, 1, 6)), + new Word(new Range(1, 6, 1, 6 + 11), './real/file'), + new Space(new Range(1, 17, 1, 18)), + new Word(new Range(1, 18, 1, 18 + 19), 'path/file★name.webp'), + new NewLine(new Range(1, 37, 1, 38)), + // `2nd` line + new FormFeed(new Range(2, 1, 2, 2)), + new ExclamationMark(new Range(2, 2, 2, 3)), + new LeftBracket(new Range(2, 3, 2, 4)), + new Word(new Range(2, 4, 2, 4 + 4), 'link'), + new Space(new Range(2, 8, 2, 9)), + new Word(new Range(2, 9, 2, 9 + 4), 'text'), + new RightBracket(new Range(2, 13, 2, 14)), + new Space(new Range(2, 14, 2, 15)), + new LeftParenthesis(new Range(2, 15, 2, 16)), + new Word(new Range(2, 16, 2, 16 + 6), './file'), + new Space(new Range(2, 22, 2, 23)), + new Word(new Range(2, 23, 2, 23 + 13), 'path/name.jpg'), + new RightParenthesis(new Range(2, 36, 2, 37)), + new NewLine(new Range(2, 37, 2, 38)), + // `3rd` line + new VerticalTab(new Range(3, 1, 3, 2)), + new ExclamationMark(new Range(3, 2, 3, 3)), + new LeftBracket(new Range(3, 3, 3, 4)), + new Space(new Range(3, 4, 3, 5)), + new RightBracket(new Range(3, 5, 3, 6)), + new LeftParenthesis(new Range(3, 6, 3, 7)), + new Word(new Range(3, 7, 3, 7 + 6), './file'), + new NewLine(new Range(3, 13, 3, 14)), + new Word(new Range(4, 1, 4, 1 + 13), 'path/name.jpg'), + new Space(new Range(4, 14, 4, 15)), + new RightParenthesis(new Range(4, 15, 4, 16)), + ], + ); + }); + + suite('• stop characters inside caption/reference (new lines)', () => { + for (const stopCharacter of [CarriageReturn, NewLine]) { + let characterName = ''; + + if (stopCharacter === CarriageReturn) { + characterName = '\\r'; + } + if (stopCharacter === NewLine) { + characterName = '\\n'; + } + + assert( + characterName !== '', + 'The "characterName" must be set, got "empty line".', + ); + + test(`• stop character - "${characterName}"`, async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); + + const inputLines = [ + // stop character inside link caption + `![haa${stopCharacter.symbol}loů](./real/💁/name.png)`, + // stop character inside link reference + `![ref text](/etc/pat${stopCharacter.symbol}h/to/file.webp)`, + // stop character between line caption and link reference is disallowed + `![text]${stopCharacter.symbol}(/etc/ path/file.jpeg)`, + ]; + + + await test.run( + inputLines, + [ + // `1st` input line + new ExclamationMark(new Range(1, 1, 1, 2)), + new LeftBracket(new Range(1, 2, 1, 3)), + new Word(new Range(1, 3, 1, 3 + 3), 'haa'), + new stopCharacter(new Range(1, 6, 1, 7)), // <- stop character + new Word(new Range(2, 1, 2, 1 + 3), 'loů'), + new RightBracket(new Range(2, 4, 2, 5)), + new LeftParenthesis(new Range(2, 5, 2, 6)), + new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.png'), + new RightParenthesis(new Range(2, 24, 2, 25)), + new NewLine(new Range(2, 25, 2, 26)), + // `2nd` input line + new ExclamationMark(new Range(3, 1, 3, 2)), + new LeftBracket(new Range(3, 2, 3, 3)), + new Word(new Range(3, 3, 3, 3 + 3), 'ref'), + new Space(new Range(3, 6, 3, 7)), + new Word(new Range(3, 7, 3, 7 + 4), 'text'), + new RightBracket(new Range(3, 11, 3, 12)), + new LeftParenthesis(new Range(3, 12, 3, 13)), + new Word(new Range(3, 13, 3, 13 + 8), '/etc/pat'), + new stopCharacter(new Range(3, 21, 3, 22)), // <- stop character + new Word(new Range(4, 1, 4, 1 + 14), 'h/to/file.webp'), + new RightParenthesis(new Range(4, 15, 4, 16)), + new NewLine(new Range(4, 16, 4, 17)), + // `3nd` input line + new ExclamationMark(new Range(5, 1, 5, 2)), + new LeftBracket(new Range(5, 2, 5, 3)), + new Word(new Range(5, 3, 5, 3 + 4), 'text'), + new RightBracket(new Range(5, 7, 5, 8)), + new stopCharacter(new Range(5, 8, 5, 9)), // <- stop character + new LeftParenthesis(new Range(6, 1, 6, 2)), + new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), + new Space(new Range(6, 7, 6, 8)), + new Word(new Range(6, 8, 6, 8 + 14), 'path/file.jpeg'), + new RightParenthesis(new Range(6, 22, 6, 23)), + ], + ); + }); + } + }); + + /** + * Same as above but these stop characters do not move the caret to the next line. + */ + suite('• stop characters inside caption/reference (same line)', () => { + for (const stopCharacter of [VerticalTab, FormFeed]) { + let characterName = ''; + + if (stopCharacter === VerticalTab) { + characterName = '\\v'; + } + if (stopCharacter === FormFeed) { + characterName = '\\f'; + } + + assert( + characterName !== '', + 'The "characterName" must be set, got "empty line".', + ); + + test(`• stop character - "${characterName}"`, async () => { + const test = testDisposables.add( + new TestMarkdownDecoder(), + ); + + const inputLines = [ + // stop character inside link caption + `![haa${stopCharacter.symbol}loů](./real/💁/name)`, + // stop character inside link reference + `![ref text](/etc/pat${stopCharacter.symbol}h/to/file.webp)`, + // stop character between line caption and link reference is disallowed + `![text]${stopCharacter.symbol}(/etc/ path/image.gif)`, + ]; + + + await test.run( + inputLines, + [ + // `1st` input line + new ExclamationMark(new Range(1, 1, 1, 2)), + new LeftBracket(new Range(1, 2, 1, 3)), + new Word(new Range(1, 3, 1, 3 + 3), 'haa'), + new stopCharacter(new Range(1, 6, 1, 7)), // <- stop character + new Word(new Range(1, 7, 1, 7 + 3), 'loů'), + new RightBracket(new Range(1, 10, 1, 11)), + new LeftParenthesis(new Range(1, 11, 1, 12)), + new Word(new Range(1, 12, 1, 12 + 14), './real/💁/name'), + new RightParenthesis(new Range(1, 26, 1, 27)), + new NewLine(new Range(1, 27, 1, 28)), + // `2nd` input line + new ExclamationMark(new Range(2, 1, 2, 2)), + new LeftBracket(new Range(2, 2, 2, 3)), + new Word(new Range(2, 3, 2, 3 + 3), 'ref'), + new Space(new Range(2, 6, 2, 7)), + new Word(new Range(2, 7, 2, 7 + 4), 'text'), + new RightBracket(new Range(2, 11, 2, 12)), + new LeftParenthesis(new Range(2, 12, 2, 13)), + new Word(new Range(2, 13, 2, 13 + 8), '/etc/pat'), + new stopCharacter(new Range(2, 21, 2, 22)), // <- stop character + new Word(new Range(2, 22, 2, 22 + 14), 'h/to/file.webp'), + new RightParenthesis(new Range(2, 36, 2, 37)), + new NewLine(new Range(2, 37, 2, 38)), + // `3nd` input line + new ExclamationMark(new Range(3, 1, 3, 2)), + new LeftBracket(new Range(3, 2, 3, 3)), + new Word(new Range(3, 3, 3, 3 + 4), 'text'), + new RightBracket(new Range(3, 7, 3, 8)), + new stopCharacter(new Range(3, 8, 3, 9)), // <- stop character + new LeftParenthesis(new Range(3, 9, 3, 10)), + new Word(new Range(3, 10, 3, 10 + 5), '/etc/'), + new Space(new Range(3, 15, 3, 16)), + new Word(new Range(3, 16, 3, 16 + 14), 'path/image.gif'), + new RightParenthesis(new Range(3, 30, 3, 31)), + ], + ); + }); + } + }); + }); }); suite('• comments', () => { From c2261b02faf97df586d23f8b7d2eb75e67bd00ed Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 12 Mar 2025 14:08:17 -0700 Subject: [PATCH 076/255] Merge pull request #243384 from microsoft/connor4312/mcp-trust mcp: allow trust per server collection --- src/vs/platform/dialogs/common/dialogs.ts | 1 + .../browser/parts/dialogs/dialogHandler.ts | 7 +- .../contrib/mcp/browser/mcpCommands.ts | 6 +- .../common/discovery/configMcpDiscovery.ts | 2 +- .../discovery/nativeMcpDiscoveryAbstract.ts | 5 +- .../contrib/mcp/common/mcpRegistry.ts | 60 ++++++- .../contrib/mcp/common/mcpRegistryTypes.ts | 11 +- .../workbench/contrib/mcp/common/mcpServer.ts | 12 +- .../workbench/contrib/mcp/common/mcpTypes.ts | 17 +- .../mcp/test/common/mcpRegistry.test.ts | 161 +++++++++++++++++- 10 files changed, 257 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index 7adeddd7013..bb9270b9c28 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -283,6 +283,7 @@ export interface ICustomDialogOptions { export interface ICustomDialogMarkdown { readonly markdown: IMarkdownString; readonly classes?: string[]; + readonly dismissOnLinkClick?: boolean; } /** diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index a7524ec4fe6..e41d878f990 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -117,7 +117,12 @@ export class BrowserDialogHandler extends AbstractDialogHandler { customOptions.markdownDetails?.forEach(markdownDetail => { const result = this.markdownRenderer.render(markdownDetail.markdown, { actionHandler: { - callback: link => openLinkFromMarkdown(this.openerService, link, markdownDetail.markdown.isTrusted, true /* skip URL validation to prevent another dialog from showing which is unsupported */), + callback: link => { + if (markdownDetail.dismissOnLinkClick) { + dialog.dispose(); + } + return openLinkFromMarkdown(this.openerService, link, markdownDetail.markdown.isTrusted, true /* skip URL validation to prevent another dialog from showing which is unsupported */); + }, disposables: dialogDisposables } }); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 3a766d4e4c0..7153741982b 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -58,7 +58,7 @@ export class ListMcpServerCommand extends Action2 { store.add(pick); store.add(autorun(reader => { - const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.order || 0) - (b.collection.order || 0)), s => s.collection.id); + const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.presentation?.order || 0) - (b.collection.presentation?.order || 0)), s => s.collection.id); pick.items = Object.values(servers).flatMap(servers => { return [ { type: 'separator', label: servers[0].collection.label, id: servers[0].collection.id }, @@ -151,7 +151,7 @@ export class McpServerOptionsCommand extends Action2 { switch (pick.action) { case 'start': - await server.start(); + await server.start(true); server.showOutput(); break; case 'stop': @@ -159,7 +159,7 @@ export class McpServerOptionsCommand extends Action2 { break; case 'restart': await server.stop(); - await server.start(); + await server.start(true); break; case 'showOutput': server.showOutput(); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts index 80d83720622..c0ee83a78d3 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts @@ -126,7 +126,7 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { src.disposable.value ??= this._mcpRegistry.registerCollection({ id: collectionId, label: src.label, - order: src.order, + presentation: { order: src.order }, remoteAuthority: src.remoteAuthority || null, serverDefinitions: src.serverDefinitions, isTrustedByDefault: true, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index 3e291cedeb9..7fe18f5f4a4 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -72,7 +72,10 @@ export abstract class FilesystemMpcDiscovery extends Disposable implements IMcpD scope: StorageScope.PROFILE, isTrustedByDefault: false, serverDefinitions: observableValue(this, []), - order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemotePenalty : 0) + presentation: { + origin: file, + order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemotePenalty : 0), + }, } satisfies McpCollectionDefinition; const collectionRegistration = this._register(new MutableDisposable()); diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index b6a317949e0..b72dc8b6165 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -3,14 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { Memento } from '../../../common/memento.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js'; -import { IMcpHostDelegate, IMcpRegistry } from './mcpRegistryTypes.js'; +import { IMcpHostDelegate, IMcpRegistry, IMcpResolveConnectionOptions } from './mcpRegistryTypes.js'; import { McpServerConnection } from './mcpServerConnection.js'; import { IMcpServerConnection, McpCollectionDefinition, McpServerDefinition } from './mcpTypes.js'; @@ -25,6 +29,12 @@ export class McpRegistry extends Disposable implements IMcpRegistry { private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER))); private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER))); + private readonly _trustMemento = new Lazy(() => { + const memento = new Memento('mcpTrustedServers', this._storageService); + this._register(this._storageService.onWillSaveState(() => memento.saveMemento())); + return memento.getMemento(StorageScope.APPLICATION, StorageTarget.MACHINE); + }); + public get delegates(): readonly IMcpHostDelegate[] { return this._delegates; } @@ -32,6 +42,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, + @IDialogService private readonly _dialogService: IDialogService, + @IStorageService private readonly _storageService: IStorageService, ) { super(); } @@ -65,15 +77,51 @@ export class McpRegistry extends Disposable implements IMcpRegistry { this._workspaceStorage.value.clearAll(); } - public async resolveConnection( - collection: McpCollectionDefinition, - definition: McpServerDefinition - ): Promise { + private async promptForTrust(collection: McpCollectionDefinition, definition: McpServerDefinition): Promise { + const labelWithOrigin = collection.presentation?.origin + ? `[\`${collection.label}\`](${collection.presentation.origin})` + : collection.label; + + const result = await this._dialogService.prompt( + { + message: 'Do you trust this server?', + custom: { + markdownDetails: [{ + markdown: new MarkdownString(localize('mcp.trust.details', 'The Model Context Protocol server `{0}` was found from {1}.\n\nDo you want to allow running MCP servers from {1}?', definition.label, labelWithOrigin)), + dismissOnLinkClick: true, + }] + }, + buttons: [ + { label: localize('mcp.trust.yes', 'Trust'), run: () => true }, + { label: localize('mcp.trust.no', 'Do not trust'), run: () => false } + ], + }, + ); + + return result.result; + } + + public async resolveConnection({ collection, definition, forceTrust }: IMcpResolveConnectionOptions): Promise { const delegate = this._delegates.find(d => d.canStart(collection, definition)); if (!delegate) { throw new Error('No delegate found that can handle the connection'); } + if (!collection.isTrustedByDefault) { + const memento = this._trustMemento.value; + const trusted = memento.hasOwnProperty(definition.id) ? memento[definition.id] : undefined; + + if (trusted) { + // continue + } else if (trusted === undefined || forceTrust) { + const trustValue = await this.promptForTrust(collection, definition); + memento[definition.id] = trustValue; + if (!trustValue) { return; } + } else /** trusted === false && !forceTrust */ { + return undefined; + } + } + let launch = definition.launch; if (definition.variableReplacement) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts index 12f5123a803..8bb2c2d066a 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts @@ -26,6 +26,13 @@ export interface IMcpHostDelegate { start(collectionDefinition: McpCollectionDefinition, serverDefinition: McpServerDefinition, resolvedLaunch: McpServerLaunch): IMcpMessageTransport; } +export interface IMcpResolveConnectionOptions { + collection: McpCollectionDefinition; + definition: McpServerDefinition; + /** If set, the user will be asked to trust the collection even if they untrusted it previously */ + forceTrust?: boolean; +} + export interface IMcpRegistry { readonly _serviceBrand: undefined; @@ -37,6 +44,6 @@ export interface IMcpRegistry { /** Resets any saved inputs for the connection. */ clearSavedInputs(collection: McpCollectionDefinition, definition: McpServerDefinition): void; - /** Createse a connection for the collection and definition. */ - resolveConnection(collection: McpCollectionDefinition, definition: McpServerDefinition): Promise; + /** Creates a connection for the collection and definition. */ + resolveConnection(options: IMcpResolveConnectionOptions): Promise; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 082d62363f1..e5fea1c89c2 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -128,11 +128,19 @@ export class McpServer extends Disposable implements IMcpServer { this._connection.get()?.showOutput(); } - public start(): Promise { + public start(isFromInteraction?: boolean): Promise { return this._connectionSequencer.queue(async () => { let connection = this._connection.get(); if (!connection) { - connection = await this._mcpRegistry.resolveConnection(this.collection, this.definition); + connection = await this._mcpRegistry.resolveConnection({ + collection: this.collection, + definition: this.definition, + forceTrust: isFromInteraction, + }); + if (!connection) { + return { state: McpConnectionState.Kind.Stopped }; + } + if (this._store.isDisposed) { connection.dispose(); return { state: McpConnectionState.Kind.Stopped }; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index ec1b2e00c4b..d7909a2f2d2 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -34,8 +34,13 @@ export interface McpCollectionDefinition { readonly isTrustedByDefault: boolean; /** Scope where associated collection info should be stored. */ readonly scope: StorageScope; - /** Sort order of the collection. */ - readonly order?: number; + + readonly presentation?: { + /** Sort order of the collection. */ + readonly order?: number; + /** Place where this server is configured, used in workspac trust prompts */ + readonly origin?: URI; + }; } export const enum McpCollectionSortOrder { @@ -98,7 +103,13 @@ export interface IMcpServer extends IDisposable { readonly definition: McpServerDefinition; readonly state: IObservable; showOutput(): void; - start(): Promise; + /** + * Starts the server and returns its resulting state. One of: + * - Running, if all good + * - Error, if the server failed to start + * - Stopped, if the server was disposed or the user cancelled the launch + */ + start(isFromInteraction?: boolean): Promise; stop(): Promise; readonly tools: IObservable; diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 6f9f02f2a58..8b32ec67463 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import * as sinon from 'sinon'; import { cloneAndChange } from '../../../../../base/common/objects.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { upcast } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILoggerService } from '../../../../../platform/log/common/log.js'; @@ -24,6 +26,7 @@ import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistry import { McpServerConnection } from '../../common/mcpServerConnection.js'; import { McpCollectionDefinition, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; +import { Memento } from '../../../../common/memento.js'; class TestConfigurationResolverService implements Partial { declare readonly _serviceBrand: undefined; @@ -117,18 +120,46 @@ class TestMcpHostDelegate implements IMcpHostDelegate { } } +class TestDialogService implements Partial { + declare readonly _serviceBrand: undefined; + + private _promptResult: boolean | undefined; + private _promptSpy: sinon.SinonStub; + + constructor() { + this._promptSpy = sinon.stub(); + this._promptSpy.callsFake(() => { + return Promise.resolve({ result: this._promptResult }); + }); + } + + setPromptResult(result: boolean | undefined): void { + this._promptResult = result; + } + + get promptSpy(): sinon.SinonStub { + return this._promptSpy; + } + + prompt(options: any): Promise { + return this._promptSpy(options); + } +} + suite('Workbench - MCP - Registry', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let registry: McpRegistry; let testStorageService: TestStorageService; let testConfigResolverService: TestConfigurationResolverService; + let testDialogService: TestDialogService; let testCollection: McpCollectionDefinition; let baseDefinition: McpServerDefinition; setup(() => { testConfigResolverService = new TestConfigurationResolverService(); testStorageService = store.add(new TestStorageService()); + testDialogService = new TestDialogService(); const services = new ServiceCollection( [IConfigurationResolverService, testConfigResolverService], @@ -136,6 +167,7 @@ suite('Workbench - MCP - Registry', () => { [ISecretStorageService, new TestSecretStorageService()], [ILoggerService, store.add(new TestLoggerService())], [IOutputService, upcast({ showChannel: () => { } })], + [IDialogService, testDialogService] ); const instaService = store.add(new TestInstantiationService(services)); @@ -165,6 +197,10 @@ suite('Workbench - MCP - Registry', () => { }; }); + teardown(() => { + Memento.clear(StorageScope.APPLICATION); + }); + test('registerCollection adds collection to registry', () => { const disposable = registry.registerCollection(testCollection); store.add(disposable); @@ -205,12 +241,10 @@ suite('Workbench - MCP - Registry', () => { } }; - // Register a delegate that can handle the connection const delegate = new TestMcpHostDelegate(); - const disposable = registry.registerDelegate(delegate); - store.add(disposable); + store.add(registry.registerDelegate(delegate)); - const connection = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + const connection = await registry.resolveConnection({ collection: testCollection, definition }) as McpServerConnection; assert.ok(connection); assert.strictEqual(connection.definition, definition); @@ -218,7 +252,7 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual((connection.launchDefinition as any).env.PATH, 'interactiveValue0'); connection.dispose(); - const connection2 = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + const connection2 = await registry.resolveConnection({ collection: testCollection, definition }) as McpServerConnection; assert.ok(connection2); assert.strictEqual((connection2.launchDefinition as any).env.PATH, 'interactiveValue0'); @@ -226,10 +260,125 @@ suite('Workbench - MCP - Registry', () => { registry.clearSavedInputs(); - const connection3 = await registry.resolveConnection(testCollection, definition) as McpServerConnection; + const connection3 = await registry.resolveConnection({ collection: testCollection, definition }) as McpServerConnection; assert.ok(connection3); assert.strictEqual((connection3.launchDefinition as any).env.PATH, 'interactiveValue4'); connection3.dispose(); }); + + suite('Trust Management', () => { + setup(() => { + const delegate = new TestMcpHostDelegate(); + store.add(registry.registerDelegate(delegate)); + }); + test('resolveConnection connects to server when trusted by default', async () => { + const definition = { ...baseDefinition }; + + const connection = await registry.resolveConnection({ collection: testCollection, definition }); + + assert.ok(connection); + assert.strictEqual(testDialogService.promptSpy.called, false); + connection?.dispose(); + }); + + test('resolveConnection prompts for confirmation when not trusted by default', async () => { + const untrustedCollection: McpCollectionDefinition = { + ...testCollection, + isTrustedByDefault: false + }; + + const definition = { ...baseDefinition }; + + testDialogService.setPromptResult(true); + + const connection = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.ok(connection); + assert.strictEqual(testDialogService.promptSpy.called, true); + connection?.dispose(); + + testDialogService.promptSpy.resetHistory(); + const connection2 = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.ok(connection2); + assert.strictEqual(testDialogService.promptSpy.called, false); + connection2?.dispose(); + }); + + test('resolveConnection returns undefined when user does not trust the server', async () => { + const untrustedCollection: McpCollectionDefinition = { + ...testCollection, + isTrustedByDefault: false + }; + + const definition = { ...baseDefinition }; + + testDialogService.setPromptResult(false); + + const connection = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.strictEqual(connection, undefined); + assert.strictEqual(testDialogService.promptSpy.called, true); + + testDialogService.promptSpy.resetHistory(); + const connection2 = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.strictEqual(connection2, undefined); + assert.strictEqual(testDialogService.promptSpy.called, false); + }); + + test('resolveConnection honors forceTrust parameter', async () => { + const untrustedCollection: McpCollectionDefinition = { + ...testCollection, + isTrustedByDefault: false + }; + + const definition = { ...baseDefinition }; + + testDialogService.setPromptResult(false); + + const connection1 = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.strictEqual(connection1, undefined); + + testDialogService.promptSpy.resetHistory(); + testDialogService.setPromptResult(true); + + const connection2 = await registry.resolveConnection({ + collection: untrustedCollection, + definition, + forceTrust: true + }); + + assert.ok(connection2); + assert.strictEqual(testDialogService.promptSpy.called, true); + connection2?.dispose(); + + testDialogService.promptSpy.resetHistory(); + const connection3 = await registry.resolveConnection({ + collection: untrustedCollection, + definition + }); + + assert.ok(connection3); + assert.strictEqual(testDialogService.promptSpy.called, false); + connection3?.dispose(); + }); + }); }); From f012f59b41d1b85c4b79bae201f22d8fa0e99f0a Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Wed, 12 Mar 2025 14:48:03 -0700 Subject: [PATCH 077/255] enables the `user prompt` synchronization for all vscode qualities (#243394) --- src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index c8fdf9f404b..5ab93b68536 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -619,8 +619,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo // if the `reusable prompt` feature is enabled and in vscode // insiders, add the `Prompts` resource item to the list - const isInsiders = (this.productService.quality !== 'stable'); - if (PromptsConfig.enabled(this.configService) && isInsiders) { + if (PromptsConfig.enabled(this.configService) === true) { result.push({ id: SyncResource.Prompts, label: getSyncAreaLabel(SyncResource.Prompts) From e8a73d9b17aa6fc0959d1a1a330b16183938f6b4 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Wed, 12 Mar 2025 14:49:25 -0700 Subject: [PATCH 078/255] wait for diffing to stop before initializing, fix indexing (#243392) Co-authored-by: amunger <> --- .../chatEditingModifiedNotebookEntry.ts | 9 ++- .../chatEditingNotebookEditorIntegration.ts | 56 +++++++++---------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index a547adb37a6..df32680d2c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -66,8 +66,12 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie */ override initialContent: string; /** - * Whether we're in the process of applying edits. + * Whether we're still generating diffs from a response. */ + private _isProcessingResponse = observableValue('isProcessingResponse', false); + get isProcessingResponse(): IObservable { + return this._isProcessingResponse; + } private _isEditFromUs: boolean = false; /** * Whether all edits are from us, e.g. is possible a user has made edits, then this will be false. @@ -216,6 +220,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } const cellsDiffInfo: CellDiffInfo[] = []; try { + this._isProcessingResponse.set(true, undefined); const notebookDiff = await this.notebookEditorWorkerService.computeDiff(this.originalURI, this.modifiedURI); if (id !== this.computeRequestId) { return; @@ -226,6 +231,8 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } } catch (ex) { this.loggingService.error('Notebook Chat', 'Error computing diff:\n' + ex); + } finally { + this._isProcessingResponse.set(false, undefined); } this.initializeModelsFromDiffImpl(cellsDiffInfo); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts index f594e30b84a..024b4e642be 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts @@ -11,7 +11,6 @@ import { assertType } from '../../../../../../base/common/types.js'; import { LineRange } from '../../../../../../editor/common/core/lineRange.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { nullDocumentDiff } from '../../../../../../editor/common/diff/documentDiffProvider.js'; -import { PrefixSumComputer } from '../../../../../../editor/common/model/prefixSumComputer.js'; import { localize } from '../../../../../../nls.js'; import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -25,15 +24,16 @@ import { INotebookEditorService } from '../../../../notebook/browser/services/no import { NotebookCellTextModel } from '../../../../notebook/common/model/notebookCellTextModel.js'; import { NotebookTextModel } from '../../../../notebook/common/model/notebookTextModel.js'; import { ChatAgentLocation, IChatAgentService } from '../../../common/chatAgents.js'; -import { IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration } from '../../../common/chatEditingService.js'; +import { IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration } from '../../../common/chatEditingService.js'; import { ChatEditingCodeEditorIntegration, IDocumentDiff2 } from '../chatEditingCodeEditorIntegration.js'; +import { ChatEditingModifiedNotebookEntry } from '../chatEditingModifiedNotebookEntry.js'; import { countChanges, ICellDiffInfo, sortCellChanges } from './notebookCellChanges.js'; export class ChatEditingNotebookEditorIntegration extends Disposable implements IModifiedFileEntryEditorIntegration { private integration: ChatEditingNotebookEditorWidgetIntegration; private notebookEditor: INotebookEditor; constructor( - _entry: IModifiedFileEntry, + _entry: ChatEditingModifiedNotebookEntry, editor: IEditorPane, notebookModel: NotebookTextModel, originalModel: NotebookTextModel, @@ -88,14 +88,12 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I private readonly _currentChange = observableValue<{ change: ICellDiffInfo; index: number } | undefined>(this, undefined); readonly currentChange: IObservable<{ change: ICellDiffInfo; index: number } | undefined> = this._currentChange; - private diffIndexPrefixSum: PrefixSumComputer = new PrefixSumComputer(new Uint32Array()); - private readonly cellEditorIntegrations = new Map }>(); private readonly insertDeleteDecorators: IObservable<{ insertedCellDecorator: NotebookInsertedCellDecorator; deletedCellDecorator: NotebookDeletedCellDecorator } | undefined>; constructor( - private readonly _entry: IModifiedFileEntry, + private readonly _entry: ChatEditingModifiedNotebookEntry, private readonly notebookEditor: INotebookEditor, private readonly notebookModel: NotebookTextModel, originalModel: NotebookTextModel, @@ -130,28 +128,15 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } })); - // INIT when not streaming anymore, once per request, and when having changes + // INIT when not streaming nor diffing the response anymore, once per request, and when having changes let lastModifyingRequestId: string | undefined; this._store.add(autorun(r => { if (!_entry.isCurrentlyBeingModifiedBy.read(r) + && !_entry.isProcessingResponse.read(r) && lastModifyingRequestId !== _entry.lastModifyingRequestId && cellChanges.read(r).some(c => c.type !== 'unchanged' && !c.diff.read(r).identical) ) { - lastModifyingRequestId = _entry.lastModifyingRequestId; - - const sortedCellChanges = sortCellChanges(cellChanges.read(r)); - const values = new Uint32Array(sortedCellChanges.length); - for (let i = 0; i < sortedCellChanges.length; i++) { - const change = sortedCellChanges[i]; - values[i] = change.type === 'insert' ? 1 - : change.type === 'delete' ? 1 - : change.type === 'modified' ? change.diff.read(r).changes.length - : 0; - } - - this.diffIndexPrefixSum = new PrefixSumComputer(values); - this.reveal(true); } })); @@ -224,17 +209,28 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I this._register(autorun(r => { const currentChange = this.currentChange.read(r); - if (currentChange) { - const indexInChange = currentChange.index; - const cellIndex = currentChange.change.modifiedCellIndex ?? currentChange.change.originalCellIndex; - - const changesBeforeCell = cellIndex !== undefined && cellIndex > 0 ? - this.diffIndexPrefixSum.getPrefixSum(cellIndex - 1) : 0; - - this._currentIndex.set(changesBeforeCell + indexInChange, undefined); - } else { + if (!currentChange) { this._currentIndex.set(-1, undefined); + return; } + + let index = 0; + const sortedCellChanges = sortCellChanges(cellChanges.read(r)); + for (const change of sortedCellChanges) { + if (currentChange && currentChange.change === change) { + if (change.type === 'modified') { + index += currentChange.index; + } + break; + } + if (change.type === 'insert' || change.type === 'delete') { + index++; + } else if (change.type === 'modified') { + index += change.diff.read(r).changes.length; + } + } + + this._currentIndex.set(index, undefined); })); this.insertDeleteDecorators = derivedWithStore((r, store) => { From 4fe72cafb7ceb1a8736f16d61348d622a1bf7a6c Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Thu, 6 Mar 2025 12:55:23 -0800 Subject: [PATCH 079/255] * II of `PromptVariable` and `PromptVariableWithData` classes --- .../markdownCodec/parsers/markdownComment.ts | 2 +- .../codecs/parsers/promptVariableParser.ts | 236 ++++++++++++++++++ .../codecs/tokens/fileReference.ts | 8 +- .../promptSyntax/codecs/tokens/promptToken.ts | 11 + .../codecs/tokens/promptVariable.ts | 121 +++++++++ 5 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptToken.ts create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts index 10b66e43c60..eeba627f708 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -197,7 +197,7 @@ export class MarkdownCommentStart extends ParserBase( +export const pick = ( key: TKeyName, ) => { return (obj: TObject): TObject[TKeyName] => { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts new file mode 100644 index 00000000000..dd3e3acc16a --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { PromptVariable, PromptVariableWithData } from '../tokens/promptVariable.js'; +import { Tab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/tab.js'; +import { Hash } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/hash.js'; +import { Space } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/space.js'; +import { Colon } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/colon.js'; +import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; +import { FormFeed } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/formFeed.js'; +import { TSimpleToken } from '../../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; +import { VerticalTab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/verticalTab.js'; +import { CarriageReturn } from '../../../../../../../editor/common/codecs/linesCodec/tokens/carriageReturn.js'; +import { ExclamationMark } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/exclamationMark.js'; +import { LeftBracket, RightBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/brackets.js'; +import { LeftAngleBracket, RightAngleBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/angleBrackets.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../../../../../../editor/common/codecs/simpleCodec/parserBase.js'; + +/** + * TODO: @lego + */ +const STOP_CHARACTERS: readonly string[] = [CarriageReturn, NewLine, Tab, VerticalTab, FormFeed, Space] + .map((token) => { return token.symbol; }); + +/** + * TODO: @lego (excluding the stop ones) + */ +// TODO: @lego - add `@` here once we have it +const INVALID_NAME_CHARACTERS: readonly string[] = [Hash, ExclamationMark, LeftAngleBracket, RightAngleBracket, LeftBracket, RightBracket] + .map((token) => { return token.symbol; }); + +/** + * The parser responsible for parsing the TODO: @lego + */ +export class PartialPromptVariableName extends ParserBase { + constructor(token: Hash) { + super([token]); + } + + @assertNotConsumed + public accept(token: TSimpleToken): TAcceptTokenResult { + // if a `stop` character is encountered, finish the parsing process + if (STOP_CHARACTERS.includes(token.text)) { + // in any case, success of failure below, this is an end of the parsing process + this.isConsumed = true; + + // if there is only one token before the stop character + // must be the starting `#` one), then fail + if (this.currentTokens.length <= 1) { + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // TODO: @lego - validate that first and last tokens are defined? + + // render the characters above into strings, excluding the starting `#` character + const variableNameTokens = this.currentTokens.slice(1); + const variableName = variableNameTokens.map(pick('text')).join(''); + + return { + result: 'success', + nextParser: new PromptVariable( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + variableName, + ), + wasTokenConsumed: false, + }; + } + + // if a `:` character is encountered, we might transition to {@link PartialPromptVariableWithData} + if (token instanceof Colon) { + // if there is only one token before the `:` character + // must be the starting `#` one), then fail + if (this.currentTokens.length <= 1) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // otherwise, if there are more characters after `#` available, + // we have a variable name, so with `:`, transition to {@link PromptVariableWithData} + this.currentTokens.push(token); + this.isConsumed = true; + + return { + result: 'success', + nextParser: new PartialPromptVariableWithData(this.currentTokens), + wasTokenConsumed: true, + }; + } + + // variables cannot have {@link INVALID_NAME_CHARACTERS} in their names + if (INVALID_NAME_CHARACTERS.includes(token.text)) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // otherwise, a valid name character, so add it to the list of + // the current tokens and continue the parsing process + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } +} + +/** + * TODO: @lego + */ +export class PartialPromptVariableWithData extends ParserBase { + /** + * Number of tokens at the initialization of the current parser. + */ + // TODO: @lego - move to the base class? + private readonly startTokensCount: number; + + constructor(tokens: readonly TSimpleToken[]) { + super([...tokens]); + + // TODO: @lego - validate that it starts with `#` and ends with `:` + + // save the number of tokens that represent a variable name and the colon at the end + this.startTokensCount = this.currentTokens.length; + } + + @assertNotConsumed + public accept(token: TSimpleToken): TAcceptTokenResult { + // if a `stop` character is encountered, finish the parsing process + if (STOP_CHARACTERS.includes(token.text)) { + // in any case, success of failure below, this is an end of the parsing process + this.isConsumed = true; + + // if no tokens received after initial set of tokens, fail + if (this.currentTokens.length === this.startTokensCount) { + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // tokens representing variable name without the `#` character at the start and + // the `:` data separator character at the end + const variableNameTokens = this.currentTokens.slice(1, this.startTokensCount - 1); + // tokens representing variable data without the `:` separator character at the start + const variableDataTokens = this.currentTokens.slice(this.startTokensCount); + + // render the characters above into strings + const variableName = variableNameTokens.map(pick('text')).join(''); + const variableData = variableDataTokens.map(pick('text')).join(''); + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // TODO: @lego - validate that first and last tokens are defined? + + return { + result: 'success', + nextParser: new PromptVariableWithData( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + variableName, + variableData, + ), + wasTokenConsumed: false, + }; + } + + // otherwise, a valid data character - the data can contain almost any character, + // including `:` and `#`, hence add it to the list of the current tokens and continue + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } +} + +/** + * Utility that helps to pick a property from an object. + * + * ## Examples + * + * ```typescript + * interface IObject = { + * a: number, + * b: string, + * }; + * + * const list: IObject[] = [ + * { a: 1, b: 'foo' }, + * { a: 2, b: 'bar' }, + * ]; + * + * assert.deepStrictEqual( + * list.map(pick('a')), + * [1, 2], + * ); + * ``` + */ +// TODO: @lego - move to a common place +export const pick = ( + key: TKeyName, +) => { + return (obj: TObject): TObject[TKeyName] => { + return obj[key]; + }; +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts index 5efc84d9a6c..d8c9660c7cc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts @@ -3,11 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { PromptToken } from './promptToken.js'; import { assert } from '../../../../../../../base/common/assert.js'; import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; import { Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/word.js'; +/** + * TODO: @legomushroom - list + * - make {@link FileReference} to extend {@link PromptVariableWithData}. + */ + /** * Start sequence for a file reference token in a prompt. */ @@ -16,7 +22,7 @@ const TOKEN_START: string = '#file:'; /** * Object represents a file reference token inside a chatbot prompt. */ -export class FileReference extends BaseToken { +export class FileReference extends PromptToken { /** * Start sequence for a file reference token in a prompt. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptToken.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptToken.ts new file mode 100644 index 00000000000..38a9d69d5ac --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptToken.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; + +/** + * Common base token that all chatbot `prompt` tokens should inherit from. + */ +export abstract class PromptToken extends BaseToken { } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts new file mode 100644 index 00000000000..436a3ed6023 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptToken } from './promptToken.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; + +/** + * All prompt variables start with `#` character. + */ +const TOKEN_START: string = '#'; + +/** + * TODO: @lego + */ +const TOKEN_DATA_SEPARATOR: string = ':'; + +/** + * Represents a `#variable` token in a prompt text. + */ +export class PromptVariable extends PromptToken { + constructor( + range: Range, + /** + * The name of the variable, excluding the starting `#` character. + */ + public readonly name: string, + ) { + // TODO: @lego - validate that name does not have `#` character (and no `:`?) + + super(range); + } + + /** + * Get full text of the token. + */ + public get text(): string { + return `${TOKEN_START}${this.name}`; + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if ((other instanceof PromptVariable) === false) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `${this.name}${this.range}`; + } +} + +/** + * Represents a {@link PromptVariable} with additional data token in a prompt text. + * (e.g., `#variable:/path/to/file.md`) + */ +export class PromptVariableWithData extends PromptVariable { + constructor( + range: Range, + /** + * The name of the variable, excluding the starting `#` character. + */ + name: string, + /** + * The data of the variable, excluding the starting {@link TOKEN_DATA_SEPARATOR} character. + */ + public readonly data: string, + ) { + super(range, name); + } + + /** + * Get full text of the token. + */ + public override get text(): string { + return `${TOKEN_START}${this.name}${TOKEN_DATA_SEPARATOR}${this.data}`; + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if ((other instanceof PromptVariableWithData) === false) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `${this.text}${this.range}`; + } +} From 6de7a2735b262e90d5ec885bab544601e867f957 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Thu, 6 Mar 2025 14:03:27 -0800 Subject: [PATCH 080/255] make prompt `FileReference` to extend the prompt `Variable` class --- .../codecs/parsers/promptVariableParser.ts | 5 + .../codecs/tokens/fileReference.ts | 102 ++---------------- .../codecs/tokens/promptVariable.ts | 46 ++++---- .../promptSyntax/parsers/basePromptParser.ts | 8 +- .../codecs/tokens/fileReference.test.ts | 26 ++++- 5 files changed, 66 insertions(+), 121 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts index dd3e3acc16a..167a625499e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts @@ -19,6 +19,11 @@ import { LeftBracket, RightBracket } from '../../../../../../../editor/common/co import { LeftAngleBracket, RightAngleBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/angleBrackets.js'; import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../../../../../../editor/common/codecs/simpleCodec/parserBase.js'; +/** + * TODO: @lego - list + * - use the parser in the the prompt codec. + */ + /** * TODO: @lego */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts index d8c9660c7cc..c9729f5158d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts @@ -3,124 +3,42 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PromptToken } from './promptToken.js'; -import { assert } from '../../../../../../../base/common/assert.js'; +import { PromptVariableWithData } from './promptVariable.js'; import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; -import { Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/word.js'; /** - * TODO: @legomushroom - list - * - make {@link FileReference} to extend {@link PromptVariableWithData}. + * Name of the variable. */ - -/** - * Start sequence for a file reference token in a prompt. - */ -const TOKEN_START: string = '#file:'; +const VARIABLE_NAME: string = 'file'; /** * Object represents a file reference token inside a chatbot prompt. */ -export class FileReference extends PromptToken { - /** - * Start sequence for a file reference token in a prompt. - */ - public static readonly TOKEN_START = TOKEN_START; - +export class FileReference extends PromptVariableWithData { constructor( range: Range, public readonly path: string, ) { - super(range); - } - - /** - * Get full text of the file reference token. - */ - public get text(): string { - return `${TOKEN_START}${this.path}`; - } - - /** - * Create a file reference token out of a generic `Word`. - * @throws if the word does not conform to the expected format or if - * the reference is an invalid `URI`. - */ - public static fromWord(word: Word): FileReference { - const { text } = word; - - assert( - text.startsWith(TOKEN_START), - `The reference must start with "${TOKEN_START}", got ${text}.`, - ); - - const maybeReference = text.split(TOKEN_START); - - assert( - maybeReference.length === 2, - `The expected reference format is "${TOKEN_START}:filesystem-path", got ${text}.`, - ); - - const [first, second] = maybeReference; - - assert( - first === '', - `The reference must start with "${TOKEN_START}", got ${first}.`, - ); - - assert( - // Note! this accounts for both cases when second is `undefined` or `empty` - // and we don't care about rest of the "falsy" cases here - !!second, - `The reference path must be defined, got ${second}.`, - ); - - const reference = new FileReference( - word.range, - second, - ); - - return reference; + super(range, VARIABLE_NAME, path); } /** * Check if this token is equal to another one. */ public override equals(other: T): boolean { - if (!super.sameRange(other.range)) { + if ((other instanceof FileReference) === false) { return false; } - if (!(other instanceof FileReference)) { - return false; - } - - return this.text === other.text; + return super.equals(other); } /** - * Get the range of the `link part` of the token (e.g., + * Get the range of the `link` part of the token (e.g., * the `/path/to/file.md` part of `#file:/path/to/file.md`). */ - public get linkRange(): IRange | undefined { - if (this.path.length === 0) { - return undefined; - } - - const { range } = this; - return new Range( - range.startLineNumber, - range.startColumn + TOKEN_START.length, - range.endLineNumber, - range.endColumn, - ); - } - - /** - * Return a string representation of the token. - */ - public override toString(): string { - return `file-ref("${this.text}")${this.range}`; + public get linkRange(): IRange { + return super.dataRange; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts index 436a3ed6023..d6d0f51837a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts @@ -4,18 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { PromptToken } from './promptToken.js'; -import { Range } from '../../../../../../../editor/common/core/range.js'; +import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; /** * All prompt variables start with `#` character. */ -const TOKEN_START: string = '#'; +const START_CHARACTER: string = '#'; /** * TODO: @lego */ -const TOKEN_DATA_SEPARATOR: string = ':'; +const DATA_SEPARATOR: string = ':'; /** * Represents a `#variable` token in a prompt text. @@ -37,7 +37,7 @@ export class PromptVariable extends PromptToken { * Get full text of the token. */ public get text(): string { - return `${TOKEN_START}${this.name}`; + return `${START_CHARACTER}${this.name}`; } /** @@ -63,7 +63,7 @@ export class PromptVariable extends PromptToken { * Return a string representation of the token. */ public override toString(): string { - return `${this.name}${this.range}`; + return `${this.text}${this.range}`; } } @@ -71,51 +71,55 @@ export class PromptVariable extends PromptToken { * Represents a {@link PromptVariable} with additional data token in a prompt text. * (e.g., `#variable:/path/to/file.md`) */ +// TODO: @legomushroom - all for empty `path`s? export class PromptVariableWithData extends PromptVariable { constructor( - range: Range, + fullRange: Range, /** * The name of the variable, excluding the starting `#` character. */ name: string, + /** - * The data of the variable, excluding the starting {@link TOKEN_DATA_SEPARATOR} character. + * The data of the variable, excluding the starting {@link DATA_SEPARATOR} character. */ public readonly data: string, ) { - super(range, name); + super(fullRange, name); } /** * Get full text of the token. */ public override get text(): string { - return `${TOKEN_START}${this.name}${TOKEN_DATA_SEPARATOR}${this.data}`; + return `${START_CHARACTER}${this.name}${DATA_SEPARATOR}${this.data}`; } /** * Check if this token is equal to another one. */ public override equals(other: T): boolean { - if (!super.sameRange(other.range)) { - return false; - } - if ((other instanceof PromptVariableWithData) === false) { return false; } - if (this.text.length !== other.text.length) { - return false; - } - - return this.text === other.text; + return super.equals(other); } /** - * Return a string representation of the token. + * Range of the `data` part of the variable. */ - public override toString(): string { - return `${this.text}${this.range}`; + public get dataRange(): IRange { + const { range } = this; + const dataStartColumn = range.startColumn + + START_CHARACTER.length + this.name.length + + DATA_SEPARATOR.length; + + return new Range( + range.startLineNumber, + dataStartColumn, + range.endLineNumber, + range.endColumn, + ); } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts index 937c7e6b1bb..d4e990345fb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts @@ -575,7 +575,7 @@ export class PromptFileReference extends BasePromptParser { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/tokens/fileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/tokens/fileReference.test.ts index 4a48b3a850d..2e91084067f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/tokens/fileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/tokens/fileReference.test.ts @@ -7,14 +7,17 @@ import assert from 'assert'; import { randomInt } from '../../../../../../../../base/common/numbers.js'; import { Range } from '../../../../../../../../editor/common/core/range.js'; import { assertDefined } from '../../../../../../../../base/common/types.js'; +import { BaseToken } from '../../../../../../../../editor/common/codecs/baseToken.js'; +import { PromptToken } from '../../../../../common/promptSyntax/codecs/tokens/promptToken.js'; import { FileReference } from '../../../../../common/promptSyntax/codecs/tokens/fileReference.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; -import { BaseToken } from '../../../../../../../../editor/common/codecs/baseToken.js'; +import { PromptVariable, PromptVariableWithData } from '../../../../../common/promptSyntax/codecs/tokens/promptVariable.js'; +// TODO: @lego - add test name separators suite('FileReference', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('`linkRange`', () => { + test('linkRange', () => { const lineNumber = randomInt(100, 1); const columnStartNumber = randomInt(100, 1); const path = `/temp/test/file-${randomInt(Number.MAX_SAFE_INTEGER)}.txt`; @@ -46,7 +49,7 @@ suite('FileReference', () => { ); }); - test('`path`', () => { + test('path', () => { const lineNumber = randomInt(100, 1); const columnStartNumber = randomInt(100, 1); const link = `/temp/test/file-${randomInt(Number.MAX_SAFE_INTEGER)}.txt`; @@ -67,7 +70,7 @@ suite('FileReference', () => { ); }); - test('extends `BaseToken`', () => { + test('extends `PromptVariableWithData` and others', () => { const lineNumber = randomInt(100, 1); const columnStartNumber = randomInt(100, 1); const link = `/temp/test/file-${randomInt(Number.MAX_SAFE_INTEGER)}.txt`; @@ -81,6 +84,21 @@ suite('FileReference', () => { ); const fileReference = new FileReference(range, link); + assert( + fileReference instanceof PromptVariableWithData, + 'Must extend `PromptVariableWithData`.', + ); + + assert( + fileReference instanceof PromptVariable, + 'Must extend `PromptVariable`.', + ); + + assert( + fileReference instanceof PromptToken, + 'Must extend `PromptToken`.', + ); + assert( fileReference instanceof BaseToken, 'Must extend `BaseToken`.', From 3154cd442f94cf1666d7d1d2cd724a2f3a892d34 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Thu, 6 Mar 2025 15:16:52 -0800 Subject: [PATCH 081/255] use new prompt variables parser in the decoder/codec --- .../promptSyntax/codecs/chatPromptDecoder.ts | 308 +++++++++--------- .../codecs/parsers/promptVariableParser.ts | 119 +++++-- .../codecs/tokens/fileReference.ts | 18 + .../promptPathAutocompletion.ts | 10 +- .../promptSyntax/parsers/basePromptParser.ts | 11 +- 5 files changed, 282 insertions(+), 184 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts index 06d8e1620ec..da3a26ab285 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts @@ -3,166 +3,159 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { FileReference } from './tokens/fileReference.js'; +import { PromptToken } from './tokens/promptToken.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; -import { Range } from '../../../../../../editor/common/core/range.js'; +import { assertNever } from '../../../../../../base/common/assert.js'; import { ReadableStream } from '../../../../../../base/common/stream.js'; import { BaseDecoder } from '../../../../../../base/common/codecs/baseDecoder.js'; -import { Tab } from '../../../../../../editor/common/codecs/simpleCodec/tokens/tab.js'; -import { Word } from '../../../../../../editor/common/codecs/simpleCodec/tokens/word.js'; +import { PromptVariable, PromptVariableWithData } from './tokens/promptVariable.js'; import { Hash } from '../../../../../../editor/common/codecs/simpleCodec/tokens/hash.js'; -import { Space } from '../../../../../../editor/common/codecs/simpleCodec/tokens/space.js'; -import { Colon } from '../../../../../../editor/common/codecs/simpleCodec/tokens/colon.js'; -import { NewLine } from '../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; -import { FormFeed } from '../../../../../../editor/common/codecs/simpleCodec/tokens/formFeed.js'; -import { VerticalTab } from '../../../../../../editor/common/codecs/simpleCodec/tokens/verticalTab.js'; import { MarkdownLink } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; -import { CarriageReturn } from '../../../../../../editor/common/codecs/linesCodec/tokens/carriageReturn.js'; -import { ParserBase, TAcceptTokenResult } from '../../../../../../editor/common/codecs/simpleCodec/parserBase.js'; +import { PartialPromptVariableName, PartialPromptVariableWithData } from './parsers/promptVariableParser.js'; import { MarkdownDecoder, TMarkdownToken } from '../../../../../../editor/common/codecs/markdownCodec/markdownDecoder.js'; /** * Tokens produced by this decoder. */ -export type TChatPromptToken = MarkdownLink | FileReference; +export type TChatPromptToken = MarkdownLink | PromptVariable | PromptVariableWithData; -/** - * The Parser responsible for processing a `prompt variable name` syntax from - * a sequence of tokens (e.g., `#variable:`). - * - * The parsing process starts with single `#` token, then can accept `file` word, - * followed by the `:` token, resulting in the tokens sequence equivalent to - * the `#file:` text sequence. In this successful case, the parser transitions into - * the {@linkcode PartialPromptFileReference} parser to continue the parsing process. - */ -class PartialPromptVariableName extends ParserBase { - constructor(token: Hash) { - super([token]); - } +// /** +// * The Parser responsible for processing a `prompt variable name` syntax from +// * a sequence of tokens (e.g., `#variable:`). +// * +// * The parsing process starts with single `#` token, then can accept `file` word, +// * followed by the `:` token, resulting in the tokens sequence equivalent to +// * the `#file:` text sequence. In this successful case, the parser transitions into +// * the {@linkcode PartialPromptFileReference} parser to continue the parsing process. +// */ +// class PartialPromptVariableName extends ParserBase { +// constructor(token: Hash) { +// super([token]); +// } - public accept(token: TMarkdownToken): TAcceptTokenResult { - // given we currently hold the `#` token, if we receive a `file` word, - // we can successfully proceed to the next token in the sequence - if (token instanceof Word) { - if (token.text === 'file') { - this.currentTokens.push(token); +// public accept(token: TMarkdownToken): TAcceptTokenResult { +// // given we currently hold the `#` token, if we receive a `file` word, +// // we can successfully proceed to the next token in the sequence +// if (token instanceof Word) { +// if (token.text === 'file') { +// this.currentTokens.push(token); - return { - result: 'success', - nextParser: this, - wasTokenConsumed: true, - }; - } +// return { +// result: 'success', +// nextParser: this, +// wasTokenConsumed: true, +// }; +// } - return { - result: 'failure', - wasTokenConsumed: false, - }; - } +// return { +// result: 'failure', +// wasTokenConsumed: false, +// }; +// } - // if we receive the `:` token, we can successfully proceed to the next - // token in the sequence `only if` the previous token was a `file` word - // therefore for currently tokens sequence equivalent to the `#file` text - if (token instanceof Colon) { - const lastToken = this.currentTokens[this.currentTokens.length - 1]; +// // if we receive the `:` token, we can successfully proceed to the next +// // token in the sequence `only if` the previous token was a `file` word +// // therefore for currently tokens sequence equivalent to the `#file` text +// if (token instanceof Colon) { +// const lastToken = this.currentTokens[this.currentTokens.length - 1]; - if (lastToken instanceof Word) { - this.currentTokens.push(token); +// if (lastToken instanceof Word) { +// this.currentTokens.push(token); - return { - result: 'success', - nextParser: new PartialPromptFileReference(this.currentTokens), - wasTokenConsumed: true, - }; - } - } +// return { +// result: 'success', +// nextParser: new PartialPromptFileReference(this.currentTokens), +// wasTokenConsumed: true, +// }; +// } +// } - // all other cases are failures and we don't consume the offending token - return { - result: 'failure', - wasTokenConsumed: false, - }; - } -} +// // all other cases are failures and we don't consume the offending token +// return { +// result: 'failure', +// wasTokenConsumed: false, +// }; +// } +// } -/** - * List of characters that stop a prompt variable sequence. - */ -const PROMPT_FILE_REFERENCE_STOP_CHARACTERS: readonly string[] = [Space, Tab, CarriageReturn, NewLine, VerticalTab, FormFeed] - .map((token) => { return token.symbol; }); +// /** +// * List of characters that stop a prompt variable sequence. +// */ +// const PROMPT_FILE_REFERENCE_STOP_CHARACTERS: readonly string[] = [Space, Tab, CarriageReturn, NewLine, VerticalTab, FormFeed] +// .map((token) => { return token.symbol; }); -/** - * Parser responsible for processing the `file reference` syntax part from - * a sequence of tokens (e.g., #variable:`./some/file/path.md`). - * - * The parsing process starts with the sequence of `#`, `file`, and `:` tokens, - * then can accept a sequence of tokens until one of the tokens defined in - * the {@linkcode PROMPT_FILE_REFERENCE_STOP_CHARACTERS} list is encountered. - * This sequence of tokens is treated as a `file path` part of the `#file:` variable, - * and in the successful case, the parser transitions into the {@linkcode FileReference} - * token which signifies the end of the file reference text parsing process. - */ -class PartialPromptFileReference extends ParserBase { - /** - * Set of tokens that were accumulated so far. - */ - private readonly fileReferenceTokens: (Hash | Word | Colon)[]; +// /** +// * Parser responsible for processing the `file reference` syntax part from +// * a sequence of tokens (e.g., #variable:`./some/file/path.md`). +// * +// * The parsing process starts with the sequence of `#`, `file`, and `:` tokens, +// * then can accept a sequence of tokens until one of the tokens defined in +// * the {@linkcode PROMPT_FILE_REFERENCE_STOP_CHARACTERS} list is encountered. +// * This sequence of tokens is treated as a `file path` part of the `#file:` variable, +// * and in the successful case, the parser transitions into the {@linkcode FileReference} +// * token which signifies the end of the file reference text parsing process. +// */ +// class PartialPromptFileReference extends ParserBase { +// /** +// * Set of tokens that were accumulated so far. +// */ +// private readonly fileReferenceTokens: (Hash | Word | Colon)[]; - constructor(tokens: (Hash | Word | Colon)[]) { - super([]); +// constructor(tokens: (Hash | Word | Colon)[]) { +// super([]); - this.fileReferenceTokens = tokens; - } +// this.fileReferenceTokens = tokens; +// } - /** - * List of tokens that were accumulated so far. - */ - public override get tokens(): readonly (Hash | Word | Colon)[] { - return [...this.fileReferenceTokens, ...this.currentTokens]; - } +// /** +// * List of tokens that were accumulated so far. +// */ +// public override get tokens(): readonly (Hash | Word | Colon)[] { +// return [...this.fileReferenceTokens, ...this.currentTokens]; +// } - /** - * Return the `FileReference` instance created from the current object. - */ - public asFileReference(): FileReference { - // use only tokens in the `currentTokens` list to - // create the path component of the file reference - const path = this.currentTokens - .map((token) => { return token.text; }) - .join(''); +// /** +// * Return the `FileReference` instance created from the current object. +// */ +// public asFileReference(): FileReference { +// // use only tokens in the `currentTokens` list to +// // create the path component of the file reference +// const path = this.currentTokens +// .map((token) => { return token.text; }) +// .join(''); - const firstToken = this.tokens[0]; +// const firstToken = this.tokens[0]; - const range = new Range( - firstToken.range.startLineNumber, - firstToken.range.startColumn, - firstToken.range.startLineNumber, - firstToken.range.startColumn + FileReference.TOKEN_START.length + path.length, - ); +// const range = new Range( +// firstToken.range.startLineNumber, +// firstToken.range.startColumn, +// firstToken.range.startLineNumber, +// firstToken.range.startColumn + FileReference.TOKEN_START.length + path.length, +// ); - return new FileReference(range, path); - } +// return new FileReference(range, path); +// } - public accept(token: TMarkdownToken): TAcceptTokenResult { - // any of stop characters is are breaking a prompt variable sequence - if (PROMPT_FILE_REFERENCE_STOP_CHARACTERS.includes(token.text)) { - return { - result: 'success', - wasTokenConsumed: false, - nextParser: this.asFileReference(), - }; - } +// public accept(token: TMarkdownToken): TAcceptTokenResult { +// // any of stop characters is are breaking a prompt variable sequence +// if (PROMPT_FILE_REFERENCE_STOP_CHARACTERS.includes(token.text)) { +// return { +// result: 'success', +// wasTokenConsumed: false, +// nextParser: this.asFileReference(), +// }; +// } - // any other token can be included in the sequence so accumulate - // it and continue with using the current parser instance - this.currentTokens.push(token); - return { - result: 'success', - wasTokenConsumed: true, - nextParser: this, - }; - } -} +// // any other token can be included in the sequence so accumulate +// // it and continue with using the current parser instance +// this.currentTokens.push(token); +// return { +// result: 'success', +// wasTokenConsumed: true, +// nextParser: this, +// }; +// } +// } /** * Decoder for the common chatbot prompt message syntax. @@ -170,11 +163,12 @@ class PartialPromptFileReference extends ParserBase { /** - * Currently active parser object that is used to parse a well-known equence of + * Currently active parser object that is used to parse a well-known sequence of * tokens, for instance, a `file reference` that consists of `hash`, `word`, and * `colon` tokens sequence plus following file path part. + * TODO: @lego - update the comment */ - private current?: PartialPromptVariableName; + private current?: PartialPromptVariableName | PartialPromptVariableWithData; constructor( stream: ReadableStream, @@ -193,7 +187,7 @@ export class ChatPromptDecoder extends BaseDecoder { return token.symbol; }); /** @@ -49,40 +49,23 @@ export class PartialPromptVariableName extends ParserBase { // if a `stop` character is encountered, finish the parsing process if (STOP_CHARACTERS.includes(token.text)) { - // in any case, success of failure below, this is an end of the parsing process - this.isConsumed = true; - - // if there is only one token before the stop character - // must be the starting `#` one), then fail - if (this.currentTokens.length <= 1) { + try { + // if it is possible to convert current parser to `PromptVariable`, return success result + return { + result: 'success', + nextParser: this.asPromptVariable(), + wasTokenConsumed: false, + }; + } catch (error) { + // otherwise fail return { result: 'failure', wasTokenConsumed: false, }; + } finally { + // in any case this is an end of the parsing process + this.isConsumed = true; } - - const firstToken = this.currentTokens[0]; - const lastToken = this.currentTokens[this.currentTokens.length - 1]; - - // TODO: @lego - validate that first and last tokens are defined? - - // render the characters above into strings, excluding the starting `#` character - const variableNameTokens = this.currentTokens.slice(1); - const variableName = variableNameTokens.map(pick('text')).join(''); - - return { - result: 'success', - nextParser: new PromptVariable( - new Range( - firstToken.range.startLineNumber, - firstToken.range.startColumn, - lastToken.range.endLineNumber, - lastToken.range.endColumn, - ), - variableName, - ), - wasTokenConsumed: false, - }; } // if a `:` character is encountered, we might transition to {@link PartialPromptVariableWithData} @@ -130,6 +113,40 @@ export class PartialPromptVariableName extends ParserBase 1, + 'Cannot create a prompt variable out of incomplete token sequence.', + ); + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // TODO: @lego - validate that first and last tokens are defined? + + // render the characters above into strings, excluding the starting `#` character + const variableNameTokens = this.currentTokens.slice(1); + const variableName = variableNameTokens.map(pick('text')).join(''); + + return new PromptVariable( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + variableName, + ); + } } /** @@ -207,6 +224,46 @@ export class PartialPromptVariableWithData extends ParserBase this.startTokensCount, + `No 'data' part of the token found.`, + ); + + // tokens representing variable name without the `#` character at the start and + // the `:` data separator character at the end + const variableNameTokens = this.currentTokens.slice(1, this.startTokensCount - 1); + // tokens representing variable data without the `:` separator character at the start + const variableDataTokens = this.currentTokens.slice(this.startTokensCount); + + // render the characters above into strings + const variableName = variableNameTokens.map(pick('text')).join(''); + const variableData = variableDataTokens.map(pick('text')).join(''); + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // TODO: @lego - validate that first and last tokens are defined? + + return new PromptVariableWithData( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + variableName, + variableData, + ); + } } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts index c9729f5158d..1dfdfdd8e9e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/fileReference.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { PromptVariableWithData } from './promptVariable.js'; +import { assert } from '../../../../../../../base/common/assert.js'; import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; @@ -23,6 +25,22 @@ export class FileReference extends PromptVariableWithData { super(range, VARIABLE_NAME, path); } + /** + * Create a {@link FileReference} from a {@link PromptVariableWithData} instance. + * @throws if variable name is not equal to {@link VARIABLE_NAME}. + */ + public static from(variable: PromptVariableWithData) { + assert( + variable.name === VARIABLE_NAME, + `Variable name must be '${VARIABLE_NAME}', got '${variable.name}'.`, + ); + + return new FileReference( + variable.range, + variable.data, + ); + } + /** * Check if this token is equal to another one. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts index 7f026bdfff8..7a5010781d5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts @@ -18,7 +18,6 @@ import { LANGUAGE_SELECTOR } from '../constants.js'; import { IPromptsService } from '../service/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IPromptFileReference } from '../parsers/types.js'; -import { FileReference } from '../codecs/tokens/fileReference.js'; import { assertOneOf } from '../../../../../../base/common/types.js'; import { isWindows } from '../../../../../../base/common/platform.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; @@ -68,10 +67,11 @@ const findFileReference = ( return undefined; } - // this ensures that we handle only the `#file:` references for now - if (!reference.text.startsWith(FileReference.TOKEN_START)) { - return undefined; - } + // TODO: @lego - put this back? + // // this ensures that we handle only the `#file:` references for now + // if (!reference.text.startsWith(FileReference.TOKEN_START)) { + // return undefined; + // } // reference must match the provided position const { startLineNumber, endColumn } = range; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts index d4e990345fb..63e0ff7296c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts @@ -9,7 +9,6 @@ import { ChatPromptCodec } from '../codecs/chatPromptCodec.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { assert } from '../../../../../../base/common/assert.js'; import { IPromptFileReference, IResolveError } from './types.js'; -import { FileReference } from '../codecs/tokens/fileReference.js'; import { ChatPromptDecoder } from '../codecs/chatPromptDecoder.js'; import { IRange } from '../../../../../../editor/common/core/range.js'; import { assertDefined } from '../../../../../../base/common/types.js'; @@ -24,6 +23,8 @@ import { FilePromptContentProvider } from '../contentProviders/filePromptContent import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { MarkdownLink } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; import { OpenFailed, NotPromptFile, RecursiveReference, FolderReference, ParseError, FailedToResolveContentsStream } from '../../promptFileReferenceErrors.js'; +import { PromptVariableWithData } from '../codecs/tokens/promptVariable.js'; +import { FileReference } from '../codecs/tokens/fileReference.js'; /** * Well-known localized error messages. @@ -225,8 +226,12 @@ export abstract class BasePromptParser extend // when some tokens received, process and store the references this.stream.on('data', (token) => { - if (token instanceof FileReference) { - this.onReference(token, [...seenReferences]); + if (token instanceof PromptVariableWithData) { + try { + this.onReference(FileReference.from(token), [...seenReferences]); + } catch (error) { + // no-op + } } // note! the `isURL` is a simple check and needs to be improved to truly From c50894bb6520de8f146088e5527e352e677efb42 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Mon, 10 Mar 2025 17:05:25 -0600 Subject: [PATCH 082/255] add common `pick` utility for arrays and related unit tests --- src/vs/base/common/arrays.ts | 30 +++++++ src/vs/base/test/common/arrays.test.ts | 59 ++++++++++++++ .../markdownCodec/parsers/markdownComment.ts | 31 +------ .../common/codecs/simpleCodec/parserBase.ts | 9 ++- .../promptSyntax/codecs/chatPromptDecoder.ts | 2 +- .../codecs/parsers/promptVariableParser.ts | 81 +++++++------------ .../codecs/tokens/promptVariable.ts | 4 +- 7 files changed, 130 insertions(+), 86 deletions(-) diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts index d4a40903443..98bf168f39d 100644 --- a/src/vs/base/common/arrays.ts +++ b/src/vs/base/common/arrays.ts @@ -620,6 +620,36 @@ function getActualStartIndex(array: T[], start: number): number { return start < 0 ? Math.max(start + array.length, 0) : Math.min(start, array.length); } +/** + * Utility that helps to pick a property from an object. + * + * ## Examples + * + * ```typescript + * interface IObject = { + * a: number, + * b: string, + * }; + * + * const list: IObject[] = [ + * { a: 1, b: 'foo' }, + * { a: 2, b: 'bar' }, + * ]; + * + * assert.deepStrictEqual( + * list.map(pick('a')), + * [1, 2], + * ); + * ``` + */ +export const pick = ( + key: TKeyName, +) => { + return (obj: TObject): TObject[TKeyName] => { + return obj[key]; + }; +}; + /** * When comparing two values, * a negative number indicates that the first value is less than the second, diff --git a/src/vs/base/test/common/arrays.test.ts b/src/vs/base/test/common/arrays.test.ts index f1144fbb613..8c4249b6a44 100644 --- a/src/vs/base/test/common/arrays.test.ts +++ b/src/vs/base/test/common/arrays.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import * as arrays from '../../common/arrays.js'; import * as arraysFind from '../../common/arraysFind.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import { pick } from '../../common/arrays.js'; suite('Arrays', () => { @@ -399,6 +400,64 @@ suite('Arrays', () => { ); }); + suite('pick', () => { + suite('object', () => { + test('numbers', () => { + const array = [{ v: 3, foo: 'a' }, { v: 5, foo: 'b' }, { v: 2, foo: 'c' }, { v: 2, foo: 'd' }, { v: 17, bar: '1' }, { v: NaN, baz: '10' }]; + + assert.deepStrictEqual( + array.map(pick('v')), + [3, 5, 2, 2, 17, NaN], + ); + }); + + test('strings', () => { + const array = [{ v: 3, foo: 'a' }, { v: 5, foo: 'b' }, { v: 2, foo: 'c' }, { v: 2, foo: 'd' }, { v: 17, bar: '1' }, { v: NaN, baz: '10' }, { foo: '12' }]; + + assert.deepStrictEqual( + array.map(pick('foo')), + ['a', 'b', 'c', 'd', undefined, undefined, '12'], + ); + }); + + test('booleans', () => { + const array = [{ v: 3, foo: 'a' }, { v: 5, foo: 'b' }, { v: 2, foo: 'c' }, { v: 2, foo: 'd' }, { v: 17, bar: true }, { v: NaN, bar: false }, { bar: false }]; + + assert.deepStrictEqual( + array.map(pick('bar')), + [undefined, undefined, undefined, undefined, true, false, false], + ); + }); + + test('objects', () => { + const array = [{ v: { test: 12 } }, { v: { test: 24 } }, {}, { v: { test: 17892 } }]; + + assert.deepStrictEqual( + array.map(pick('v')), + [{ test: 12 }, { test: 24 }, undefined, { test: 17892 }], + ); + }); + + test('mixed', () => { + const array = [{ v: { test: 104 } }, { v: 2 }, {}, { v: '24' }, { v: null }]; + + assert.deepStrictEqual( + array.map(pick('v')), + [{ test: 104 }, 2, undefined, '24', null], + ); + }); + }); + + test('string', () => { + const array = ['haallo', 'there', ':wave:', '!']; + + assert.deepStrictEqual( + array.map(pick('length')), + [6, 5, 6, 1], + ); + }); + }); + suite('ArrayQueue', () => { suite('takeWhile/takeFromEndWhile', () => { test('TakeWhile 1', () => { diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts index eeba627f708..df5f3f028ab 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -5,6 +5,7 @@ import { Range } from '../../../core/range.js'; import { Dash } from '../../simpleCodec/tokens/dash.js'; +import { pick } from '../../../../../base/common/arrays.js'; import { assert } from '../../../../../base/common/assert.js'; import { MarkdownComment } from '../tokens/markdownComment.js'; import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; @@ -174,33 +175,3 @@ export class MarkdownCommentStart extends ParserBase( - key: TKeyName, -) => { - return (obj: TObject): TObject[TKeyName] => { - return obj[key]; - }; -}; diff --git a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts index 807beda411a..e088a18f264 100644 --- a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts +++ b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts @@ -53,12 +53,19 @@ export abstract class ParserBase { */ protected isConsumed: boolean = false; + /** + * Number of tokens at the initialization of the current parser. + */ + protected readonly startTokensCount: number; + constructor( /** * Set of tokens that were accumulated so far. */ protected readonly currentTokens: TToken[] = [], - ) { } + ) { + this.startTokensCount = this.currentTokens.length; + } /** * Get the tokens that were accumulated so far. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts index da3a26ab285..ac486f61d34 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts @@ -268,7 +268,7 @@ export class ChatPromptDecoder extends BaseDecoder { return token.symbol; }); /** - * TODO: @lego (excluding the stop ones) + * List of characters that cannot be in a variable name (excluding the {@link STOP_CHARACTERS}). */ -// TODO: @lego - add `@` here once we have it const INVALID_NAME_CHARACTERS: readonly string[] = [Hash, ExclamationMark, LeftAngleBracket, RightAngleBracket, LeftBracket, RightBracket] .map((token) => { return token.symbol; }); /** - * The parser responsible for parsing the TODO: @lego + * The parser responsible for parsing a `prompt variable name`. + * E.g., `#selection` or `#workspace` variable. If the `:` character follows + * the variable name, the parser transitions to {@link PartialPromptVariableWithData} + * that is also able to parse the `data` part of the variable. E.g., the `#file` part + * of the `#file:/path/to/something.md` sequence. */ export class PartialPromptVariableName extends ParserBase { constructor(token: Hash) { @@ -150,22 +150,30 @@ export class PartialPromptVariableName extends ParserBase { - /** - * Number of tokens at the initialization of the current parser. - */ - // TODO: @lego - move to the base class? - private readonly startTokensCount: number; constructor(tokens: readonly TSimpleToken[]) { + const firstToken = tokens; + const lastToken = tokens[tokens.length - 1]; + + // sanity checks of our expectations about the tokens list + assert( + tokens.length > 2, + `Tokens list must contain at least 3 items, got '${tokens.length}'.`, + ); + assert( + firstToken instanceof Hash, + `The first token must be a '#', got '${firstToken} '.`, + ); + assert( + lastToken instanceof Colon, + `The last token must be a ':', got '${lastToken} '.`, + ); + super([...tokens]); - - // TODO: @lego - validate that it starts with `#` and ends with `:` - - // save the number of tokens that represent a variable name and the colon at the end - this.startTokensCount = this.currentTokens.length; } @assertNotConsumed @@ -232,7 +240,7 @@ export class PartialPromptVariableWithData extends ParserBase this.startTokensCount, `No 'data' part of the token found.`, @@ -265,34 +273,3 @@ export class PartialPromptVariableWithData extends ParserBase( - key: TKeyName, -) => { - return (obj: TObject): TObject[TKeyName] => { - return obj[key]; - }; -}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts index d6d0f51837a..db6479e0e53 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptVariable.ts @@ -13,7 +13,7 @@ import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.j const START_CHARACTER: string = '#'; /** - * TODO: @lego + * Character that separates name of a prompt variable from its data. */ const DATA_SEPARATOR: string = ':'; @@ -71,7 +71,7 @@ export class PromptVariable extends PromptToken { * Represents a {@link PromptVariable} with additional data token in a prompt text. * (e.g., `#variable:/path/to/file.md`) */ -// TODO: @legomushroom - all for empty `path`s? +// TODO: @legomushroom - allow for empty `path`s? export class PromptVariableWithData extends PromptVariable { constructor( fullRange: Range, From a1d5a4c72f185804b22989e2440f3eee29719824 Mon Sep 17 00:00:00 2001 From: Oleg Solomko Date: Mon, 10 Mar 2025 18:08:16 -0600 Subject: [PATCH 083/255] improve the `IPromptReference` type, add more doc comments --- .../markdownCodec/parsers/markdownImage.ts | 8 ++-- .../promptSyntax/codecs/chatPromptDecoder.ts | 23 +++++----- .../codecs/parsers/promptVariableParser.ts | 19 ++++---- .../codecs/tokens/promptVariable.ts | 16 ++++++- .../promptPathAutocompletion.ts | 13 +++--- .../promptSyntax/parsers/basePromptParser.ts | 43 +++++++++++++------ .../common/promptSyntax/parsers/types.d.ts | 34 ++++++++++----- .../testUtils/expectedReference.ts | 4 +- 8 files changed, 97 insertions(+), 63 deletions(-) diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownImage.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownImage.ts index 460f9b23932..a3264f7cbdd 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownImage.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownImage.ts @@ -12,12 +12,12 @@ import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleC import { MarkdownLinkCaption, PartialMarkdownLink, PartialMarkdownLinkCaption } from './markdownLink.js'; /** - * The parser responsible for parsing the `