diff --git a/packages/core/i18n/nls.de.json b/packages/core/i18n/nls.de.json index b024910f2bddd..ff6cb23b7f208 100644 --- a/packages/core/i18n/nls.de.json +++ b/packages/core/i18n/nls.de.json @@ -447,12 +447,24 @@ "confirmCloseNever": "Niemals bestätigen.", "enableCopy": "Aktivieren von ctrl-c (cmd-c unter macOS) zum Kopieren von markiertem Text", "enablePaste": "Aktivieren von ctrl-v (cmd-v unter macOS) zum Einfügen aus der Zwischenablage", + "profileNew": "Neues Terminal (mit Profil)...", + "profileDefault": "Standardprofil wählen...", + "selectProfile": "Wählen Sie ein Profil für das neue Terminal", "shellArgsLinux": "Die Befehlszeilenargumente, die im Linux-Terminal zu verwenden sind.", "shellArgsOsx": "Die Befehlszeilenargumente, die im macOS-Terminal zu verwenden sind.", "shellArgsWindows": "Die Befehlszeilenargumente, die im Windows-Terminal zu verwenden sind.", "shellLinux": "Der Pfad der Shell, die das Terminal unter Linux verwendet (Standard: '{0}'}).", "shellOsx": "Der Pfad der Shell, die das Terminal unter macOS verwendet (Standard: '{0}'}).", "shellWindows": "Der Pfad der Shell, die das Terminal unter Windows verwendet. (Standard: '{0}').", + "shell.deprecated": "Dies ist veraltet, neu können Sie Ihre Shell konfigurieren, indem Sie ein Profil unter 'terminal.integrated.profiles.{0}' anlegen und dessen Namen in 'terminal.integrated.defaultProfile.{0}' als Standard setzen.", + "defaultProfile": "Das Standardprofil unter {0}", + "profiles": "Die Profile welche zur Erzeugung eines Terminals verwendet werden können. Setzen Sie den Pfad von Hand mit optionalen Parametern.\n\nSezen Sie ein Profile auf `null` um es zu verbergen, z.B.: `{0}: null`.", + "profilePath": "Der Pfad der Shell, den dieses Profil benutzt.", + "profileSource": "Eine Profilquelle, die Shellpfade automatisch erkennt. Unübliche Installationsorte werden nicht unterstützt und müssen manuell erfasst werden", + "profileArgs": "Die Shellparameter, welche dieses Profil verwendet.", + "profileEnv": "Ein Objekt mit Umgebungsvariablen die zum Terminalprozess hinzugefügt werden. Setzen Sie Variablen auf `null` um sie aus der Basisumgebung zu löschen", + "profileIcon": "Eine codicon ID zur Verwendung mit diesem Terminal. \nterminal-tmux:\"$(terminal-tmux)\"", + "profileColor": "ID einer Terminal-Themenfarbe zur Verwendung mit diesem Terminal.", "terminate": "Ende", "terminateActive": "Möchten Sie die aktive Terminalsitzung beenden?", "terminateActiveMultiple": "Sollen die {0} aktiven Terminalsitzungen beendet werden?" diff --git a/packages/core/src/common/uri.ts b/packages/core/src/common/uri.ts index f83699f7a4c2a..104717e2ba325 100644 --- a/packages/core/src/common/uri.ts +++ b/packages/core/src/common/uri.ts @@ -23,6 +23,10 @@ export class URI { return new URI(Uri.revive(components)); } + public static fromFilePath(path: string): URI { + return new URI(Uri.file(path)); + } + private readonly codeUri: Uri; private _path: Path | undefined; diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index d803933c75dc5..4a4930b08909f 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -269,6 +269,7 @@ export interface CommandRegistryExt { } export interface TerminalServiceExt { + $startProfile(providerId: string, cancellationToken: theia.CancellationToken): Promise; $terminalCreated(id: string, name: string): void; $terminalNameChanged(id: string, name: string): void; $terminalOpened(id: string, processId: number, terminalId: number, cols: number, rows: number): void; diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index f4e84cbbf9d77..2996f4aeeccb4 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -95,6 +95,17 @@ export interface PluginPackageContribution { jsonValidation?: PluginJsonValidationContribution[]; resourceLabelFormatters?: ResourceLabelFormatter[]; localizations?: PluginPackageLocalization[]; + terminal?: PluginPackageTerminal; +} + +export interface PluginPackageTerminalProfile { + title: string, + id: string, + icon?: string +} + +export interface PluginPackageTerminal { + profiles: PluginPackageTerminalProfile[] } export interface PluginPackageLocalization { @@ -555,6 +566,13 @@ export interface PluginContribution { problemPatterns?: ProblemPatternContribution[]; resourceLabelFormatters?: ResourceLabelFormatter[]; localizations?: Localization[]; + terminalProfiles?: TerminalProfile[]; +} + +export interface TerminalProfile { + title: string, + id: string, + icon?: string } export interface Localization { diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 62627760d3eb2..4433013af9f36 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -616,6 +616,10 @@ export class HostedPluginSupport { return this.activateByEvent(`onFileSystem:${event.scheme}`); } + activateByTerminalProfile(profileId: string): Promise { + return this.activateByEvent(`onTerminalProfile:${profileId}`); + } + protected ensureFileSystemActivation(event: FileSystemProviderActivationEvent): void { event.waitUntil(this.activateByFileSystem(event)); } diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 4f3329b8bedce..f4c6cf4939a52 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -59,7 +59,8 @@ import { Localization, PluginPackageTranslation, Translation, - PluginIdentifiers + PluginIdentifiers, + TerminalProfile } from '../../../common/plugin-protocol'; import * as fs from 'fs'; import * as path from 'path'; @@ -358,9 +359,22 @@ export class TheiaPluginScanner implements PluginScanner { console.error(`Could not read '${rawPlugin.name}' contribution 'localizations'.`, rawPlugin.contributes.colors, err); } + try { + contributions.terminalProfiles = this.readTerminals(rawPlugin); + } catch (err) { + console.error(`Could not read '${rawPlugin.name}' contribution 'terminals'.`, rawPlugin.contributes.terminal, err); + } + return contributions; } + protected readTerminals(pck: PluginPackage): TerminalProfile[] | undefined { + if (!pck?.contributes?.terminal?.profiles) { + return undefined; + } + return pck.contributes.terminal.profiles.filter(profile => profile.id && profile.title); + } + protected readLocalizations(pck: PluginPackage): Localization[] | undefined { if (!pck.contributes || !pck.contributes.localizations) { return undefined; diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index 6bdf44b97c676..08fbf79ea39ca 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -43,6 +43,10 @@ import { PluginIconThemeService } from './plugin-icon-theme-service'; import { ContributionProvider } from '@theia/core/lib/common'; import * as monaco from '@theia/monaco-editor-core'; import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; +import { ContributedTerminalProfileStore, TerminalProfileStore } from '@theia/terminal/lib/browser/terminal-profile-service'; +import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; +import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; +import { PluginTerminalRegistry } from './plugin-terminal-registry'; @injectable() export class PluginContributionHandler { @@ -106,6 +110,15 @@ export class PluginContributionHandler { @inject(PluginIconThemeService) protected readonly iconThemeService: PluginIconThemeService; + @inject(TerminalService) + protected readonly terminalService: TerminalService; + + @inject(PluginTerminalRegistry) + protected readonly pluginTerminalRegistry: PluginTerminalRegistry; + + @inject(ContributedTerminalProfileStore) + protected readonly contributedProfileStore: TerminalProfileStore; + @inject(ContributionProvider) @named(LabelProviderContribution) protected readonly contributionProvider: ContributionProvider; @@ -356,6 +369,28 @@ export class PluginContributionHandler { } } + const self = this; + if (contributions.terminalProfiles) { + for (const profile of contributions.terminalProfiles) { + pushContribution(`terminalProfiles.${profile.id}`, () => { + this.contributedProfileStore.registerTerminalProfile(profile.title, { + async start(): Promise { + const terminalId = await self.pluginTerminalRegistry.start(profile.id); + const result = self.terminalService.getById(terminalId); + if (!result) { + throw new Error(`Error starting terminal from profile ${profile.id}`); + } + return result; + + } + }); + return Disposable.create(() => { + this.contributedProfileStore.unregisterTerminalProfile(profile.id); + }); + }); + } + } + return toDispose; } diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 024cb87870599..45cc023761982 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -80,6 +80,7 @@ import { bindTreeViewDecoratorUtilities, TreeViewDecoratorService } from './view import { CodeEditorWidgetUtil } from './menus/vscode-theia-menu-mappings'; import { PluginMenuCommandAdapter } from './menus/plugin-menu-command-adapter'; import './theme-icon-override'; +import { PluginTerminalRegistry } from './plugin-terminal-registry'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -240,4 +241,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(PluginAuthenticationServiceImpl).toSelf().inSingletonScope(); rebind(AuthenticationService).toService(PluginAuthenticationServiceImpl); + + bind(PluginTerminalRegistry).toSelf().inSingletonScope(); }); diff --git a/packages/plugin-ext/src/main/browser/plugin-terminal-registry.ts b/packages/plugin-ext/src/main/browser/plugin-terminal-registry.ts new file mode 100644 index 0000000000000..4eb24b7b9440a --- /dev/null +++ b/packages/plugin-ext/src/main/browser/plugin-terminal-registry.ts @@ -0,0 +1,27 @@ +// ***************************************************************************** +// Copyright (C) 2022 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 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class PluginTerminalRegistry { + + startCallback: (id: string) => Promise; + + start(profileId: string): Promise { + return this.startCallback(profileId); + } +} diff --git a/packages/plugin-ext/src/main/browser/terminal-main.ts b/packages/plugin-ext/src/main/browser/terminal-main.ts index a4a4ae53768ed..d412b1b114e84 100644 --- a/packages/plugin-ext/src/main/browser/terminal-main.ts +++ b/packages/plugin-ext/src/main/browser/terminal-main.ts @@ -16,7 +16,6 @@ import { interfaces } from '@theia/core/shared/inversify'; import { ApplicationShell, WidgetOpenerOptions } from '@theia/core/lib/browser'; -import { CancellationToken } from '@theia/core/shared/vscode-languageserver-protocol'; import { TerminalEditorLocationOptions, TerminalOptions } from '@theia/plugin'; import { TerminalLocation, TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; @@ -28,6 +27,9 @@ import { ShellTerminalServerProxy } from '@theia/terminal/lib/common/shell-termi 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'; +import { PluginTerminalRegistry } from './plugin-terminal-registry'; +import { CancellationToken } from '@theia/core'; +import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; /** * Plugin api service allows working with terminal emulator. @@ -35,6 +37,8 @@ import { getIconClass } from '../../plugin/terminal-ext'; export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLinkProvider, Disposable { private readonly terminals: TerminalService; + private readonly pluginTerminalRegistry: PluginTerminalRegistry; + private readonly hostedPluginSupport: HostedPluginSupport; private readonly shell: ApplicationShell; private readonly extProxy: TerminalServiceExt; private readonly shellTerminalServer: ShellTerminalServerProxy; @@ -44,6 +48,8 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin constructor(rpc: RPCProtocol, container: interfaces.Container) { this.terminals = container.get(TerminalService); + this.pluginTerminalRegistry = container.get(PluginTerminalRegistry); + this.hostedPluginSupport = container.get(HostedPluginSupport); this.shell = container.get(ApplicationShell); this.shellTerminalServer = container.get(ShellTerminalServerProxy); this.extProxy = rpc.getProxy(MAIN_RPC_CONTEXT.TERMINAL_EXT); @@ -59,9 +65,16 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin this.extProxy.$initEnvironmentVariableCollections(serializedCollections); } + this.pluginTerminalRegistry.startCallback = id => this.startProfile(id); + container.bind(TerminalLinkProvider).toDynamicValue(() => this); } + async startProfile(id: string): Promise { + await this.hostedPluginSupport.activateByTerminalProfile(id); + return this.extProxy.$startProfile(id, CancellationToken.None); + } + $setEnvironmentVariableCollection(extensionIdentifier: string, persistent: boolean, collection: SerializableEnvironmentVariableCollection | undefined): void { if (collection) { this.shellTerminalServer.setCollection(extensionIdentifier, persistent, collection); @@ -124,32 +137,28 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin } async $createTerminal(id: string, options: TerminalOptions, parentId?: string, isPseudoTerminal?: boolean): Promise { - try { - const terminal = await this.terminals.newTerminal({ - id, - title: options.name, - iconClass: getIconClass(options), - shellPath: options.shellPath, - shellArgs: options.shellArgs, - cwd: options.cwd ? new URI(options.cwd) : undefined, - env: options.env, - strictEnv: options.strictEnv, - destroyTermOnClose: true, - useServerTitle: false, - attributes: options.attributes, - hideFromUser: options.hideFromUser, - location: this.getTerminalLocation(options, parentId), - isPseudoTerminal, - isTransient: options.isTransient - }); - if (options.message) { - terminal.writeLine(options.message); - } - terminal.start(); - return terminal.id; - } catch (error) { - throw new Error('Failed to create terminal. Cause: ' + error); + const terminal = await this.terminals.newTerminal({ + id, + title: options.name, + iconClass: getIconClass(options), + shellPath: options.shellPath, + shellArgs: options.shellArgs, + cwd: options.cwd ? new URI(options.cwd) : undefined, + env: options.env, + strictEnv: options.strictEnv, + destroyTermOnClose: true, + useServerTitle: false, + attributes: options.attributes, + hideFromUser: options.hideFromUser, + location: this.getTerminalLocation(options, parentId), + isPseudoTerminal, + isTransient: options.isTransient + }); + if (options.message) { + terminal.writeLine(options.message); } + terminal.start(); + return terminal.id; } protected getTerminalLocation(options: TerminalOptions, parentId?: string): TerminalLocation | TerminalEditorLocationOptions | { parentTerminal: string; } | undefined { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 3f9570b8570e0..03d829b74168e 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -154,6 +154,7 @@ import { InputBoxValidationSeverity, TerminalLink, TerminalLocation, + TerminalProfile, InlayHint, InlayHintKind, InlayHintLabelPart, @@ -548,6 +549,9 @@ export function createAPIFactory( registerTerminalLinkProvider(provider: theia.TerminalLinkProvider): theia.Disposable { return terminalExt.registerTerminalLinkProvider(provider); }, + registerTerminalProfileProvider(id: string, provider: theia.TerminalProfileProvider): theia.Disposable { + return terminalExt.registerTerminalProfileProvider(id, provider); + }, get activeColorTheme(): theia.ColorTheme { return themingExt.activeColorTheme; }, @@ -1252,6 +1256,7 @@ export function createAPIFactory( SourceControlInputBoxValidationType, FileDecoration, TerminalLink, + TerminalProfile, CancellationError, ExtensionMode, LinkedEditingRanges, diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index d78afa6a1a33f..baaa96e116491 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -89,6 +89,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { 'workspaceContains', 'onView', 'onUri', + 'onTerminalProfile', 'onWebviewPanel', 'onFileSystem', 'onCustomEditor', diff --git a/packages/plugin-ext/src/plugin/terminal-ext.ts b/packages/plugin-ext/src/plugin/terminal-ext.ts index 28ac7dd315f42..c8b402c6bfe4c 100644 --- a/packages/plugin-ext/src/plugin/terminal-ext.ts +++ b/packages/plugin-ext/src/plugin/terminal-ext.ts @@ -52,9 +52,9 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { private readonly _pseudoTerminals = new Map(); - private static nextTerminalLinkProviderId = 0; + private static nextProviderId = 0; private readonly terminalLinkProviders = new Map(); - + private readonly terminalProfileProviders = new Map(); private readonly onDidCloseTerminalEmitter = new Emitter(); readonly onDidCloseTerminal: theia.Event = this.onDidCloseTerminalEmitter.event; @@ -81,9 +81,9 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { nameOrOptions: TerminalOptions | PseudoTerminalOptions | ExtensionTerminalOptions | (string | undefined), shellPath?: string, shellArgs?: string[] | string ): Terminal { - let options: TerminalOptions | ExtensionTerminalOptions; - let pseudoTerminal: theia.Pseudoterminal | undefined = undefined; const id = `plugin-terminal-${UUID.uuid4()}`; + let options: TerminalOptions; + let pseudoTerminal: theia.Pseudoterminal | undefined = undefined; if (typeof nameOrOptions === 'object') { if ('pty' in nameOrOptions) { pseudoTerminal = nameOrOptions.pty; @@ -220,7 +220,7 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { } registerTerminalLinkProvider(provider: theia.TerminalLinkProvider): theia.Disposable { - const providerId = (TerminalServiceExtImpl.nextTerminalLinkProviderId++).toString(); + const providerId = (TerminalServiceExtImpl.nextProviderId++).toString(); this.terminalLinkProviders.set(providerId, provider); this.proxy.$registerTerminalLinkProvider(providerId); return Disposable.create(() => { @@ -229,6 +229,36 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { }); } + registerTerminalProfileProvider(id: string, provider: theia.TerminalProfileProvider): theia.Disposable { + this.terminalProfileProviders.set(id, provider); + return Disposable.create(() => { + this.terminalProfileProviders.delete(id); + }); + } + + protected isExtensionTerminalOptions(options: theia.TerminalOptions | theia.ExtensionTerminalOptions): options is theia.ExtensionTerminalOptions { + return 'pty' in options; + } + + async $startProfile(profileId: string, cancellationToken: theia.CancellationToken): Promise { + const provider = this.terminalProfileProviders.get(profileId); + if (!provider) { + throw new Error(`No terminal profile provider with id '${profileId}'`); + } + const profile = await provider.provideTerminalProfile(cancellationToken); + if (!profile) { + throw new Error(`Profile with id ${profileId} could not be created`); + } + const id = `plugin-terminal-${UUID.uuid4()}`; + const options = profile.options; + if (this.isExtensionTerminalOptions(options)) { + this._pseudoTerminals.set(id, new PseudoTerminal(id, this.proxy, options.pty)); + return this.proxy.$createTerminal(id, { name: options.name }, undefined, true); + } else { + return this.proxy.$createTerminal(id, profile.options); + } + } + async $provideTerminalLinks(line: string, terminalId: string, token: theia.CancellationToken): Promise { const links: ProvidedTerminalLink[] = []; const terminal = this._terminals.get(terminalId); diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 8042be0c9e55e..f2d08a76d127d 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1974,6 +1974,14 @@ export enum TerminalLocation { Panel = 1, Editor = 2 } +export class TerminalProfile { + /** + * Creates a new terminal profile. + * @param options The options that the terminal will launch with. + */ + constructor(readonly options: theia.TerminalOptions | theia.ExtensionTerminalOptions) { + } +} @es5ClassCompat export class FileDecoration { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 65b48c129cbe8..52511f5f9d240 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -3299,6 +3299,35 @@ export module '@theia/plugin' { parentTerminal: Terminal; } + /* + * Provides a terminal profile for the contributed terminal profile when launched via the UI or + * command. + */ + export interface TerminalProfileProvider { + /** + * Provide the terminal profile. + * @param token A cancellation token that indicates the result is no longer needed. + * @returns The terminal profile. + */ + provideTerminalProfile(token: CancellationToken): ProviderResult; + } + + /** + * A terminal profile defines how a terminal will be launched. + */ + export class TerminalProfile { + /** + * The options that the terminal will launch with. + */ + options: TerminalOptions | ExtensionTerminalOptions; + + /** + * Creates a new terminal profile. + * @param options The options that the terminal will launch with. + */ + constructor(options: TerminalOptions | ExtensionTerminalOptions); + } + /** * A file decoration represents metadata that can be rendered with a file. */ @@ -5316,6 +5345,12 @@ export module '@theia/plugin' { * @return Disposable that unregisters the provider. */ export function registerTerminalLinkProvider(provider: TerminalLinkProvider): Disposable; + /** + * Registers a provider for a contributed terminal profile. + * @param id The ID of the contributed terminal profile. + * @param provider The terminal profile provider. + */ + export function registerTerminalProfileProvider(id: string, provider: TerminalProfileProvider): Disposable; /** * Register a file decoration provider. diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 62ba041bf1f5d..86c0a42a6b9f8 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -7,6 +7,7 @@ "@theia/editor": "1.33.0", "@theia/filesystem": "1.33.0", "@theia/process": "1.33.0", + "@theia/variable-resolver": "1.33.0", "@theia/workspace": "1.33.0", "xterm": "^4.16.0", "xterm-addon-fit": "^0.5.0", diff --git a/packages/terminal/src/browser/shell-terminal-profile.ts b/packages/terminal/src/browser/shell-terminal-profile.ts new file mode 100644 index 0000000000000..109a28171039a --- /dev/null +++ b/packages/terminal/src/browser/shell-terminal-profile.ts @@ -0,0 +1,40 @@ +// ***************************************************************************** +// Copyright (C) 2022 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 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { IShellTerminalServerOptions } from '../common/shell-terminal-protocol'; +import { TerminalService } from './base/terminal-service'; +import { TerminalWidget, TerminalWidgetOptions } from './base/terminal-widget'; +import { TerminalProfile } from './terminal-profile-service'; + +export class ShellTerminalProfile implements TerminalProfile { + constructor(protected readonly terminalService: TerminalService, protected readonly options: TerminalWidgetOptions) { } + + async start(): Promise { + const widget = await this.terminalService.newTerminal(this.options); + widget.start(); + return widget; + } + + /** + * Makes a copy of this profile modified with the options given + * as an argument. + * @param options the options to override + * @returns a modified copy of this profile + */ + modify(options: IShellTerminalServerOptions): TerminalProfile { + return new ShellTerminalProfile(this.terminalService, { ...this.options, ...options }); + } +} diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index 60fbf57c2b52b..520e8e86e0334 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -26,18 +26,20 @@ import { SelectionService, Emitter, Event, - ViewColumn + ViewColumn, + OS } from '@theia/core/lib/common'; import { - ApplicationShell, KeybindingContribution, KeyCode, Key, WidgetManager, + ApplicationShell, KeybindingContribution, KeyCode, Key, WidgetManager, PreferenceService, KeybindingRegistry, Widget, LabelProvider, WidgetOpenerOptions, StorageService, - QuickInputService, codicon, CommonCommands, FrontendApplicationContribution, OnWillStopAction, Dialog, ConfirmDialog + QuickInputService, codicon, CommonCommands, FrontendApplicationContribution, OnWillStopAction, Dialog, ConfirmDialog, FrontendApplication, PreferenceScope } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions, TerminalWidgetImpl } from './terminal-widget-impl'; import { TerminalKeybindingContexts } from './terminal-keybinding-contexts'; import { TerminalService } from './base/terminal-service'; import { TerminalWidgetOptions, TerminalWidget, TerminalLocation } from './base/terminal-widget'; +import { ContributedTerminalProfileStore, NULL_PROFILE, TerminalProfile, TerminalProfileService, TerminalProfileStore, UserTerminalProfileStore } from './terminal-profile-service'; import { UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; import { ShellTerminalServerProxy } from '../common/shell-terminal-protocol'; import URI from '@theia/core/lib/common/uri'; @@ -55,11 +57,14 @@ import { SerializableExtensionEnvironmentVariableCollection } from '../common/base-terminal-protocol'; import { nls } from '@theia/core/lib/common/nls'; -import { TerminalPreferences } from './terminal-preferences'; +import { Profiles, TerminalPreferences } from './terminal-preferences'; +import { ShellTerminalProfile } from './shell-terminal-profile'; +import { VariableResolverService } from '@theia/variable-resolver/lib/browser'; export namespace TerminalMenus { export const TERMINAL = [...MAIN_MENU_BAR, '7_terminal']; export const TERMINAL_NEW = [...TERMINAL, '1_terminal']; + export const TERMINAL_TASKS = [...TERMINAL, '2_terminal']; export const TERMINAL_TASKS_INFO = [...TERMINAL_TASKS, '3_terminal']; export const TERMINAL_TASKS_CONFIG = [...TERMINAL_TASKS, '4_terminal']; @@ -74,6 +79,16 @@ export namespace TerminalCommands { category: TERMINAL_CATEGORY, label: 'Create New Integrated Terminal' }); + export const PROFILE_NEW = Command.toLocalizedCommand({ + id: 'terminal:new:profile', + category: TERMINAL_CATEGORY, + label: 'Create New Integrated Terminal from a Profile' + }); + export const PROFILE_DEFAULT = Command.toLocalizedCommand({ + id: 'terminal:profile:default', + category: TERMINAL_CATEGORY, + label: 'Choose the default Terminal Profile' + }); export const NEW_ACTIVE_WORKSPACE = Command.toDefaultLocalizedCommand({ id: 'terminal:new:active:workspace', category: TERMINAL_CATEGORY, @@ -165,15 +180,32 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(TerminalProfileService) + protected readonly profileService: TerminalProfileService; + + @inject(UserTerminalProfileStore) + protected readonly userProfileStore: TerminalProfileStore; + + @inject(ContributedTerminalProfileStore) + protected readonly contributedProfileStore: TerminalProfileStore; + @inject(TerminalWatcher) protected readonly terminalWatcher: TerminalWatcher; + @inject(VariableResolverService) + protected readonly variableResolver: VariableResolverService; + @inject(StorageService) protected readonly storageService: StorageService; + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + @inject(TerminalPreferences) protected terminalPreferences: TerminalPreferences; + protected mergePreferencesPromise: Promise = Promise.resolve(); + protected readonly onDidCreateTerminalEmitter = new Emitter(); readonly onDidCreateTerminal: Event = this.onDidCreateTerminalEmitter.event; @@ -212,6 +244,145 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu }); } + async onStart(app: FrontendApplication): Promise { + await this.contributeDefaultProfiles(); + + this.terminalPreferences.onPreferenceChanged(e => { + if (e.preferenceName.startsWith('terminal.integrated.')) { + this.mergePreferencesPromise = this.mergePreferencesPromise.finally(() => this.mergePreferences()); + } + }); + this.mergePreferencesPromise = this.mergePreferencesPromise.finally(() => this.mergePreferences()); + + this.profileService.onAdded(id => { + // extension contributions get read after this point: need to set the default profile if necessary + let defaultProfileId; + switch (OS.backend.type()) { + case OS.Type.Windows: { + defaultProfileId = this.terminalPreferences['terminal.integrated.defaultProfile.windows']; + break; + } + case OS.Type.Linux: { + defaultProfileId = this.terminalPreferences['terminal.integrated.defaultProfile.linux']; + break; + } + case OS.Type.OSX: { + defaultProfileId = this.terminalPreferences['terminal.integrated.defaultProfile.osx']; + break; + } + } + this.profileService.setDefaultProfile(defaultProfileId); + }); + } + + async contributeDefaultProfiles(): Promise { + if (OS.backend.isWindows) { + this.contributedProfileStore.registerTerminalProfile('cmd', new ShellTerminalProfile(this, { + shellPath: await this.resolveShellPath([ + '${env:windir}\\Sysnative\\cmd.exe', + '${env:windir}\\System32\\cmd.exe' + ])! + })); + } else { + this.contributedProfileStore.registerTerminalProfile('SHELL', new ShellTerminalProfile(this, { + shellPath: await this.resolveShellPath('${SHELL}')!, + shellArgs: ['-l'] + })); + } + + // contribute default profiles based on legacy preferences + } + + protected async mergePreferences(): Promise { + let profiles: Profiles; + let defaultProfile: string; + let legacyShellPath: string | undefined; + let legacyShellArgs: string[] | undefined; + const removed = new Set(this.userProfileStore.all.map(([id, profile]) => id)); + switch (OS.backend.type()) { + case OS.Type.Windows: { + profiles = this.terminalPreferences['terminal.integrated.profiles.windows']; + defaultProfile = this.terminalPreferences['terminal.integrated.defaultProfile.windows']; + legacyShellPath = this.terminalPreferences['terminal.integrated.shell.windows'] ?? undefined; + legacyShellArgs = this.terminalPreferences['terminal.integrated.shellArgs.windows']; + break; + } + case OS.Type.Linux: { + profiles = this.terminalPreferences['terminal.integrated.profiles.linux']; + defaultProfile = this.terminalPreferences['terminal.integrated.defaultProfile.linux']; + legacyShellPath = this.terminalPreferences['terminal.integrated.shell.linux'] ?? undefined; + legacyShellArgs = this.terminalPreferences['terminal.integrated.shellArgs.linux']; + break; + } + case OS.Type.OSX: { + profiles = this.terminalPreferences['terminal.integrated.profiles.osx']; + defaultProfile = this.terminalPreferences['terminal.integrated.defaultProfile.osx']; + legacyShellPath = this.terminalPreferences['terminal.integrated.shell.osx'] ?? undefined; + legacyShellArgs = this.terminalPreferences['terminal.integrated.shellArgs.osx']; + break; + } + } + if (profiles) { + for (const id of Object.getOwnPropertyNames(profiles)) { + const profile = profiles[id]; + removed.delete(id); + if (profile) { + const shellPath = await this.resolveShellPath(profile.path); + + if (shellPath) { + const options: TerminalWidgetOptions = { + shellPath: shellPath, + shellArgs: profile.args ? await this.variableResolver.resolve(profile.args) : undefined, + useServerTitle: profile.overrideName ? false : undefined, + env: profile.env ? await this.variableResolver.resolve(profile.env) : undefined, + title: profile.overrideName ? id : undefined + }; + + this.userProfileStore.registerTerminalProfile(id, new ShellTerminalProfile(this, options)); + } + } else { + this.userProfileStore.registerTerminalProfile(id, NULL_PROFILE); + } + } + } + + if (legacyShellPath) { + this.userProfileStore.registerTerminalProfile('Legacy Shell Preferences', new ShellTerminalProfile(this, { + shellPath: legacyShellPath!, + shellArgs: legacyShellArgs + })); + // if no other default is set, use the legacy preferences as default if they exist + this.profileService.setDefaultProfile('Legacy Shell Preferences'); + } + + if (defaultProfile && this.profileService.getProfile(defaultProfile)) { + this.profileService.setDefaultProfile(defaultProfile); + } + + for (const id of removed) { + this.userProfileStore.unregisterTerminalProfile(id); + } + } + + protected async resolveShellPath(path: string | string[] | undefined): Promise { + if (!path) { + return undefined; + } + if (typeof path === 'string') { + path = [path]; + } + for (const p of path) { + const resolved = await this.variableResolver.resolve(p); + if (resolved) { + const resolvedURI = URI.fromFilePath(resolved); + if (await this.fileService.exists(resolvedURI)) { + return resolved; + } + } + } + return undefined; + } + onWillStop(): OnWillStopAction | undefined { const preferenceValue = this.terminalPreferences['terminal.integrated.confirmOnExit']; if (preferenceValue !== 'never') { @@ -338,6 +509,15 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu commands.registerCommand(TerminalCommands.NEW, { execute: () => this.openTerminal() }); + + commands.registerCommand(TerminalCommands.PROFILE_NEW, { + execute: () => this.openTerminalFromProfile() + }); + + commands.registerCommand(TerminalCommands.PROFILE_DEFAULT, { + execute: () => this.chooseDefaultProfile() + }); + commands.registerCommand(TerminalCommands.NEW_ACTIVE_WORKSPACE, { execute: () => this.openActiveWorkspaceTerminal() }); @@ -475,9 +655,20 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu order: '0' }); menus.registerMenuAction(TerminalMenus.TERMINAL_NEW, { - commandId: TerminalCommands.SPLIT.id, + commandId: TerminalCommands.PROFILE_NEW.id, + label: nls.localize('theia/terminal/profileNew', 'New Terminal (With Profile)...'), order: '1' }); + + menus.registerMenuAction(TerminalMenus.TERMINAL_NEW, { + commandId: TerminalCommands.PROFILE_DEFAULT.id, + label: nls.localize('theia/terminal/profileDefault', 'Choose Default Profile...'), + order: '3' + }); + menus.registerMenuAction(TerminalMenus.TERMINAL_NEW, { + commandId: TerminalCommands.SPLIT.id, + order: '3' + }); menus.registerMenuAction(TerminalMenus.TERMINAL_NAVIGATOR_CONTEXT_MENU, { commandId: TerminalCommands.TERMINAL_CONTEXT.id, order: 'z' @@ -710,6 +901,24 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu }); } + protected async selectTerminalProfile(placeholder: string): Promise<[string, TerminalProfile] | undefined> { + return new Promise(async resolve => { + const profiles = this.profileService.all; + if (profiles.length === 0) { + resolve(undefined); + } else { + const items = profiles.map(([id, profile]) => ({ + label: id, + profile + })); + const selectedItem = await this.quickInputService?.showQuickPick(items, { + placeholder + }); + resolve(selectedItem ? [selectedItem.label, selectedItem.profile] : undefined); + } + }); + } + protected async splitTerminal(widget?: Widget): Promise { const ref = this.getTerminalRef(widget); if (ref) { @@ -724,11 +933,43 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu protected async openTerminal(options?: ApplicationShell.WidgetOptions): Promise { const cwd = await this.selectTerminalCwd(); - const termWidget = await this.newTerminal({ cwd }); - termWidget.start(); + let profile = this.profileService.defaultProfile; + + if (!profile) { + throw new Error('There are not profiles registered'); + } + if (profile instanceof ShellTerminalProfile) { + profile = profile.modify({ rootURI: cwd }); + } + + const termWidget = await profile?.start(); + this.open(termWidget, { widgetOptions: options }); } + protected async openTerminalFromProfile(options?: ApplicationShell.WidgetOptions): Promise { + const result = await this.selectTerminalProfile(nls.localize('theia/terminal/selectProfile', 'Select a profile for the new terminal')); + if (!result) { + return; + } + let profile = result[1]; + if (profile instanceof ShellTerminalProfile) { + const cwd = await this.selectTerminalCwd(); + profile = profile.modify({ rootURI: cwd }); + } + const termWidget = await profile.start(); + this.open(termWidget, { widgetOptions: options }); + } + + protected async chooseDefaultProfile(): Promise { + const result = await this.selectTerminalProfile(nls.localizeByDefault('Select your default terminal profile')); + if (!result) { + return; + } + + this.preferenceService.set(`terminal.integrated.defaultProfile.${OS.backend.type().toLowerCase()}`, result[0], PreferenceScope.User); + } + protected async openActiveWorkspaceTerminal(options?: ApplicationShell.WidgetOptions): Promise { const termWidget = await this.newTerminal({}); termWidget.start(); diff --git a/packages/terminal/src/browser/terminal-frontend-module.ts b/packages/terminal/src/browser/terminal-frontend-module.ts index 1beb7be95f48a..bcc0452c38510 100644 --- a/packages/terminal/src/browser/terminal-frontend-module.ts +++ b/packages/terminal/src/browser/terminal-frontend-module.ts @@ -43,6 +43,10 @@ import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/qui import { createXtermLinkFactory, TerminalLinkProvider, TerminalLinkProviderContribution, XtermLinkFactory } from './terminal-link-provider'; import { UrlLinkProvider } from './terminal-url-link-provider'; import { FileDiffPostLinkProvider, FileDiffPreLinkProvider, FileLinkProvider } from './terminal-file-link-provider'; +import { + ContributedTerminalProfileStore, DefaultProfileStore, DefaultTerminalProfileService, + TerminalProfileService, TerminalProfileStore, UserTerminalProfileStore +} from './terminal-profile-service'; export default new ContainerModule(bind => { bindTerminalPreferences(bind); @@ -123,5 +127,13 @@ export default new ContainerModule(bind => { bind(FileDiffPostLinkProvider).toSelf().inSingletonScope(); bind(TerminalLinkProvider).toService(FileDiffPostLinkProvider); + bind(ContributedTerminalProfileStore).to(DefaultProfileStore).inSingletonScope(); + bind(UserTerminalProfileStore).to(DefaultProfileStore).inSingletonScope(); + bind(TerminalProfileService).toDynamicValue(ctx => { + const userStore = ctx.container.get(UserTerminalProfileStore); + const contributedStore = ctx.container.get(ContributedTerminalProfileStore); + return new DefaultTerminalProfileService(userStore, contributedStore); + }).inSingletonScope(); + bind(FrontendApplicationContribution).to(TerminalFrontendContribution); }); diff --git a/packages/terminal/src/browser/terminal-preferences.ts b/packages/terminal/src/browser/terminal-preferences.ts index f1baaf31b83bb..93a2343fe63f2 100644 --- a/packages/terminal/src/browser/terminal-preferences.ts +++ b/packages/terminal/src/browser/terminal-preferences.ts @@ -17,9 +17,56 @@ /* eslint-disable max-len */ import { interfaces } from '@theia/core/shared/inversify'; -import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; +import { IJSONSchema } from '@theia/core/lib/common/json-schema'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/browser'; import { nls } from '@theia/core/lib/common/nls'; import { editorGeneratedPreferenceProperties } from '@theia/editor/lib/browser/editor-generated-preference-schema'; +import { OS } from '@theia/core'; +import { terminalAnsiColorMap } from './terminal-theme-service'; + +const commonProfileProperties: PreferenceSchemaProperties = { + env: { + type: 'object', + additionalProperties: { + type: 'string' + }, + markdownDescription: nls.localize('theia/terminal/profileEnv', 'An object with environment variables that will be added to the terminal profile process. Set to `null` to delete environment variables from the base environment.'), + }, + overrideName: { + type: 'boolean', + description: nls.localizeByDefault('Controls whether or not the profile name overrides the auto detected one.') + }, + icon: { + type: 'string', + markdownDescription: nls.localize('theia/terminal/profileIcon', 'A codicon ID to associate with the terminal icon.\nterminal-tmux:"$(terminal-tmux)"') + }, + color: { + type: 'string', + enum: Object.getOwnPropertyNames(terminalAnsiColorMap), + description: nls.localize('theia/terminal/profileColor', 'A terminal theme color ID to associate with the terminal.') + } +}; + +const stringOrStringArray: IJSONSchema = { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string' + } + } + ] +}; + +const pathProperty: IJSONSchema = { + description: nls.localize('theia/terminal/profilePath', 'The path of the shell that this profile uses.'), + ...stringOrStringArray +}; + +function shellArgsDeprecatedMessage(type: OS.Type): string { + return nls.localize('theia/terminal/shell.deprecated', 'This is deprecated, the new recommended way to configure your default shell is by creating a terminal profile in \'terminal.integrated.profiles.{0}\' and setting its profile name as the default in \'terminal.integrated.defaultProfile.{0}.\'', type.toString().toLowerCase()); +} export const TerminalConfigSchema: PreferenceSchema = { type: 'object', @@ -113,34 +160,40 @@ export const TerminalConfigSchema: PreferenceSchema = { type: ['string', 'null'], typeDetails: { isFilepath: true }, markdownDescription: nls.localize('theia/terminal/shellWindows', 'The path of the shell that the terminal uses on Windows. (default: \'{0}\').', 'C:\\Windows\\System32\\cmd.exe'), - default: undefined + default: undefined, + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.Windows), }, 'terminal.integrated.shell.osx': { type: ['string', 'null'], markdownDescription: nls.localize('theia/terminal/shellOsx', 'The path of the shell that the terminal uses on macOS (default: \'{0}\'}).', '/bin/bash'), - default: undefined + default: undefined, + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.OSX), }, 'terminal.integrated.shell.linux': { type: ['string', 'null'], markdownDescription: nls.localize('theia/terminal/shellLinux', 'The path of the shell that the terminal uses on Linux (default: \'{0}\'}).', '/bin/bash'), - default: undefined + default: undefined, + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.Linux), }, 'terminal.integrated.shellArgs.windows': { type: 'array', markdownDescription: nls.localize('theia/terminal/shellArgsWindows', 'The command line arguments to use when on the Windows terminal.'), - default: [] + default: [], + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.Windows), }, 'terminal.integrated.shellArgs.osx': { type: 'array', markdownDescription: nls.localize('theia/terminal/shellArgsOsx', 'The command line arguments to use when on the macOS terminal.'), default: [ '-l' - ] + ], + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.OSX), }, 'terminal.integrated.shellArgs.linux': { type: 'array', markdownDescription: nls.localize('theia/terminal/shellArgsLinux', 'The command line arguments to use when on the Linux terminal.'), - default: [] + default: [], + deprecationMessage: shellArgsDeprecatedMessage(OS.Type.Linux), }, 'terminal.integrated.confirmOnExit': { type: 'string', @@ -158,7 +211,156 @@ export const TerminalConfigSchema: PreferenceSchema = { type: 'boolean', description: nls.localizeByDefault('Persist terminal sessions for the workspace across window reloads.'), default: true - } + }, + 'terminal.integrated.defaultProfile.windows': { + type: 'string', + description: nls.localize('theia/terminal/defaultProfile', 'The default profile used on {0}', OS.Type.Windows.toString()) + + }, + 'terminal.integrated.defaultProfile.linux': { + type: 'string', + description: nls.localize('theia/terminal/defaultProfile', 'The default profile used on {0}', OS.Type.Linux.toString()) + + }, + 'terminal.integrated.defaultProfile.osx': { + type: 'string', + description: nls.localize('theia/terminal/defaultProfile', 'The default profile used on {0}', OS.Type.OSX.toString()) + }, + 'terminal.integrated.profiles.windows': { + markdownDescription: nls.localize('theia/terminal/profiles', 'The profiles to present when creating a new terminal. Set the path property manually with optional args.\nSet an existing profile to `null` to hide the profile from the list, for example: `"{0}": null`.', 'cmd'), + anyOf: [ + { + type: 'object', + properties: { + }, + additionalProperties: { + oneOf: [{ + type: 'object', + additionalProperties: false, + properties: { + path: pathProperty, + args: { + ...stringOrStringArray, + description: nls.localize('theia/terminal/profileArgs', 'The shell arguments that this profile uses.'), + + }, + ...commonProfileProperties + }, + required: ['path'] + }, + { + type: 'object', + additionalProperties: false, + properties: { + source: { + type: 'string', + description: nls.localize('theia/terminal/profileSource', 'A profile source that will auto detect the paths to the shell. Note that non-standard executable locations are not supported and must be created manually in a new profile.') + }, + args: { + ...stringOrStringArray, + description: nls.localize('theia/terminal/profileArgs', 'The shell arguments that this profile uses.'), + + }, + ...commonProfileProperties + }, + required: ['source'], + default: { + path: 'C:\\Windows\\System32\\cmd.exe' + } + + }, { + type: 'null' + }] + }, + default: { + cmd: { + path: 'C:\\Windows\\System32\\cmd.exe' + } + } + }, + { type: 'null' } + ] + }, + 'terminal.integrated.profiles.linux': { + markdownDescription: nls.localize('theia/terminal/profiles', 'The profiles to present when creating a new terminal. Set the path property manually with optional args.\nSet an existing profile to `null` to hide the profile from the list, for example: `"{0}": null`.', 'bash'), + anyOf: [{ + type: 'object', + properties: { + }, + additionalProperties: { + oneOf: [ + { + type: 'object', + properties: { + path: pathProperty, + args: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('theia/terminal/profileArgs', 'The shell arguments that this profile uses.'), + }, + ...commonProfileProperties + }, + required: ['path'], + additionalProperties: false, + }, + { type: 'null' } + ] + }, + default: { + path: '${env:SHELL}', + args: ['-l'] + } + + }, + { type: 'null' } + ] + }, + 'terminal.integrated.profiles.osx': { + markdownDescription: nls.localize('theia/terminal/profiles', 'The profiles to present when creating a new terminal. Set the path property manually with optional args.\nSet an existing profile to `null` to hide the profile from the list, for example: `"{0}": null`.', 'zsh'), + anyOf: [{ + type: 'object', + properties: { + }, + additionalProperties: { + oneOf: [ + { + type: 'object', + properties: { + path: pathProperty, + args: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('theia/terminal/profileArgs', 'The shell arguments that this profile uses.'), + }, + ...commonProfileProperties + }, + required: ['path'], + additionalProperties: false, + }, + { type: 'null' } + ] + }, + default: { + path: '${env:SHELL}', + args: ['-l'] + } + + }, + { type: 'null' } + ] + }, + } +}; + +export type Profiles = null | { + [key: string]: { + path?: string | string[], + source?: string, + args?: string | string[], + env?: { [key: string]: string }, + overrideName?: boolean; + icon?: string, + color?: string } }; @@ -186,7 +388,13 @@ export interface TerminalConfiguration { 'terminal.integrated.shellArgs.windows': string[], 'terminal.integrated.shellArgs.osx': string[], 'terminal.integrated.shellArgs.linux': string[], - 'terminal.integrated.confirmOnExit': ConfirmOnExitType, + 'terminal.integrated.defaultProfile.windows': string, + 'terminal.integrated.defaultProfile.linux': string, + 'terminal.integrated.defaultProfile.osx': string, + 'terminal.integrated.profiles.windows': Profiles + 'terminal.integrated.profiles.linux': Profiles, + 'terminal.integrated.profiles.osx': Profiles, + 'terminal.integrated.confirmOnExit': ConfirmOnExitType 'terminal.integrated.enablePersistentSessions': boolean } diff --git a/packages/terminal/src/browser/terminal-profile-service.ts b/packages/terminal/src/browser/terminal-profile-service.ts new file mode 100644 index 0000000000000..30f60f859ba8b --- /dev/null +++ b/packages/terminal/src/browser/terminal-profile-service.ts @@ -0,0 +1,170 @@ +// ***************************************************************************** +// Copyright (C) 2022 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 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Emitter, Event } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { TerminalWidget } from './base/terminal-widget'; + +export const TerminalProfileService = Symbol('TerminalProfileService'); +export const ContributedTerminalProfileStore = Symbol('ContributedTerminalProfileStore'); +export const UserTerminalProfileStore = Symbol('UserTerminalProfileStore'); + +export interface TerminalProfile { + start(): Promise; +} + +export const NULL_PROFILE: TerminalProfile = { + start: async () => { throw new Error('you cannot start a null profile'); } +}; + +export interface TerminalProfileService { + onAdded: Event; + onRemoved: Event; + getProfile(id: string): TerminalProfile | undefined + readonly all: [string, TerminalProfile][]; + setDefaultProfile(id: string): void; + readonly defaultProfile: TerminalProfile | undefined; +} + +export interface TerminalProfileStore { + onAdded: Event<[string, TerminalProfile]>; + onRemoved: Event; + registerTerminalProfile(id: string, profile: TerminalProfile): void; + unregisterTerminalProfile(id: string): void; + hasProfile(id: string): boolean; + getProfile(id: string): TerminalProfile | undefined + readonly all: [string, TerminalProfile][]; +} + +@injectable() +export class DefaultProfileStore implements TerminalProfileStore { + protected readonly onAddedEmitter: Emitter<[string, TerminalProfile]> = new Emitter(); + protected readonly onRemovedEmitter: Emitter = new Emitter(); + protected readonly profiles: Map = new Map(); + + onAdded: Event<[string, TerminalProfile]> = this.onAddedEmitter.event; + onRemoved: Event = this.onRemovedEmitter.event; + + registerTerminalProfile(id: string, profile: TerminalProfile): void { + this.profiles.set(id, profile); + this.onAddedEmitter.fire([id, profile]); + } + unregisterTerminalProfile(id: string): void { + this.profiles.delete(id); + this.onRemovedEmitter.fire(id); + } + + hasProfile(id: string): boolean { + return this.profiles.has(id); + } + + getProfile(id: string): TerminalProfile | undefined { + return this.profiles.get(id); + } + get all(): [string, TerminalProfile][] { + return [...this.profiles.entries()]; + } +} + +@injectable() +export class DefaultTerminalProfileService implements TerminalProfileService { + protected defaultProfileIndex = 0; + protected order: string[] = []; + protected readonly stores: TerminalProfileStore[]; + + protected readonly onAddedEmitter: Emitter = new Emitter(); + protected readonly onRemovedEmitter: Emitter = new Emitter(); + + onAdded: Event = this.onAddedEmitter.event; + onRemoved: Event = this.onRemovedEmitter.event; + + constructor(...stores: TerminalProfileStore[]) { + this.stores = stores; + for (const store of this.stores) { + store.onAdded(e => { + if (e[1] === NULL_PROFILE) { + this.handleRemoved(e[0]); + } else { + this.handleAdded(e[0]); + } + }); + store.onRemoved(id => { + if (!this.getProfile(id)) { + this.handleRemoved(id); + } else { + // we may have removed a null profile + this.handleAdded(id); + } + }); + } + } + + handleRemoved(id: string): void { + const index = this.order.indexOf(id); + if (index >= 0 && !this.getProfile(id)) { + // the profile was removed, but it's still in the `order` array + this.order.splice(index, 1); + this.defaultProfileIndex = Math.max(0, Math.min(this.order.length - 1, index)); + this.onRemovedEmitter.fire(id); + } + } + + handleAdded(id: string): void { + const index = this.order.indexOf(id); + if (index < 0) { + this.order.push(id); + this.onAddedEmitter.fire(id); + } + } + + get defaultProfile(): TerminalProfile | undefined { + const id = this.order[this.defaultProfileIndex]; + if (id) { + return this.getProfile(id); + } + return undefined; + } + + setDefaultProfile(id: string): void { + const profile = this.getProfile(id); + if (!profile) { + throw new Error(`Cannot set default to unknown profile '${id}' `); + } + this.defaultProfileIndex = this.order.indexOf(id); + } + + getProfile(id: string): TerminalProfile | undefined { + for (const store of this.stores) { + if (store.hasProfile(id)) { + const found = store.getProfile(id); + return found === NULL_PROFILE ? undefined : found; + } + } + return undefined; + } + + getId(profile: TerminalProfile): string | undefined { + for (const [id, p] of this.all) { + if (p === profile) { + return id; + } + } + } + + get all(): [string, TerminalProfile][] { + return this.order.filter(id => !!this.getProfile(id)).map(id => [id, this.getProfile(id)!]); + } +} diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 439c7ff267696..a37e4fc7db063 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -17,7 +17,7 @@ import { Terminal, RendererType } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; -import { ContributionProvider, Disposable, Event, Emitter, ILogger, DisposableCollection, Channel } from '@theia/core'; +import { ContributionProvider, Disposable, Event, Emitter, ILogger, DisposableCollection, Channel, OS } from '@theia/core'; import { Widget, Message, WebSocketConnectionProvider, StatefulWidget, isFirefox, MessageLoop, KeyCode, codicon, ExtractableWidget } from '@theia/core/lib/browser'; import { isOSX } from '@theia/core/lib/common'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @@ -492,9 +492,8 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget const { cols, rows } = this.term; const terminalId = await this.shellTerminalServer.create({ - shellPreferences: this.shellPreferences, - shell: this.options.shellPath, - args: this.options.shellArgs, + shell: this.options.shellPath || this.shellPreferences.shell[OS.backend.type()], + args: this.options.shellArgs || this.shellPreferences.shellArgs[OS.backend.type()], env: this.options.env, strictEnv: this.options.strictEnv, isPseudo: this.options.isPseudoTerminal, diff --git a/packages/terminal/src/common/shell-terminal-protocol.ts b/packages/terminal/src/common/shell-terminal-protocol.ts index 169929d3b5a01..920ca9bb81c1b 100644 --- a/packages/terminal/src/common/shell-terminal-protocol.ts +++ b/packages/terminal/src/common/shell-terminal-protocol.ts @@ -36,7 +36,6 @@ export interface IShellTerminalPreferences { }; export interface IShellTerminalServerOptions extends IBaseTerminalServerOptions { - shellPreferences?: IShellTerminalPreferences, shell?: string, args?: string[] | string, rootURI?: string, diff --git a/packages/terminal/src/node/shell-process.ts b/packages/terminal/src/node/shell-process.ts index 494b286f71a9a..b2a87a5a8c8ae 100644 --- a/packages/terminal/src/node/shell-process.ts +++ b/packages/terminal/src/node/shell-process.ts @@ -18,19 +18,17 @@ import { injectable, inject, named } from '@theia/core/shared/inversify'; import * as os from 'os'; import { ILogger } from '@theia/core/lib/common/logger'; import { TerminalProcess, TerminalProcessOptions, ProcessManager, MultiRingBuffer } from '@theia/process/lib/node'; -import { isWindows, isOSX, OS } from '@theia/core/lib/common'; +import { isWindows, isOSX } from '@theia/core/lib/common'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { EnvironmentUtils } from '@theia/core/lib/node/environment-utils'; import { parseArgs } from '@theia/process/lib/node/utils'; -import { IShellTerminalPreferences } from '../common/shell-terminal-protocol'; export const ShellProcessFactory = Symbol('ShellProcessFactory'); export type ShellProcessFactory = (options: ShellProcessOptions) => ShellProcess; export const ShellProcessOptions = Symbol('ShellProcessOptions'); export interface ShellProcessOptions { - shellPreferences?: IShellTerminalPreferences, shell?: string, args?: string[] | string, rootURI?: string, @@ -64,8 +62,8 @@ export class ShellProcess extends TerminalProcess { @inject(EnvironmentUtils) environmentUtils: EnvironmentUtils, ) { super({ - command: options.shell || ShellProcess.getShellExecutablePath(options.shellPreferences), - args: options.args || ShellProcess.getShellExecutableArgs(options.shellPreferences), + command: options.shell || ShellProcess.getShellExecutablePath(), + args: options.args || ShellProcess.getShellExecutableArgs(), options: { name: 'xterm-color', cols: options.cols || ShellProcess.defaultCols, @@ -77,28 +75,24 @@ export class ShellProcess extends TerminalProcess { }, processManager, ringBuffer, logger); } - public static getShellExecutablePath(preferences?: IShellTerminalPreferences): string { + public static getShellExecutablePath(): string { const shell = process.env.THEIA_SHELL; if (shell) { return shell; } - if (preferences && preferences.shell[OS.type()]) { - return preferences.shell[OS.type()]!; - } else if (isWindows) { + if (isWindows) { return 'cmd.exe'; } else { return process.env.SHELL!; } } - public static getShellExecutableArgs(preferences?: IShellTerminalPreferences): string[] { + public static getShellExecutableArgs(): string[] { const args = process.env.THEIA_SHELL_ARGS; if (args) { return parseArgs(args); } - if (preferences) { - return preferences.shellArgs[OS.type()]; - } else if (isOSX) { + if (isOSX) { return ['-l']; } else { return []; diff --git a/packages/terminal/tsconfig.json b/packages/terminal/tsconfig.json index 3153a10e1d53d..adb72fecde9d5 100644 --- a/packages/terminal/tsconfig.json +++ b/packages/terminal/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../process" }, + { + "path": "../variable-resolver" + }, { "path": "../workspace" }