diff --git a/packages/core/src/common/collections.ts b/packages/core/src/common/collections.ts new file mode 100644 index 0000000000000..4dad49cdfa21d --- /dev/null +++ b/packages/core/src/common/collections.ts @@ -0,0 +1,125 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/** + * A convencience class for managing a "map of maps" of arbitrary depth + */ +export class MultiKeyMap { + private rootMap = new Map(); + + constructor(private readonly keyLength: number) { + } + + static create(keyLength: number, data: [S[], T][]): MultiKeyMap { + const result = new MultiKeyMap(keyLength); + for (const entry of data) { + result.set(entry[0], entry[1]); + } + return result; + } + + set(key: readonly K[], value: V): V | undefined { + if (this.keyLength !== key.length) { + throw new Error(`innappropriate key length: ${key.length}, should be ${this.keyLength}`); + } + let map = this.rootMap; + for (let i = 0; i < this.keyLength - 1; i++) { + let existing = map.get(key[i]); + if (!existing) { + existing = new Map(); + map.set(key[i], existing); + } + map = existing; + } + const oldValue = map.get(key[this.keyLength - 1]); + map.set(key[this.keyLength - 1], value); + return oldValue; + } + + get(key: readonly K[]): V | undefined { + if (this.keyLength !== key.length) { + throw new Error(`innappropriate key length: ${key.length}, should be ${this.keyLength}`); + } + let map = this.rootMap; + for (let i = 0; i < this.keyLength - 1; i++) { + map = map.get(key[i]); + if (!map) { + return undefined; + } + } + return map.get(key[this.keyLength - 1]); + } + + /** + * Checks whether the given key is present in the map + * @param key the key to test. It can have a length < the key length + * @returns whether the key exists + */ + has(key: readonly K[]): boolean { + if (this.keyLength < key.length) { + throw new Error(`innappropriate key length: ${key.length}, should <= ${this.keyLength}`); + } + let map = this.rootMap; + for (let i = 0; i < key.length - 1; i++) { + map = map.get(key[i]); + if (!map) { + return false; + } + } + return map.has(key[key.length - 1]); + } + + /** + * Deletes the value with the given key from the map + * @param key the key to remove. It can have a length < the key length + * @returns whether the key was present in the map + */ + delete(key: readonly K[]): boolean { + if (this.keyLength < key.length) { + throw new Error(`innappropriate key length: ${key.length}, should <= ${this.keyLength}`); + } + let map = this.rootMap; + for (let i = 0; i < this.keyLength - 1; i++) { + map = map.get(key[i]); + if (!map) { + return false; + } + } + return map.delete(key[key.length - 1]); + } + + /** + * Iterates over all entries in the map. The ordering semantices are like iterating over a map of maps. + * @param handler Handler for each entry + */ + forEach(handler: (value: V, key: K[]) => void): void { + this.doForeach(handler, this.rootMap, []); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private doForeach(handler: (value: V, key: K[]) => void, currentMap: Map, keys: K[]): void { + if (keys.length === this.keyLength - 1) { + currentMap.forEach((v, k) => { + handler(v, [...keys, k]); + }); + } else { + currentMap.forEach((v, k) => { + this.doForeach(handler, v, [...keys, k]); + }); + + } + } +} diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 8051cd8fe3aa7..a20facab333ff 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -112,7 +112,7 @@ import type { TimelineChangeEvent, TimelineProviderDescriptor } from '@theia/timeline/lib/common/timeline-model'; -import { SerializableEnvironmentVariableCollection, SerializableExtensionEnvironmentVariableCollection } from '@theia/terminal/lib/common/base-terminal-protocol'; +import { SerializableEnvironmentVariableCollection } from '@theia/terminal/lib/common/shell-terminal-protocol'; import { ThemeType } from '@theia/core/lib/common/theme'; import { Disposable } from '@theia/core/lib/common/disposable'; import { isString, isObject, PickOptions, QuickInputButtonHandle } from '@theia/core/lib/common'; @@ -287,10 +287,10 @@ export interface TerminalServiceExt { $terminalSizeChanged(id: string, cols: number, rows: number): void; $currentTerminalChanged(id: string | undefined): void; $terminalStateChanged(id: string): void; - $initEnvironmentVariableCollections(collections: [string, SerializableEnvironmentVariableCollection][]): void; + $initEnvironmentVariableCollections(collections: [string, string, boolean, SerializableEnvironmentVariableCollection][]): void; $provideTerminalLinks(line: string, terminalId: string, token: theia.CancellationToken): Promise; $handleTerminalLink(link: ProvidedTerminalLink): Promise; - getEnvironmentVariableCollection(extensionIdentifier: string): theia.EnvironmentVariableCollection; + getEnvironmentVariableCollection(extensionIdentifier: string): theia.GlobalEnvironmentVariableCollection; } export interface OutputChannelRegistryExt { createOutputChannel(name: string, pluginInfo: PluginInfo): theia.OutputChannel, @@ -408,7 +408,7 @@ export interface TerminalServiceMain { */ $disposeByTerminalId(id: number, waitOnExit?: boolean | string): void; - $setEnvironmentVariableCollection(persistent: boolean, collection: SerializableExtensionEnvironmentVariableCollection): void; + $setEnvironmentVariableCollection(persistent: boolean, extensionIdentifier: string, rootUri: string, collection: SerializableEnvironmentVariableCollection): void; /** * Set the terminal widget name. diff --git a/packages/plugin-ext/src/main/browser/terminal-main.ts b/packages/plugin-ext/src/main/browser/terminal-main.ts index b68d5a800881f..d894afd779f03 100644 --- a/packages/plugin-ext/src/main/browser/terminal-main.ts +++ b/packages/plugin-ext/src/main/browser/terminal-main.ts @@ -22,8 +22,7 @@ import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-servi import { TerminalServiceMain, TerminalServiceExt, MAIN_RPC_CONTEXT } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; -import { SerializableEnvironmentVariableCollection, SerializableExtensionEnvironmentVariableCollection } from '@theia/terminal/lib/common/base-terminal-protocol'; -import { ShellTerminalServerProxy } from '@theia/terminal/lib/common/shell-terminal-protocol'; +import { SerializableEnvironmentVariableCollection, ShellTerminalServerProxy } from '@theia/terminal/lib/common/shell-terminal-protocol'; import { TerminalLink, TerminalLinkProvider } from '@theia/terminal/lib/browser/terminal-link-provider'; import { URI } from '@theia/core/lib/common/uri'; import { getIconClass } from '../../plugin/terminal-ext'; @@ -59,11 +58,8 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin } this.toDispose.push(this.terminals.onDidChangeCurrentTerminal(() => this.updateCurrentTerminal())); this.updateCurrentTerminal(); - if (this.shellTerminalServer.collections.size > 0) { - const collectionAsArray = [...this.shellTerminalServer.collections.entries()]; - const serializedCollections: [string, SerializableEnvironmentVariableCollection][] = collectionAsArray.map(e => [e[0], [...e[1].map.entries()]]); - this.extProxy.$initEnvironmentVariableCollections(serializedCollections); - } + + this.shellTerminalServer.getEnvVarCollections().then(collections => this.extProxy.$initEnvironmentVariableCollections(collections)); this.pluginTerminalRegistry.startCallback = id => this.startProfile(id); @@ -75,11 +71,11 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin return this.extProxy.$startProfile(id, CancellationToken.None); } - $setEnvironmentVariableCollection(persistent: boolean, collection: SerializableExtensionEnvironmentVariableCollection): void { - if (collection.collection) { - this.shellTerminalServer.setCollection(collection.extensionIdentifier, persistent, collection.collection, collection.description); + $setEnvironmentVariableCollection(persistent: boolean, extensionIdentifier: string, rootUri: string, collection: SerializableEnvironmentVariableCollection): void { + if (collection) { + this.shellTerminalServer.setCollection(extensionIdentifier, rootUri, persistent, collection, collection.description); } else { - this.shellTerminalServer.deleteCollection(collection.extensionIdentifier); + this.shellTerminalServer.deleteCollection(extensionIdentifier); } } diff --git a/packages/plugin-ext/src/plugin/terminal-ext.ts b/packages/plugin-ext/src/plugin/terminal-ext.ts index e2f2c8ae36275..c48e618a0e651 100644 --- a/packages/plugin-ext/src/plugin/terminal-ext.ts +++ b/packages/plugin-ext/src/plugin/terminal-ext.ts @@ -18,11 +18,12 @@ import { Terminal, TerminalOptions, PseudoTerminalOptions, ExtensionTerminalOpti import { TerminalServiceExt, TerminalServiceMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; import { Event, Emitter } from '@theia/core/lib/common/event'; +import { MultiKeyMap } from '@theia/core/lib/common/collections'; import { Deferred } from '@theia/core/lib/common/promise-util'; import * as theia from '@theia/plugin'; import * as Converter from './type-converters'; import { Disposable, EnvironmentVariableMutatorType, TerminalExitReason, ThemeIcon } from './types-impl'; -import { SerializableEnvironmentVariableCollection } from '@theia/terminal/lib/common/base-terminal-protocol'; +import { NO_ROOT_URI, SerializableEnvironmentVariableCollection } from '@theia/terminal/lib/common/shell-terminal-protocol'; import { ProvidedTerminalLink } from '../common/plugin-api-rpc-model'; import { ThemeIcon as MonacoThemeIcon } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; @@ -68,7 +69,7 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { private readonly onDidChangeTerminalStateEmitter = new Emitter(); readonly onDidChangeTerminalState: theia.Event = this.onDidChangeTerminalStateEmitter.event; - protected environmentVariableCollections: Map = new Map(); + protected environmentVariableCollections: MultiKeyMap = new MultiKeyMap(2); constructor(rpc: RPCProtocol) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.TERMINAL_MAIN); @@ -303,46 +304,53 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { *--------------------------------------------------------------------------------------------*/ // some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/api/common/extHostTerminalService.ts - getEnvironmentVariableCollection(extensionIdentifier: string): theia.EnvironmentVariableCollection { - let collection = this.environmentVariableCollections.get(extensionIdentifier); + getEnvironmentVariableCollection(extensionIdentifier: string, rootUri: string = NO_ROOT_URI): theia.GlobalEnvironmentVariableCollection { + const that = this; + let collection = this.environmentVariableCollections.get([extensionIdentifier, rootUri]); if (!collection) { - collection = new EnvironmentVariableCollection(); - this.setEnvironmentVariableCollection(extensionIdentifier, collection); + collection = new class extends EnvironmentVariableCollectionImpl { + override getScoped(scope: theia.EnvironmentVariableScope): theia.EnvironmentVariableCollection { + return that.getEnvironmentVariableCollection(extensionIdentifier, scope.workspaceFolder?.uri.toString()); + } + }(true); + this.setEnvironmentVariableCollection(extensionIdentifier, rootUri, collection); } return collection; } - private syncEnvironmentVariableCollection(extensionIdentifier: string, collection: EnvironmentVariableCollection): void { + private syncEnvironmentVariableCollection(extensionIdentifier: string, rootUri: string, collection: EnvironmentVariableCollectionImpl): void { const serialized = [...collection.map.entries()]; - this.proxy.$setEnvironmentVariableCollection(collection.persistent, { - extensionIdentifier, - collection: serialized.length === 0 ? undefined : serialized, - description: Converter.fromMarkdownOrString(collection.description) - }); + this.proxy.$setEnvironmentVariableCollection(collection.persistent, extensionIdentifier, + rootUri, + { + mutators: serialized, + description: Converter.fromMarkdownOrString(collection.description) + }); } - private setEnvironmentVariableCollection(extensionIdentifier: string, collection: EnvironmentVariableCollection): void { - this.environmentVariableCollections.set(extensionIdentifier, collection); + private setEnvironmentVariableCollection(pluginIdentifier: string, rootUri: string, collection: EnvironmentVariableCollectionImpl): void { + this.environmentVariableCollections.set([pluginIdentifier, rootUri], collection); collection.onDidChangeCollection(() => { // When any collection value changes send this immediately, this is done to ensure // following calls to createTerminal will be created with the new environment. It will // result in more noise by sending multiple updates when called but collections are // expected to be small. - this.syncEnvironmentVariableCollection(extensionIdentifier, collection); + this.syncEnvironmentVariableCollection(pluginIdentifier, rootUri, collection); }); } - $initEnvironmentVariableCollections(collections: [string, SerializableEnvironmentVariableCollection][]): void { + $initEnvironmentVariableCollections(collections: [string, string, boolean, SerializableEnvironmentVariableCollection][]): void { collections.forEach(entry => { const extensionIdentifier = entry[0]; - const collection = new EnvironmentVariableCollection(entry[1]); - this.setEnvironmentVariableCollection(extensionIdentifier, collection); + const rootUri = entry[1]; + const collection = new EnvironmentVariableCollectionImpl(entry[2], entry[3]); + this.setEnvironmentVariableCollection(extensionIdentifier, rootUri, collection); }); } } -export class EnvironmentVariableCollection implements theia.EnvironmentVariableCollection { +export class EnvironmentVariableCollectionImpl implements theia.GlobalEnvironmentVariableCollection { readonly map: Map = new Map(); private _description?: string | theia.MarkdownString; private _persistent: boolean = true; @@ -363,9 +371,15 @@ export class EnvironmentVariableCollection implements theia.EnvironmentVariableC onDidChangeCollection: Event = this.onDidChangeCollectionEmitter.event; constructor( + persistent: boolean, serialized?: SerializableEnvironmentVariableCollection ) { - this.map = new Map(serialized); + this._persistent = persistent; + this.map = new Map(serialized?.mutators); + } + + getScoped(scope: theia.EnvironmentVariableScope): theia.EnvironmentVariableCollection { + throw new Error('Cannot get scoped from a regular env var collection'); } get size(): number { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 96b9163fc9356..e686729eca817 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -3692,6 +3692,39 @@ export module '@theia/plugin' { clear(): void; } + /** + * A collection of mutations that an extension can apply to a process environment. Applies to all scopes. + */ + export interface GlobalEnvironmentVariableCollection extends EnvironmentVariableCollection { + /** + * Gets scope-specific environment variable collection for the extension. This enables alterations to + * terminal environment variables solely within the designated scope, and is applied in addition to (and + * after) the global collection. + * + * Each object obtained through this method is isolated and does not impact objects for other scopes, + * including the global collection. + * + * @param scope The scope to which the environment variable collection applies to. + * + * If a scope parameter is omitted, collection applicable to all relevant scopes for that parameter is + * returned. For instance, if the 'workspaceFolder' parameter is not specified, the collection that applies + * across all workspace folders will be returned. + * + * @return Environment variable collection for the passed in scope. + */ + getScoped(scope: EnvironmentVariableScope): EnvironmentVariableCollection; + } + + /** + * The scope object to which the environment variable collection applies. + */ + export interface EnvironmentVariableScope { + /** + * Any specific workspace folder to get collection for. + */ + workspaceFolder?: WorkspaceFolder; + } + /** * The ExtensionMode is provided on the `ExtensionContext` and indicates the * mode the specific extension is running in. @@ -3879,7 +3912,7 @@ export module '@theia/plugin' { * Gets the extension's environment variable collection for this workspace, enabling changes * to be applied to terminal environment variables. */ - readonly environmentVariableCollection: EnvironmentVariableCollection; + readonly environmentVariableCollection: GlobalEnvironmentVariableCollection; /** * Get the absolute path of a resource contained in the extension. diff --git a/packages/terminal/src/browser/base/terminal-widget.ts b/packages/terminal/src/browser/base/terminal-widget.ts index b47b322299030..9350d0fb32dc7 100644 --- a/packages/terminal/src/browser/base/terminal-widget.ts +++ b/packages/terminal/src/browser/base/terminal-widget.ts @@ -60,7 +60,7 @@ export abstract class TerminalWidget extends BaseWidget { abstract processInfo: Promise; /** The ids of extensions contributing to the environment of this terminal mapped to the provided description for their changes. */ - abstract envVarCollectionDescriptionsByExtension: Promise>; + abstract envVarCollectionDescriptionsByExtension: Promise>; /** Terminal kind that indicates whether a terminal is created by a user or by some extension for a user */ abstract readonly kind: 'user' | string; diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index ed0da8067e938..2263f1402e692 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -51,10 +51,6 @@ import { terminalAnsiColorMap } from './terminal-theme-service'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { TerminalWatcher } from '../common/terminal-watcher'; -import { - ENVIRONMENT_VARIABLE_COLLECTIONS_KEY, - SerializableExtensionEnvironmentVariableCollection -} from '../common/base-terminal-protocol'; import { nls } from '@theia/core/lib/common/nls'; import { Profiles, TerminalPreferences } from './terminal-preferences'; import { ShellTerminalProfile } from './shell-terminal-profile'; @@ -172,6 +168,7 @@ export namespace TerminalCommands { }); } +const ENVIRONMENT_VARIABLE_COLLECTIONS_KEY = 'terminal.integrated.environmentVariableCollections'; @injectable() export class TerminalFrontendContribution implements FrontendApplicationContribution, TerminalService, CommandContribution, MenuContribution, KeybindingContribution, TabBarToolbarContribution, ColorContribution { @@ -252,8 +249,7 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu this.terminalWatcher.onUpdateTerminalEnvVariablesRequested(() => { this.storageService.getData(ENVIRONMENT_VARIABLE_COLLECTIONS_KEY).then(data => { if (data) { - const collectionsJson: SerializableExtensionEnvironmentVariableCollection[] = JSON.parse(data); - collectionsJson.forEach(c => this.shellTerminalServer.setCollection(c.extensionIdentifier, true, c.collection ? c.collection : [], c.description)); + this.shellTerminalServer.restorePersisted(data); } }); }); diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 1508c92a12bbe..5cb3dd8755d22 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -437,7 +437,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget return this.shellTerminalServer.getProcessInfo(this.terminalId); } - get envVarCollectionDescriptionsByExtension(): Promise> { + get envVarCollectionDescriptionsByExtension(): Promise> { if (!IBaseTerminalServer.validateId(this.terminalId)) { return Promise.reject(new Error('terminal is not started')); } @@ -898,7 +898,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget this.enhancedPreviewNode = document.createElement('div'); Promise.all([this.envVarCollectionDescriptionsByExtension, this.processId, this.processInfo]) - .then((values: [Map, number, TerminalProcessInfo]) => { + .then((values: [Map, number, TerminalProcessInfo]) => { const extensions = values[0]; const processId = values[1]; const processInfo = values[2]; @@ -911,14 +911,16 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget processInfo.arguments.join(' ') + '\n\n---\n\n'); markdown.appendMarkdown('The following extensions have contributed to this terminal\'s environment:\n'); - extensions.forEach((value, key) => { - if (value === undefined) { - markdown.appendMarkdown('* ' + key + '\n'); - } else if (typeof value === 'string') { - markdown.appendMarkdown('* ' + key + ': ' + value + '\n'); - } else { - markdown.appendMarkdown('* ' + key + ': ' + value.value + '\n'); - } + extensions.forEach((arr, key) => { + arr.forEach(value => { + if (value === undefined) { + markdown.appendMarkdown('* ' + key + '\n'); + } else if (typeof value === 'string') { + markdown.appendMarkdown('* ' + key + ': ' + value + '\n'); + } else { + markdown.appendMarkdown('* ' + key + ': ' + value.value + '\n'); + } + }); }); const enhancedPreviewNode = this.enhancedPreviewNode; diff --git a/packages/terminal/src/common/base-terminal-protocol.ts b/packages/terminal/src/common/base-terminal-protocol.ts index ee0db863abfc6..b85f8f7427237 100644 --- a/packages/terminal/src/common/base-terminal-protocol.ts +++ b/packages/terminal/src/common/base-terminal-protocol.ts @@ -16,7 +16,6 @@ import { RpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; import { Disposable } from '@theia/core'; -import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string'; export interface TerminalProcessInfo { executable: string @@ -29,32 +28,12 @@ export interface IBaseTerminalServer extends RpcServer { create(IBaseTerminalServerOptions: object): Promise; getProcessId(id: number): Promise; getProcessInfo(id: number): Promise; - getEnvVarCollectionDescriptionsByExtension(id: number): Promise>; getCwdURI(id: number): Promise; resize(id: number, cols: number, rows: number): Promise; attach(id: number): Promise; onAttachAttempted(id: number): Promise; close(id: number): Promise; getDefaultShell(): Promise; - - /** - * Gets a single collection constructed by merging all environment variable collections into - * one. - */ - readonly collections: ReadonlyMap; - /** - * Gets a single collection constructed by merging all environment variable collections into - * one. - */ - readonly mergedCollection: MergedEnvironmentVariableCollection; - /** - * Sets an extension's environment variable collection. - */ - setCollection(extensionIdentifier: string, persistent: boolean, collection: SerializableEnvironmentVariableCollection, description: string | MarkdownString | undefined): void; - /** - * Deletes an extension's environment variable collection. - */ - deleteCollection(extensionIdentifier: string): void; } export namespace IBaseTerminalServer { export function validateId(id?: number): boolean { @@ -144,61 +123,3 @@ export class DispatchingBaseTerminalClient { }); } } - -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariable.ts - -export const ENVIRONMENT_VARIABLE_COLLECTIONS_KEY = 'terminal.integrated.environmentVariableCollections'; - -export interface EnvironmentVariableCollection { - readonly map: ReadonlyMap; -} - -export interface EnvironmentVariableCollectionWithPersistence extends EnvironmentVariableCollection { - readonly persistent: boolean; - readonly description: string | MarkdownString | undefined; -} - -export enum EnvironmentVariableMutatorType { - Replace = 1, - Append = 2, - Prepend = 3 -} - -export interface EnvironmentVariableMutatorOptions { - applyAtProcessCreation?: boolean; -} - -export interface EnvironmentVariableMutator { - readonly value: string; - readonly type: EnvironmentVariableMutatorType; - readonly options: EnvironmentVariableMutatorOptions; -} - -export interface ExtensionOwnedEnvironmentVariableMutator extends EnvironmentVariableMutator { - readonly extensionIdentifier: string; -} - -/** - * Represents an environment variable collection that results from merging several collections - * together. - */ -export interface MergedEnvironmentVariableCollection { - readonly map: ReadonlyMap; - - /** - * Applies this collection to a process environment. - */ - applyToProcessEnvironment(env: { [key: string]: string | null }): void; -} - -export interface SerializableExtensionEnvironmentVariableCollection { - extensionIdentifier: string, - collection: SerializableEnvironmentVariableCollection | undefined, - description: string | MarkdownString | undefined -} - -export type SerializableEnvironmentVariableCollection = [string, EnvironmentVariableMutator][]; diff --git a/packages/terminal/src/common/shell-terminal-protocol.ts b/packages/terminal/src/common/shell-terminal-protocol.ts index 2c06139b8181b..b71133f3265cc 100644 --- a/packages/terminal/src/common/shell-terminal-protocol.ts +++ b/packages/terminal/src/common/shell-terminal-protocol.ts @@ -17,11 +17,25 @@ import { RpcProxy } from '@theia/core'; import { IBaseTerminalServer, IBaseTerminalServerOptions } from './base-terminal-protocol'; import { OS } from '@theia/core/lib/common/os'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string'; export const IShellTerminalServer = Symbol('IShellTerminalServer'); export interface IShellTerminalServer extends IBaseTerminalServer { hasChildProcesses(processId: number | undefined): Promise; + getEnvVarCollectionDescriptionsByExtension(id: number): Promise>; + getEnvVarCollections(): Promise<[string, string, boolean, SerializableEnvironmentVariableCollection][]>; + + restorePersisted(jsonValue: string): void; + /** + * Sets an extension's environment variable collection. + */ + setCollection(extensionIdentifier: string, rootUri: string, persistent: boolean, + collection: SerializableEnvironmentVariableCollection, description: string | MarkdownString | undefined): void; + /** + * Deletes an extension's environment variable collection. + */ + deleteCollection(extensionIdentifier: string): void; } export const shellTerminalPath = '/services/shell-terminal'; @@ -48,3 +62,42 @@ export interface IShellTerminalServerOptions extends IBaseTerminalServerOptions export const ShellTerminalServerProxy = Symbol('ShellTerminalServerProxy'); export type ShellTerminalServerProxy = RpcProxy; + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariable.ts + +export const NO_ROOT_URI = ''; + +export interface EnvironmentVariableCollection { + readonly variableMutators: ReadonlyMap; + readonly description: string | MarkdownString | undefined; +} + +export interface EnvironmentVariableCollectionWithPersistence extends EnvironmentVariableCollection { + readonly persistent: boolean; +} + +export enum EnvironmentVariableMutatorType { + Replace = 1, + Append = 2, + Prepend = 3 +} + +export interface EnvironmentVariableMutatorOptions { + applyAtProcessCreation?: boolean; +} + +export interface EnvironmentVariableMutator { + readonly value: string; + readonly type: EnvironmentVariableMutatorType; + readonly options: EnvironmentVariableMutatorOptions; +} + +export interface SerializableEnvironmentVariableCollection { + readonly description: string | MarkdownString | undefined; + readonly mutators: [string, EnvironmentVariableMutator][] +}; + diff --git a/packages/terminal/src/node/base-terminal-server.ts b/packages/terminal/src/node/base-terminal-server.ts index 6338304588ce7..e7865f24acc7e 100644 --- a/packages/terminal/src/node/base-terminal-server.ts +++ b/packages/terminal/src/node/base-terminal-server.ts @@ -15,34 +15,22 @@ // ***************************************************************************** import { inject, injectable, named } from '@theia/core/shared/inversify'; -import { ILogger, DisposableCollection, isWindows } from '@theia/core/lib/common'; +import { ILogger, DisposableCollection } from '@theia/core/lib/common'; import { IBaseTerminalServer, IBaseTerminalServerOptions, IBaseTerminalClient, TerminalProcessInfo, - EnvironmentVariableCollection, - MergedEnvironmentVariableCollection, - SerializableEnvironmentVariableCollection, - EnvironmentVariableMutator, - ExtensionOwnedEnvironmentVariableMutator, - EnvironmentVariableMutatorType, - EnvironmentVariableCollectionWithPersistence, - SerializableExtensionEnvironmentVariableCollection, TerminalExitReason } from '../common/base-terminal-protocol'; import { TerminalProcess, ProcessManager, TaskTerminalProcess } from '@theia/process/lib/node'; import { ShellProcess } from './shell-process'; -import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string'; @injectable() export abstract class BaseTerminalServer implements IBaseTerminalServer { protected client: IBaseTerminalClient | undefined = undefined; protected terminalToDispose = new Map(); - readonly collections: Map = new Map(); - mergedCollection: MergedEnvironmentVariableCollection; - constructor( @inject(ProcessManager) protected readonly processManager: ProcessManager, @inject(ILogger) @named('terminal') protected readonly logger: ILogger @@ -54,7 +42,6 @@ export abstract class BaseTerminalServer implements IBaseTerminalServer { this.terminalToDispose.delete(id); } }); - this.mergedCollection = this.resolveMergedCollection(); } abstract create(options: IBaseTerminalServerOptions): Promise; @@ -103,18 +90,6 @@ export abstract class BaseTerminalServer implements IBaseTerminalServer { }; } - async getEnvVarCollectionDescriptionsByExtension(id: number): Promise> { - const terminal = this.processManager.get(id); - if (!(terminal instanceof TerminalProcess)) { - throw new Error(`terminal "${id}" does not exist`); - } - const result = new Map(); - this.collections.forEach((value, key) => { - result.set(key, value.description); - }); - return result; - } - async getCwdURI(id: number): Promise { const terminal = this.processManager.get(id); if (!(terminal instanceof TerminalProcess)) { @@ -195,117 +170,4 @@ export abstract class BaseTerminalServer implements IBaseTerminalServer { const toDispose = this.notifyClientOnExit(term); this.terminalToDispose.set(term.id, toDispose); } - - /*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - // some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts - - setCollection(extensionIdentifier: string, persistent: boolean, collection: SerializableEnvironmentVariableCollection, description: string | MarkdownString | undefined): void { - const translatedCollection = { persistent, description, map: new Map(collection) }; - this.collections.set(extensionIdentifier, translatedCollection); - this.updateCollections(); - } - - deleteCollection(extensionIdentifier: string): void { - this.collections.delete(extensionIdentifier); - this.updateCollections(); - } - - private updateCollections(): void { - this.persistCollections(); - this.mergedCollection = this.resolveMergedCollection(); - } - - protected persistCollections(): void { - const collectionsJson: SerializableExtensionEnvironmentVariableCollection[] = []; - this.collections.forEach((collection, extensionIdentifier) => { - if (collection.persistent) { - collectionsJson.push({ - extensionIdentifier, - collection: [...this.collections.get(extensionIdentifier)!.map.entries()], - description: collection.description - }); - } - }); - if (this.client) { - const stringifiedJson = JSON.stringify(collectionsJson); - this.client.storeTerminalEnvVariables(stringifiedJson); - } - } - - private resolveMergedCollection(): MergedEnvironmentVariableCollection { - return new MergedEnvironmentVariableCollectionImpl(this.collections); - } - -} - -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariableCollection.ts - -export class MergedEnvironmentVariableCollectionImpl implements MergedEnvironmentVariableCollection { - readonly map: Map = new Map(); - - constructor(collections: Map) { - collections.forEach((collection, extensionIdentifier) => { - const it = collection.map.entries(); - let next = it.next(); - while (!next.done) { - const variable = next.value[0]; - let entry = this.map.get(variable); - if (!entry) { - entry = []; - this.map.set(variable, entry); - } - - // If the first item in the entry is replace ignore any other entries as they would - // just get replaced by this one. - if (entry.length > 0 && entry[0].type === EnvironmentVariableMutatorType.Replace) { - next = it.next(); - continue; - } - - // Mutators get applied in the reverse order than they are created - const mutator = next.value[1]; - entry.unshift({ - extensionIdentifier, - value: mutator.value, - type: mutator.type, - options: mutator.options - }); - - next = it.next(); - } - }); - } - - applyToProcessEnvironment(env: { [key: string]: string | null }): void { - let lowerToActualVariableNames: { [lowerKey: string]: string | undefined } | undefined; - if (isWindows) { - lowerToActualVariableNames = {}; - Object.keys(env).forEach(e => lowerToActualVariableNames![e.toLowerCase()] = e); - } - this.map.forEach((mutators, variable) => { - const actualVariable = isWindows ? lowerToActualVariableNames![variable.toLowerCase()] || variable : variable; - mutators.forEach(mutator => { - if (mutator.options?.applyAtProcessCreation ?? true) { - switch (mutator.type) { - case EnvironmentVariableMutatorType.Append: - env[actualVariable] = (env[actualVariable] || '') + mutator.value; - break; - case EnvironmentVariableMutatorType.Prepend: - env[actualVariable] = mutator.value + (env[actualVariable] || ''); - break; - case EnvironmentVariableMutatorType.Replace: - env[actualVariable] = mutator.value; - break; - } - } - }); - }); - } } diff --git a/packages/terminal/src/node/shell-process.ts b/packages/terminal/src/node/shell-process.ts index 971d4d4c24c84..524a697082c7e 100644 --- a/packages/terminal/src/node/shell-process.ts +++ b/packages/terminal/src/node/shell-process.ts @@ -39,7 +39,7 @@ export interface ShellProcessOptions { isPseudo?: boolean, } -function getRootPath(rootURI?: string): string { +export function getRootPath(rootURI?: string): string { if (rootURI) { const uri = new URI(rootURI); return FileUri.fsPath(uri); diff --git a/packages/terminal/src/node/shell-terminal-server.ts b/packages/terminal/src/node/shell-terminal-server.ts index 543c5cf4badf7..63eded6a30618 100644 --- a/packages/terminal/src/node/shell-terminal-server.ts +++ b/packages/terminal/src/node/shell-terminal-server.ts @@ -17,18 +17,32 @@ import { inject, injectable, named } from '@theia/core/shared/inversify'; import { ILogger } from '@theia/core/lib/common/logger'; import { EnvironmentUtils } from '@theia/core/lib/node/environment-utils'; -import { IShellTerminalServerOptions } from '../common/shell-terminal-protocol'; import { BaseTerminalServer } from './base-terminal-server'; -import { ShellProcessFactory } from './shell-process'; -import { ProcessManager } from '@theia/process/lib/node'; +import { ShellProcessFactory, getRootPath } from './shell-process'; +import { ProcessManager, TerminalProcess } from '@theia/process/lib/node'; import { isWindows } from '@theia/core/lib/common/os'; import * as cp from 'child_process'; +import { + EnvironmentVariableCollectionWithPersistence, EnvironmentVariableMutatorType, NO_ROOT_URI, SerializableEnvironmentVariableCollection, + IShellTerminalServer, IShellTerminalServerOptions +} + from '../common/shell-terminal-protocol'; +import { URI } from '@theia/core'; +import { MultiKeyMap } from '@theia/core/lib/common/collections'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string'; -@injectable() -export class ShellTerminalServer extends BaseTerminalServer { +interface SerializedExtensionEnvironmentVariableCollection { + extensionIdentifier: string, + rootUri: string, + collection: SerializableEnvironmentVariableCollection, +} +@injectable() +export class ShellTerminalServer extends BaseTerminalServer implements IShellTerminalServer { @inject(EnvironmentUtils) protected environmentUtils: EnvironmentUtils; + readonly collections: MultiKeyMap = new MultiKeyMap(2); + constructor( @inject(ShellProcessFactory) protected readonly shellFactory: ShellProcessFactory, @inject(ProcessManager) processManager: ProcessManager, @@ -40,7 +54,7 @@ export class ShellTerminalServer extends BaseTerminalServer { try { if (options.strictEnv !== true) { options.env = this.environmentUtils.mergeProcessEnv(options.env); - this.mergedCollection.applyToProcessEnvironment(options.env); + this.applyToProcessEnvironment(URI.fromFilePath(getRootPath(options.rootURI)), options.env); } const term = this.shellFactory(options); this.postCreate(term); @@ -92,4 +106,116 @@ export class ShellTerminalServer extends BaseTerminalServer { // fall back to safe side return Promise.resolve(true); } + + applyToProcessEnvironment(cwdUri: URI, env: { [key: string]: string | null }): void { + let lowerToActualVariableNames: { + [lowerKey: string]: string | undefined + } | undefined; + if (isWindows) { + lowerToActualVariableNames = {}; + Object.keys(env).forEach(e => lowerToActualVariableNames![e.toLowerCase()] = e); + } + this.collections.forEach((mutators, [extensionIdentifier, rootUri]) => { + if (rootUri === NO_ROOT_URI || this.matchesRootUri(cwdUri, rootUri)) { + mutators.variableMutators.forEach((mutator, variable) => { + const actualVariable = isWindows ? lowerToActualVariableNames![variable.toLowerCase()] || variable : variable; + switch (mutator.type) { + case EnvironmentVariableMutatorType.Append: + env[actualVariable] = (env[actualVariable] || '') + mutator.value; + break; + case EnvironmentVariableMutatorType.Prepend: + env[actualVariable] = mutator.value + (env[actualVariable] || ''); + break; + case EnvironmentVariableMutatorType.Replace: + env[actualVariable] = mutator.value; + break; + } + }); + } + }); + } + + matchesRootUri(cwdUri: URI, rootUri: string): boolean { + return new URI(rootUri).isEqualOrParent(cwdUri); + } + + /*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + // some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts + + setCollection(extensionIdentifier: string, baseUri: string, persistent: boolean, + collection: SerializableEnvironmentVariableCollection): void { + this.doSetCollection(extensionIdentifier, baseUri, persistent, collection); + this.updateCollections(); + } + + private doSetCollection(extensionIdentifier: string, baseUri: string, persistent: boolean, + collection: SerializableEnvironmentVariableCollection): void { + this.collections.set([extensionIdentifier, baseUri], { + persistent: persistent, + description: collection.description, + variableMutators: new Map(collection.mutators) + }); + } + + restorePersisted(jsonValue: string): void { + const collectionsJson: SerializedExtensionEnvironmentVariableCollection[] = JSON.parse(jsonValue); + collectionsJson.forEach(c => this.doSetCollection(c.extensionIdentifier, c.rootUri ?? NO_ROOT_URI, true, c.collection)); + + } + + deleteCollection(extensionIdentifier: string): void { + this.collections.delete([extensionIdentifier]); + this.updateCollections(); + } + + private updateCollections(): void { + this.persistCollections(); + } + + protected persistCollections(): void { + const collectionsJson: SerializedExtensionEnvironmentVariableCollection[] = []; + this.collections.forEach((collection, [extensionIdentifier, rootUri]) => { + if (collection.persistent) { + collectionsJson.push({ + extensionIdentifier, + rootUri, + collection: { + description: collection.description, + mutators: [...this.collections.get([extensionIdentifier, rootUri])!.variableMutators.entries()] + }, + }); + } + }); + if (this.client) { + const stringifiedJson = JSON.stringify(collectionsJson); + this.client.storeTerminalEnvVariables(stringifiedJson); + } + } + + async getEnvVarCollectionDescriptionsByExtension(id: number): Promise> { + const terminal = this.processManager.get(id); + if (!(terminal instanceof TerminalProcess)) { + throw new Error(`terminal "${id}" does not exist`); + } + const result = new Map(); + this.collections.forEach((value, key) => { + const prev = result.get(key[0]) || []; + prev.push(value.description); + result.set(key[0], prev); + }); + return result; + } + + async getEnvVarCollections(): Promise<[string, string, boolean, SerializableEnvironmentVariableCollection][]> { + const result: [string, string, boolean, SerializableEnvironmentVariableCollection][] = []; + + this.collections.forEach((value, [extensionIdentifier, rootUri]) => { + result.push([extensionIdentifier, rootUri, value.persistent, { description: value.description, mutators: [...value.variableMutators.entries()] }]); + }); + + return result; + } }