diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index f884c2899526e..2dc37d78b3afd 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -25,7 +25,7 @@ import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/term import { TerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminalInstance'; import { TerminalTab } from 'vs/workbench/contrib/terminal/browser/terminalTab'; import { TerminalViewPane } from 'vs/workbench/contrib/terminal/browser/terminalView'; -import { IAvailableProfilesRequest, IRemoteTerminalAttachTarget, ITerminalProfile, IStartExtensionTerminalRequest, ITerminalConfigHelper, ITerminalNativeWindowsDelegate, ITerminalProcessExtHostProxy, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE, KEYBINDING_CONTEXT_TERMINAL_FOCUS, KEYBINDING_CONTEXT_TERMINAL_IS_OPEN, KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_SHELL_TYPE, LinuxDistro, TERMINAL_VIEW_ID, ITerminalProfileObject, ITerminalExecutable, ITerminalProfileSource, ITerminalTypeContribution } from 'vs/workbench/contrib/terminal/common/terminal'; +import { IAvailableProfilesRequest, IRemoteTerminalAttachTarget, ITerminalProfile, IStartExtensionTerminalRequest, ITerminalConfigHelper, ITerminalNativeWindowsDelegate, ITerminalProcessExtHostProxy, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, KEYBINDING_CONTEXT_TERMINAL_FOCUS, KEYBINDING_CONTEXT_TERMINAL_IS_OPEN, KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_SHELL_TYPE, LinuxDistro, TERMINAL_VIEW_ID, ITerminalProfileObject, ITerminalExecutable, ITerminalProfileSource, ITerminalTypeContribution } from 'vs/workbench/contrib/terminal/common/terminal'; import { escapeNonWindowsPath } from 'vs/workbench/contrib/terminal/common/terminalEnvironment'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; @@ -50,7 +50,6 @@ export class TerminalService implements ITerminalService { private _terminalFocusContextKey: IContextKey; private _terminalShellTypeContextKey: IContextKey; private _terminalAltBufferActiveContextKey: IContextKey; - private _findWidgetVisible: IContextKey; private _terminalTabs: ITerminalTab[] = []; private _backgroundedTerminalInstances: ITerminalInstance[] = []; private get _terminalInstances(): ITerminalInstance[] { @@ -143,7 +142,6 @@ export class TerminalService implements ITerminalService { this._terminalFocusContextKey = KEYBINDING_CONTEXT_TERMINAL_FOCUS.bindTo(this._contextKeyService); this._terminalShellTypeContextKey = KEYBINDING_CONTEXT_TERMINAL_SHELL_TYPE.bindTo(this._contextKeyService); this._terminalAltBufferActiveContextKey = KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE.bindTo(this._contextKeyService); - this._findWidgetVisible = KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE.bindTo(this._contextKeyService); this._configHelper = this._instantiationService.createInstance(TerminalConfigHelper); this.onTabDisposed(tab => this._removeTab(tab)); this.onActiveTabChanged(() => { @@ -1041,32 +1039,29 @@ export class TerminalService implements ITerminalService { public async focusFindWidget(): Promise { await this.showPanel(false); const pane = this._viewsService.getActiveViewWithId(TERMINAL_VIEW_ID) as TerminalViewPane; - pane.focusFindWidget(); - this._findWidgetVisible.set(true); + pane.terminalTabbedView.focusFindWidget(); } public hideFindWidget(): void { const pane = this._viewsService.getActiveViewWithId(TERMINAL_VIEW_ID) as TerminalViewPane; if (pane) { - pane.hideFindWidget(); - this._findWidgetVisible.reset(); - pane.focus(); + pane.terminalTabbedView.hideFindWidget(); } } public findNext(): void { const pane = this._viewsService.getActiveViewWithId(TERMINAL_VIEW_ID) as TerminalViewPane; if (pane) { - pane.showFindWidget(); - pane.getFindWidget().find(false); + pane.terminalTabbedView.showFindWidget(); + pane.terminalTabbedView.getFindWidget().find(false); } } public findPrevious(): void { const pane = this._viewsService.getActiveViewWithId(TERMINAL_VIEW_ID) as TerminalViewPane; if (pane) { - pane.showFindWidget(); - pane.getFindWidget().find(true); + pane.terminalTabbedView.showFindWidget(); + pane.terminalTabbedView.getFindWidget().find(true); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTab.ts b/src/vs/workbench/contrib/terminal/browser/terminalTab.ts index 95599ea105f5a..35f01ef3bfd19 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTab.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTab.ts @@ -343,10 +343,11 @@ export class TerminalTab extends Disposable implements ITerminalTab { } // Fire events and dispose tab if it was the last instance - this._onInstancesChanged.fire(); if (this._terminalInstances.length === 0) { this._onDisposed.fire(this); this.dispose(); + } else { + this._onInstancesChanged.fire(); } } @@ -403,6 +404,11 @@ export class TerminalTab extends Disposable implements ITerminalTab { } public get title(): string { + if (this._terminalInstances.length === 0) { + // Normally consumers should not call into title at all after the tab is disposed but + // this is required when the tab is used as part of a tree. + return ''; + } let title = this._titleWithConnectionStatus(this.terminalInstances[0]); for (let i = 1; i < this.terminalInstances.length; i++) { const instance = this.terminalInstances[i]; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts new file mode 100644 index 0000000000000..bc0cfe45d2c1e --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Orientation, Sizing, SplitView } from 'vs/base/browser/ui/splitview/splitview'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ITerminalService, TerminalConnectionState } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalFindWidget } from 'vs/workbench/contrib/terminal/browser/terminalFindWidget'; +import { TerminalTabsWidget } from 'vs/workbench/contrib/terminal/browser/terminalTabsWidget'; +import { IThemeService, IColorTheme } from 'vs/platform/theme/common/themeService'; +import { isLinux, isMacintosh } from 'vs/base/common/platform'; +import * as dom from 'vs/base/browser/dom'; +import { BrowserFeatures } from 'vs/base/browser/canIUse'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { DataTransfers } from 'vs/base/browser/dnd'; +import { URI } from 'vs/base/common/uri'; +import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; +import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IAction } from 'vs/base/common/actions'; +import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE } from 'vs/workbench/contrib/terminal/common/terminal'; +const FIND_FOCUS_CLASS = 'find-focused'; + +export class TerminalTabbedView extends Disposable { + + private _splitView: SplitView; + + private _terminalContainer: HTMLElement; + private _terminalTabTree: HTMLElement; + private _parentElement: HTMLElement; + + private _tabsWidget: TerminalTabsWidget; + private _findWidget: TerminalFindWidget; + + private _tabTreeIndex: number; + private _terminalContainerIndex: number; + + private _showTabs: boolean; + private _findWidgetVisible: IContextKey; + + private _height: number | undefined; + + private _cancelContextMenu: boolean = false; + private _menu: IMenu; + + constructor( + parentElement: HTMLElement, + @ITerminalService private readonly _terminalService: ITerminalService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @INotificationService private readonly _notificationService: INotificationService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IThemeService private readonly _themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IMenuService menuService: IMenuService + ) { + super(); + + this._parentElement = parentElement; + + this._terminalTabTree = document.createElement('div'); + this._terminalTabTree.classList.add('tabs-widget'); + + this._tabsWidget = this._instantiationService.createInstance(TerminalTabsWidget, this._terminalTabTree); + this._findWidget = this._instantiationService.createInstance(TerminalFindWidget, this._terminalService.getFindState()); + parentElement.appendChild(this._findWidget.getDomNode()); + + this._terminalContainer = document.createElement('div'); + this._terminalContainer.classList.add('terminal-outer-container'); + this._terminalContainer.style.display = 'block'; + + this._showTabs = this._terminalService.configHelper.config.showTabs; + + this._tabTreeIndex = this._terminalService.configHelper.config.tabsLocation === 'left' ? 0 : 1; + this._terminalContainerIndex = this._terminalService.configHelper.config.tabsLocation === 'left' ? 1 : 0; + + this._menu = this._register(menuService.createMenu(MenuId.TerminalContext, contextKeyService)); + + this._findWidgetVisible = KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE.bindTo(contextKeyService); + + this._terminalService.setContainers(parentElement, this._terminalContainer); + + configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('terminal.integrated.showTabs')) { + this._showTabs = this._terminalService.configHelper.config.showTabs; + if (this._showTabs) { + this._splitView.addView({ + element: this._terminalTabTree, + layout: width => this._tabsWidget.layout(this._height, width), + minimumSize: 40, + maximumSize: Number.POSITIVE_INFINITY, + onDidChange: () => Disposable.None, + }, Sizing.Distribute, this._tabTreeIndex); + } else { + this._splitView.removeView(this._tabTreeIndex); + } + } else if (e.affectsConfiguration('terminal.integrated.tabsLocation')) { + this._tabTreeIndex = this._terminalService.configHelper.config.tabsLocation === 'left' ? 0 : 1; + this._terminalContainerIndex = this._terminalService.configHelper.config.tabsLocation === 'left' ? 1 : 0; + if (this._showTabs) { + this._splitView.swapViews(0, 1); + } + } + }); + + this._register(this._themeService.onDidColorThemeChange(theme => this._updateTheme(theme))); + this._updateTheme(); + + this._findWidget.focusTracker.onDidFocus(() => this._terminalContainer!.classList.add(FIND_FOCUS_CLASS)); + + this._attachEventListeners(parentElement, this._terminalContainer); + + this._splitView = new SplitView(parentElement, { orientation: Orientation.HORIZONTAL }); + + this._setupSplitView(); + } + + private _setupSplitView(): void { + this._register(this._splitView.onDidSashReset(() => this._splitView.distributeViewSizes())); + + if (this._showTabs) { + this._splitView.addView({ + element: this._terminalTabTree, + layout: width => this._tabsWidget.layout(this._height, width), + minimumSize: 40, + maximumSize: Number.POSITIVE_INFINITY, + onDidChange: () => Disposable.None, + }, Sizing.Distribute, this._tabTreeIndex); + } + this._splitView.addView({ + element: this._terminalContainer, + layout: width => this._terminalService.terminalTabs.forEach(tab => tab.layout(width, this._height || 0)), + minimumSize: 120, + maximumSize: Number.POSITIVE_INFINITY, + onDidChange: () => Disposable.None + }, Sizing.Distribute, this._terminalContainerIndex); + } + + layout(width: number, height: number): void { + this._splitView.layout(width); + this._height = height; + } + + private _updateTheme(theme?: IColorTheme): void { + if (!theme) { + theme = this._themeService.getColorTheme(); + } + + this._findWidget?.updateTheme(theme); + } + + private _attachEventListeners(parentDomElement: HTMLElement, terminalContainer: HTMLElement): void { + this._register(dom.addDisposableListener(parentDomElement, 'mousedown', async (event: MouseEvent) => { + if (this._terminalService.terminalInstances.length === 0) { + return; + } + + if (event.which === 2 && isLinux) { + // Drop selection and focus terminal on Linux to enable middle button paste when click + // occurs on the selection itself. + const terminal = this._terminalService.getActiveInstance(); + if (terminal) { + terminal.focus(); + } + } else if (event.which === 3) { + const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + if (rightClickBehavior === 'copyPaste' || rightClickBehavior === 'paste') { + const terminal = this._terminalService.getActiveInstance(); + if (!terminal) { + return; + } + + // copyPaste: Shift+right click should open context menu + if (rightClickBehavior === 'copyPaste' && event.shiftKey) { + this._openContextMenu(event); + return; + } + + if (rightClickBehavior === 'copyPaste' && terminal.hasSelection()) { + await terminal.copySelection(); + terminal.clearSelection(); + } else { + if (BrowserFeatures.clipboard.readText) { + terminal.paste(); + } else { + this._notificationService.info(`This browser doesn't support the clipboard.readText API needed to trigger a paste, try ${isMacintosh ? '⌘' : 'Ctrl'}+V instead.`); + } + } + // Clear selection after all click event bubbling is finished on Mac to prevent + // right-click selecting a word which is seemed cannot be disabled. There is a + // flicker when pasting but this appears to give the best experience if the + // setting is enabled. + if (isMacintosh) { + setTimeout(() => { + terminal.clearSelection(); + }, 0); + } + this._cancelContextMenu = true; + } + } + })); + this._register(dom.addDisposableListener(parentDomElement, 'contextmenu', (event: MouseEvent) => { + if (!this._cancelContextMenu) { + this._openContextMenu(event); + } + event.preventDefault(); + event.stopImmediatePropagation(); + this._cancelContextMenu = false; + })); + this._register(dom.addDisposableListener(document, 'keydown', (event: KeyboardEvent) => { + terminalContainer.classList.toggle('alt-active', !!event.altKey); + })); + this._register(dom.addDisposableListener(document, 'keyup', (event: KeyboardEvent) => { + terminalContainer.classList.toggle('alt-active', !!event.altKey); + })); + this._register(dom.addDisposableListener(parentDomElement, 'keyup', (event: KeyboardEvent) => { + if (event.keyCode === 27) { + // Keep terminal open on escape + event.stopPropagation(); + } + })); + this._register(dom.addDisposableListener(parentDomElement, dom.EventType.DROP, async (e: DragEvent) => { + if (e.target === this._parentElement || dom.isAncestor(e.target as HTMLElement, parentDomElement)) { + if (!e.dataTransfer) { + return; + } + + // Check if files were dragged from the tree explorer + let path: string | undefined; + const resources = e.dataTransfer.getData(DataTransfers.RESOURCES); + if (resources) { + path = URI.parse(JSON.parse(resources)[0]).fsPath; + } else if (e.dataTransfer.files.length > 0 && e.dataTransfer.files[0].path /* Electron only */) { + // Check if the file was dragged from the filesystem + path = URI.file(e.dataTransfer.files[0].path).fsPath; + } + + if (!path) { + return; + } + + const terminal = this._terminalService.getActiveInstance(); + if (terminal) { + const preparedPath = await this._terminalService.preparePathForTerminalAsync(path, terminal.shellLaunchConfig.executable, terminal.title, terminal.shellType); + terminal.sendText(preparedPath, false); + terminal.focus(); + } + } + })); + } + private _openContextMenu(event: MouseEvent): void { + const standardEvent = new StandardMouseEvent(event); + const anchor: { x: number, y: number } = { x: standardEvent.posx, y: standardEvent.posy }; + + const actions: IAction[] = []; + const actionsDisposable = createAndFillInContextMenuActions(this._menu, undefined, actions); + + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + getActionsContext: () => this._parentElement, + onHide: () => actionsDisposable.dispose() + }); + } + + public focusFindWidget() { + this._findWidgetVisible.set(true); + const activeInstance = this._terminalService.getActiveInstance(); + if (activeInstance && activeInstance.hasSelection() && activeInstance.selection!.indexOf('\n') === -1) { + this._findWidget!.reveal(activeInstance.selection); + } else { + this._findWidget!.reveal(); + } + } + + public hideFindWidget() { + this._findWidgetVisible.reset(); + this.focus(); + this._findWidget!.hide(); + } + + public showFindWidget() { + const activeInstance = this._terminalService.getActiveInstance(); + if (activeInstance && activeInstance.hasSelection() && activeInstance.selection!.indexOf('\n') === -1) { + this._findWidget!.show(activeInstance.selection); + } else { + this._findWidget!.show(); + } + } + + public getFindWidget(): TerminalFindWidget { + return this._findWidget!; + } + public focus() { + if (this._terminalService.connectionState === TerminalConnectionState.Connecting) { + // If the terminal is waiting to reconnect to remote terminals, then there is no TerminalInstance yet that can + // be focused. So wait for connection to finish, then focus. + const activeElement = document.activeElement; + this._register(this._terminalService.onDidChangeConnectionState(() => { + // Only focus the terminal if the activeElement has not changed since focus() was called + // TODO hack + if (document.activeElement === activeElement) { + this._focus(); + } + })); + + return; + } + this._focus(); + } + + private _focus() { + this._terminalService.getActiveInstance()?.focusWhenReady(); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsWidget.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsWidget.ts new file mode 100644 index 0000000000000..be19f85cc424f --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsWidget.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IListService, WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; +import { ITreeElement, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; +import { DefaultStyleController, IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IIdentityProvider } from 'vs/base/browser/ui/list/list'; +import { ITerminalInstance, ITerminalService, ITerminalTab } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { localize } from 'vs/nls'; +import * as dom from 'vs/base/browser/dom'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { Codicon } from 'vs/base/common/codicons'; + +const $ = dom.$; + +export class TerminalTabsWidget extends WorkbenchObjectTree { + private _terminalService: ITerminalService; + constructor( + container: HTMLElement, + @IContextKeyService contextKeyService: IContextKeyService, + @IListService listService: IListService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService, + @IAccessibilityService accessibilityService: IAccessibilityService, + @ITerminalService terminalService: ITerminalService, + @IInstantiationService _instantiationService: IInstantiationService + ) { + super('TerminalTabsTree', container, + { + getHeight: () => 24, + getTemplateId: () => 'terminal.tabs' + }, + [new TerminalTabsRenderer()], + { + horizontalScrolling: false, + supportDynamicHeights: true, + identityProvider: new TerminalTabsIdentityProvider(), + accessibilityProvider: new TerminalTabsAccessibilityProvider(), + styleController: id => new DefaultStyleController(dom.createStyleSheet(container), id), + filter: undefined, + smoothScrolling: configurationService.getValue('workbench.list.smoothScrolling'), + multipleSelectionSupport: false, + expandOnlyOnTwistieClick: true + }, + contextKeyService, + listService, + themeService, + configurationService, + keybindingService, + accessibilityService, + ); + this.onDidChangeSelection(e => { + if (e.elements && e.elements[0]) { + if ('terminalInstances' in e.elements[0]) { + terminalService.setActiveTabByIndex(terminalService.terminalTabs.indexOf(e.elements[0])); + } else { + e.elements[0].focus(true); + } + } + }); + this._terminalService = terminalService; + + terminalService.onInstancesChanged(() => this._render()); + terminalService.onInstanceTitleChanged(() => this._render()); + + this._render(); + } + + private _render(): void { + this.setChildren(null, createTerminalTabsIterator(this._terminalService.terminalTabs)); + } +} + +class TerminalTabsIdentityProvider implements IIdentityProvider { + constructor() { + } + getId(element: ITabTreeNode): { toString(): string; } { + if ('terminalInstances' in element) { + return element.title; + } else { + return element.instanceId; + } + } + +} +class TerminalTabsAccessibilityProvider implements IListAccessibilityProvider { + getAriaLabel(node: ITabTreeNode) { + let label = ''; + if ('terminalInstances' in node) { + if (node.terminalInstances.length === 1) { + label = node.terminalInstances[0].title; + } else if (node.terminalInstances.length > 1) { + label = `Terminals (${node.terminalInstances.length})`; + } + } else { + label = node.title; + } + return label; + } + + getWidgetAriaLabel() { + return localize('terminal.tabs', "TerminalTabs"); + } +} + +class TerminalTabsRenderer implements ITreeRenderer { + + templateId = 'terminal.tabs'; + + renderTemplate(container: HTMLElement): ITerminalTabEntryTemplate { + return { + labelElement: dom.append(container, $('.terminal-tabs-entry')), + }; + } + + renderElement(node: ITreeNode, index: number, template: ITerminalTabEntryTemplate): void { + let label = ''; + let item = node.element; + if ('terminalInstances' in item) { + if (item.terminalInstances.length === 1) { + label = item.terminalInstances[0].title; + } else if (item.terminalInstances.length > 1) { + label = `Terminals (${item.terminalInstances.length})`; + } + } else { + label = item.title; + } + template.labelElement.textContent = label; + template.labelElement.title = label; + } + + disposeTemplate(templateData: ITerminalTabEntryTemplate): void { + } +} + +interface ITerminalTabEntryTemplate { + labelElement: HTMLElement; + icon?: Codicon; +} + +type ITabTreeNode = ITerminalTab | ITerminalInstance; + +function createTerminalTabsIterator(tabs: ITerminalTab[]): Iterable> { + const result = tabs.map(tab => { + const hasChildren = tab.terminalInstances.length > 1; + return { + element: tab, + collapsed: false, + collapsible: hasChildren, + children: getChildren(tab) + }; + }); + return result; +} + +function getChildren(tab: ITerminalTab): Iterable> | undefined { + if (tab.terminalInstances.length > 1) { + return tab.terminalInstances.map(instance => { + return { + element: instance, + collapsed: true, + collapsible: false, + }; + }); + } + return undefined; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 5015ca7a63e70..91ddb5992e81a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -3,21 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from 'vs/base/browser/dom'; import * as nls from 'vs/nls'; -import * as platform from 'vs/base/common/platform'; import { Action, IAction } from 'vs/base/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService, IColorTheme, registerThemingParticipant, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; -import { TerminalFindWidget } from 'vs/workbench/contrib/terminal/browser/terminalFindWidget'; import { configureTerminalSettingsTitle, selectDefaultProfileTitle, switchTerminalActionViewItemSeparator } from 'vs/workbench/contrib/terminal/browser/terminalActions'; -import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; -import { URI } from 'vs/base/common/uri'; import { TERMINAL_BACKGROUND_COLOR, TERMINAL_BORDER_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; -import { DataTransfers } from 'vs/base/browser/dnd'; import { INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; import { ITerminalService, TerminalConnectionState } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPane'; @@ -26,27 +20,23 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; -import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; -import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuService } from 'vs/platform/actions/common/actions'; import { TERMINAL_COMMAND_ID } from 'vs/workbench/contrib/terminal/common/terminal'; import { SelectActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { ITerminalContributionService } from 'vs/workbench/contrib/terminal/common/terminalExtensionPoints'; import { attachSelectBoxStyler, attachStylerCallback } from 'vs/platform/theme/common/styler'; import { selectBorder } from 'vs/platform/theme/common/colorRegistry'; import { ISelectOptionItem } from 'vs/base/browser/ui/selectBox/selectBox'; -import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; - -const FIND_FOCUS_CLASS = 'find-focused'; +import { TerminalTabbedView } from 'vs/workbench/contrib/terminal/browser/terminalTabbedView'; export class TerminalViewPane extends ViewPane { - private _menu: IMenu; private _actions: IAction[] | undefined; - private _cancelContextMenu: boolean = false; private _fontStyleElement: HTMLElement | undefined; private _parentDomElement: HTMLElement | undefined; - private _terminalContainer: HTMLElement | undefined; - private _findWidget: TerminalFindWidget | undefined; + private _tabsViewWrapper: HTMLElement | undefined; + private _terminalTabbedView!: TerminalTabbedView; + public get terminalTabbedView(): TerminalTabbedView { return this._terminalTabbedView; } private _terminalsInitialized = false; private _bodyDimensions: { width: number, height: number } = { width: 0, height: 0 }; private _isWelcomeShowing: boolean = false; @@ -57,7 +47,7 @@ export class TerminalViewPane extends ViewPane { @IContextKeyService contextKeyService: IContextKeyService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @IConfigurationService configurationService: IConfigurationService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IContextMenuService _contextMenuService: IContextMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITerminalService private readonly _terminalService: ITerminalService, @IThemeService protected readonly themeService: IThemeService, @@ -67,7 +57,6 @@ export class TerminalViewPane extends ViewPane { @IMenuService menuService: IMenuService, ) { super(options, keybindingService, _contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instantiationService, openerService, themeService, telemetryService); - this._menu = this._register(menuService.createMenu(MenuId.TerminalContext, contextKeyService)); this._terminalService.onDidRegisterProcessSupport(() => { if (this._actions) { for (const action of this._actions) { @@ -82,36 +71,26 @@ export class TerminalViewPane extends ViewPane { } this._isWelcomeShowing = true; this._onDidChangeViewWelcomeState.fire(); - if (this._terminalContainer) { - this._terminalContainer.style.display = 'block'; - this.layoutBody(this._terminalContainer.offsetHeight, this._terminalContainer.offsetWidth); + if (!this._terminalTabbedView && this._parentDomElement) { + this._createTabsView(); + this.layoutBody(this._parentDomElement.offsetHeight, this._parentDomElement.offsetWidth); } }); } - protected renderBody(container: HTMLElement): void { + public renderBody(container: HTMLElement): void { super.renderBody(container); this._parentDomElement = container; this._parentDomElement.classList.add('integrated-terminal'); this._fontStyleElement = document.createElement('style'); - this._terminalContainer = document.createElement('div'); - this._terminalContainer.classList.add('terminal-outer-container'); - this._terminalContainer.style.display = this.shouldShowWelcome() ? 'none' : 'block'; - - this._findWidget = this._instantiationService.createInstance(TerminalFindWidget, this._terminalService.getFindState()); - this._findWidget.focusTracker.onDidFocus(() => this._terminalContainer!.classList.add(FIND_FOCUS_CLASS)); + if (!this.shouldShowWelcome()) { + this._createTabsView(); + } this._parentDomElement.appendChild(this._fontStyleElement); - this._parentDomElement.appendChild(this._terminalContainer); - this._parentDomElement.appendChild(this._findWidget.getDomNode()); - - this._attachEventListeners(this._parentDomElement, this._terminalContainer); - this._terminalService.setContainers(container, this._terminalContainer); - - this._register(this.themeService.onDidColorThemeChange(theme => this._updateTheme(theme))); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('terminal.integrated.fontFamily') || e.affectsConfiguration('editor.fontFamily')) { const configHelper = this._terminalService.configHelper; @@ -124,7 +103,6 @@ export class TerminalViewPane extends ViewPane { } } })); - this._updateTheme(); this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { @@ -140,7 +118,6 @@ export class TerminalViewPane extends ViewPane { } } - this._updateTheme(); if (hadTerminals) { this._terminalService.getActiveTab()?.setVisible(visible); } else { @@ -155,9 +132,17 @@ export class TerminalViewPane extends ViewPane { }); } })); + this.layoutBody(this._parentDomElement.offsetHeight, this._parentDomElement.offsetWidth); + } - // Force another layout (first is setContainers) since config has changed - this.layoutBody(this._terminalContainer.offsetHeight, this._terminalContainer.offsetWidth); + private _createTabsView(): void { + if (!this._parentDomElement) { + return; + } + this._tabsViewWrapper = document.createElement('div'); + this._tabsViewWrapper.classList.add('tabs-view-wrapper'); + this._terminalTabbedView = this.instantiationService.createInstance(TerminalTabbedView, this._parentDomElement); + this._parentDomElement.append(this._tabsViewWrapper); } protected layoutBody(height: number, width: number): void { @@ -165,7 +150,8 @@ export class TerminalViewPane extends ViewPane { this._bodyDimensions.width = width; this._bodyDimensions.height = height; - this._terminalService.terminalTabs.forEach(t => t.layout(width, height)); + + this._terminalTabbedView.layout(width, height); } public getActionViewItem(action: Action): IActionViewItem | undefined { @@ -198,155 +184,6 @@ export class TerminalViewPane extends ViewPane { this._terminalService.getActiveInstance()?.focusWhenReady(); } - public focusFindWidget() { - const activeInstance = this._terminalService.getActiveInstance(); - if (activeInstance && activeInstance.hasSelection() && activeInstance.selection!.indexOf('\n') === -1) { - this._findWidget!.reveal(activeInstance.selection); - } else { - this._findWidget!.reveal(); - } - } - - public hideFindWidget() { - this._findWidget!.hide(); - } - - public showFindWidget() { - const activeInstance = this._terminalService.getActiveInstance(); - if (activeInstance && activeInstance.hasSelection() && activeInstance.selection!.indexOf('\n') === -1) { - this._findWidget!.show(activeInstance.selection); - } else { - this._findWidget!.show(); - } - } - - public getFindWidget(): TerminalFindWidget { - return this._findWidget!; - } - - private _attachEventListeners(parentDomElement: HTMLElement, terminalContainer: HTMLElement): void { - this._register(dom.addDisposableListener(parentDomElement, 'mousedown', async (event: MouseEvent) => { - if (this._terminalService.terminalInstances.length === 0) { - return; - } - - if (event.which === 2 && platform.isLinux) { - // Drop selection and focus terminal on Linux to enable middle button paste when click - // occurs on the selection itself. - const terminal = this._terminalService.getActiveInstance(); - if (terminal) { - terminal.focus(); - } - } else if (event.which === 3) { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; - if (rightClickBehavior === 'copyPaste' || rightClickBehavior === 'paste') { - const terminal = this._terminalService.getActiveInstance(); - if (!terminal) { - return; - } - - // copyPaste: Shift+right click should open context menu - if (rightClickBehavior === 'copyPaste' && event.shiftKey) { - this._openContextMenu(event); - return; - } - - if (rightClickBehavior === 'copyPaste' && terminal.hasSelection()) { - await terminal.copySelection(); - terminal.clearSelection(); - } else { - if (BrowserFeatures.clipboard.readText) { - terminal.paste(); - } else { - this._notificationService.info(`This browser doesn't support the clipboard.readText API needed to trigger a paste, try ${platform.isMacintosh ? '⌘' : 'Ctrl'}+V instead.`); - } - } - // Clear selection after all click event bubbling is finished on Mac to prevent - // right-click selecting a word which is seemed cannot be disabled. There is a - // flicker when pasting but this appears to give the best experience if the - // setting is enabled. - if (platform.isMacintosh) { - setTimeout(() => { - terminal.clearSelection(); - }, 0); - } - this._cancelContextMenu = true; - } - } - })); - this._register(dom.addDisposableListener(parentDomElement, 'contextmenu', (event: MouseEvent) => { - if (!this._cancelContextMenu) { - this._openContextMenu(event); - } - event.preventDefault(); - event.stopImmediatePropagation(); - this._cancelContextMenu = false; - })); - this._register(dom.addDisposableListener(document, 'keydown', (event: KeyboardEvent) => { - terminalContainer.classList.toggle('alt-active', !!event.altKey); - })); - this._register(dom.addDisposableListener(document, 'keyup', (event: KeyboardEvent) => { - terminalContainer.classList.toggle('alt-active', !!event.altKey); - })); - this._register(dom.addDisposableListener(parentDomElement, 'keyup', (event: KeyboardEvent) => { - if (event.keyCode === 27) { - // Keep terminal open on escape - event.stopPropagation(); - } - })); - this._register(dom.addDisposableListener(parentDomElement, dom.EventType.DROP, async (e: DragEvent) => { - if (e.target === this._parentDomElement || dom.isAncestor(e.target as HTMLElement, parentDomElement)) { - if (!e.dataTransfer) { - return; - } - - // Check if files were dragged from the tree explorer - let path: string | undefined; - const resources = e.dataTransfer.getData(DataTransfers.RESOURCES); - if (resources) { - path = URI.parse(JSON.parse(resources)[0]).fsPath; - } else if (e.dataTransfer.files.length > 0 && e.dataTransfer.files[0].path /* Electron only */) { - // Check if the file was dragged from the filesystem - path = URI.file(e.dataTransfer.files[0].path).fsPath; - } - - if (!path) { - return; - } - - const terminal = this._terminalService.getActiveInstance(); - if (terminal) { - const preparedPath = await this._terminalService.preparePathForTerminalAsync(path, terminal.shellLaunchConfig.executable, terminal.title, terminal.shellType); - terminal.sendText(preparedPath, false); - terminal.focus(); - } - } - })); - } - - private _openContextMenu(event: MouseEvent): void { - const standardEvent = new StandardMouseEvent(event); - const anchor: { x: number, y: number } = { x: standardEvent.posx, y: standardEvent.posy }; - - const actions: IAction[] = []; - const actionsDisposable = createAndFillInContextMenuActions(this._menu, undefined, actions); - - this._contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => actions, - getActionsContext: () => this._parentDomElement, - onHide: () => actionsDisposable.dispose() - }); - } - - private _updateTheme(theme?: IColorTheme): void { - if (!theme) { - theme = this.themeService.getColorTheme(); - } - - this._findWidget?.updateTheme(theme); - } - shouldShowWelcome(): boolean { this._isWelcomeShowing = !this._terminalService.isProcessSupportRegistered && this._terminalService.terminalInstances.length === 0; return this._isWelcomeShowing; diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 7a7644e24a0d1..b85879fc49096 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -103,6 +103,8 @@ export interface ITerminalConfiguration { }; profiles: ITerminalProfiles; useWslProfiles: boolean; + showTabs: boolean; + tabsLocation: 'left' | 'right'; altClickMovesCursor: boolean; macOptionIsMeta: boolean; macOptionClickForcesSelection: boolean; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index bd4d9534de54b..1f38a003b2a73 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -252,6 +252,17 @@ export const terminalConfiguration: IConfigurationNode = { type: 'boolean', default: true }, + 'terminal.integrated.showTabs': { + description: localize('terminal.integrated.showTabs', 'Controls whether or not the terminal tabs widget is shown'), + type: 'boolean', + default: false + }, + 'terminal.integrated.tabsLocation': { + 'type': 'string', + 'enum': ['left', 'right'], + 'default': 'left', + 'description': localize('terminal.integrated.tabsLocation', "Controls the location of the terminal tabs, either left or right of the terminal container.") + }, 'terminal.integrated.macOptionIsMeta': { description: localize('terminal.integrated.macOptionIsMeta', "Controls whether to treat the option key as the meta key in the terminal on macOS."), type: 'boolean',