diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index f38d3149563764..01d7f7a5a5ec8e 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -17,6 +17,7 @@ export const enum AccessibleViewProviderId { Terminal = 'terminal', TerminalChat = 'terminal-chat', TerminalHelp = 'terminal-help', + TerminalTabsHelp = 'terminal-tabs-help', DiffEditor = 'diffEditor', MergeEditor = 'mergeEditor', PanelChat = 'panelChat', diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminalTabsBar.css b/src/vs/workbench/contrib/terminal/browser/media/terminalTabsBar.css new file mode 100644 index 00000000000000..069e1c531db222 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/media/terminalTabsBar.css @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .pane-body.integrated-terminal .tabs-container.horizontal-tabs { + border-left: none; + border-right: none; + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-terminal-border); + box-sizing: border-box; +} + +.monaco-workbench .pane-body.integrated-terminal .tabs-container.horizontal-tabs.tabs-bottom { + border-bottom: none; + border-top: var(--vscode-strokeThickness) solid var(--vscode-terminal-border); +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .tabs-list-container { + flex-direction: row; + align-items: center; + min-width: 0; +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .tabs-list { + min-width: 0; +} + +.monaco-workbench .terminal-tabs-bar { + display: flex; + position: relative; + align-items: stretch; + overflow: hidden; + white-space: nowrap; +} + +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab { + position: relative; + flex: 0 0 auto; + min-width: 120px; + max-width: 240px; + box-sizing: border-box; + cursor: pointer; + outline-offset: -1px; +} + +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab.group-start { + margin-left: var(--vscode-spacing-size40); + border-left: var(--vscode-strokeThickness) solid var(--vscode-terminal-border); +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-entry { + display: flex; + align-items: center; + height: 100%; + box-sizing: border-box; +} + +.monaco-workbench .terminal-tabs-bar .monaco-icon-label { + display: flex; + align-items: center; + min-width: 0; + flex: 1; +} + +.monaco-workbench .terminal-tabs-bar .monaco-icon-label-container { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-bar-tab .actions { + display: block; + visibility: hidden; + flex-shrink: 0; +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-bar-tab:hover .actions, +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-bar-tab:focus-within .actions { + visibility: visible; +} + +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab:hover { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-list-hoverForeground); +} + +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-list-inactiveSelectionForeground); + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, transparent); +} + +.monaco-workbench .terminal-tabs-bar:focus-within .terminal-tabs-bar-tab.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-entry.is-active::before { + top: auto; + right: 0; + bottom: 0; + width: auto; + height: var(--vscode-strokeThickness); +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs.tabs-bottom .terminal-tabs-entry.is-active::before { + top: 0; + bottom: auto; +} + +.monaco-workbench .terminal-tabs-bar.drop-target, +.monaco-workbench .terminal-tabs-bar .terminal-tabs-bar-tab.drop-target { + background-color: var(--vscode-list-dropBackground); + outline: var(--vscode-strokeThickness) solid var(--vscode-list-dropBetweenBackground); + outline-offset: -1px; +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-chat-entry { + max-width: 40%; + min-width: 0; + height: 100%; +} + +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-chat-entry-icon, +.monaco-workbench .pane-body.integrated-terminal .horizontal-tabs .terminal-tabs-chat-entry-delete { + flex-shrink: 0; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 156b980ca7c71c..8bdc061a29795b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -831,6 +831,7 @@ export interface ITerminalGroupService extends ITerminalInstanceHost { showPanel(focus?: boolean): Promise; hidePanel(): void; focusTabs(): void; + getSelectedTabInstances(): ITerminalInstance[] | undefined; focusHover(): void; updateVisibility(): void; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 4d881d9cb1b23c..0e394af810a92b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -32,7 +32,6 @@ import { FileKind } from '../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; -import { IListService } from '../../../../platform/list/browser/listService.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; @@ -61,7 +60,6 @@ import { InstanceContext } from './terminalContextMenu.js'; import { getColorClass, getIconId, getUriClasses } from './terminalIcon.js'; import { killTerminalIcon, newTerminalIcon } from './terminalIcons.js'; import { ITerminalQuickPickItem } from './terminalProfileQuickpick.js'; -import { TerminalTabList } from './terminalTabsList.js'; import { ResourceContextKey } from '../../../common/contextkeys.js'; import { SeparatorSelectOption } from '../../../../base/browser/ui/selectBox/selectBox.js'; @@ -1331,7 +1329,7 @@ export function registerTerminalActions() { }, run: async (c, accessor) => { const disposePromises: Promise[] = []; - for (const terminal of getSelectedViewInstances(accessor, true) ?? []) { + for (const terminal of getSelectedViewInstances(accessor) ?? []) { disposePromises.push(c.service.safeDisposeTerminal(terminal)); } await Promise.all(disposePromises); @@ -1468,39 +1466,14 @@ function getSelectedViewInstances2(accessor: ServicesAccessor, args?: unknown): return undefined; } -function getSelectedViewInstances(accessor: ServicesAccessor, args?: unknown, args2?: unknown): ITerminalInstance[] | undefined { - const listService = accessor.get(IListService); +function getSelectedViewInstances(accessor: ServicesAccessor): ITerminalInstance[] | undefined { const terminalGroupService = accessor.get(ITerminalGroupService); - const result: ITerminalInstance[] = []; - - // Assign list only if it's an instance of TerminalTabList (#234791) - const list = listService.lastFocusedList instanceof TerminalTabList ? listService.lastFocusedList : undefined; - // Get selected tab list instance(s) - const selections = list?.getSelection(); - // Get inline tab instance if there are not tab list selections #196578 - if (terminalGroupService.lastAccessedMenu === 'inline-tab' && !selections?.length) { + const selection = terminalGroupService.getSelectedTabInstances(); + if (terminalGroupService.lastAccessedMenu === 'inline-tab' && !selection?.length) { const instance = terminalGroupService.activeInstance; - return instance ? [terminalGroupService.activeInstance] : undefined; - } - - if (!list || !selections) { - return undefined; - } - const focused = list.getFocus(); - - const viewInstances = terminalGroupService.instances; - if (focused.length === 1 && !selections.includes(focused[0])) { - // focused length is always a max of 1 - // if the focused one is not in the selected list, return that item - result.push(viewInstances[focused[0]]); - return result; - } - - // multi-select - for (const selection of selections) { - result.push(viewInstances[selection]); + return instance ? [instance] : undefined; } - return result.filter(r => !!r); + return selection; } export function validateTerminalName(name: string): { content: string; severity: Severity } | null { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts index 506dd04965b34a..62ed2e317303d5 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts @@ -148,6 +148,11 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe pane?.terminalTabbedView?.focusHover(); } + getSelectedTabInstances(): ITerminalInstance[] | undefined { + const pane = this._viewsService.getActiveViewWithId(TERMINAL_VIEW_ID); + return pane?.terminalTabbedView?.getSelectedTabInstances(); + } + async focusInstance(instance: ITerminalInstance): Promise { if (this.instances.includes(instance)) { this.setActiveInstance(instance); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 7a3dd5844588e4..4741c2eee397d5 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { LayoutPriority, Orientation, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; -import { Disposable, dispose, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ITerminalChatService, ITerminalConfigurationService, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState, TerminalDataTransfers } from './terminal.js'; -import { TerminalTabsListSizes, TerminalTabList } from './terminalTabsList.js'; +import { ITerminalChatService, ITerminalConfigurationService, ITerminalEditingService, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState, TerminalDataTransfers } from './terminal.js'; +import { TerminalTabsDragAndDrop, TerminalTabsListSizes, TerminalTabList } from './terminalTabsList.js'; import * as dom from '../../../../base/browser/dom.js'; import { Action, IAction, Separator } from '../../../../base/common/actions.js'; import { IMenu, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; @@ -25,9 +25,13 @@ import { getInstanceHoverInfo } from './terminalTooltip.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { TerminalTabsChatEntry } from './terminalTabsChatEntry.js'; import { containsDragType } from '../../../../platform/dnd/browser/dnd.js'; -import { getTerminalResourcesFromDragEvent, parseTerminalUri } from './terminalUri.js'; -import type { IProcessDetails } from '../../../../platform/terminal/common/terminalProcess.js'; import { TerminalContribContextKeyStrings } from '../terminalContribExports.js'; +import { ITerminalConfiguration } from '../common/terminal.js'; +import { getSelectedTerminalTabInstances, ITerminalTabsWidget } from './terminalTabsWidget.js'; +import { TerminalTabsBar } from './terminalTabsBar.js'; +import { NativeDragAndDropData } from '../../../../base/browser/ui/list/listView.js'; +import { IListService } from '../../../../platform/list/browser/listService.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; const $ = dom.$; @@ -43,15 +47,20 @@ const enum WidthConstants { export class TerminalTabbedView extends Disposable { private _splitView: SplitView; + private readonly _layoutDisposables = this._register(new DisposableStore()); + private readonly _tabsDisposables = this._register(new DisposableStore()); + private readonly _tabActionsDisposables = this._register(new DisposableStore()); + private _location: ITerminalConfiguration['tabs']['location']; private _terminalContainer: HTMLElement; private _tabListElement: HTMLElement; private _tabContainer: HTMLElement; - private _tabList: TerminalTabList; + private _tabList: ITerminalTabsWidget; + private readonly _tabDragAndDrop: TerminalTabsDragAndDrop; private _tabListContainer: HTMLElement; private _tabListDomElement: HTMLElement; - private _sashDisposables: IDisposable[] | undefined; + private readonly _sashDisposables = this._register(new DisposableStore()); private _plusButton: HTMLElement | undefined; private _chatEntry: TerminalTabsChatEntry | undefined; @@ -73,6 +82,7 @@ export class TerminalTabbedView extends Disposable { private _panelOrientation: Orientation | undefined; private _emptyAreaDropTargetCount = 0; + private _hiddenChatTerminalCount = 0; constructor( parentElement: HTMLElement, @@ -87,8 +97,11 @@ export class TerminalTabbedView extends Disposable { @IStorageService private readonly _storageService: IStorageService, @IContextKeyService contextKeyService: IContextKeyService, @IHoverService private readonly _hoverService: IHoverService, + @ITerminalEditingService private readonly _terminalEditingService: ITerminalEditingService, + @IListService private readonly _listService: IListService, ) { super(); + this._location = this._terminalConfigurationService.config.tabs.location; this._tabContainer = $('.tabs-container'); const tabListContainer = $('.tabs-list-container'); @@ -101,8 +114,13 @@ export class TerminalTabbedView extends Disposable { this._tabsListMenu = this._register(menuService.createMenu(MenuId.TerminalTabContext, contextKeyService)); this._tabsListEmptyMenu = this._register(menuService.createMenu(MenuId.TerminalTabEmptyAreaContext, contextKeyService)); - this._tabList = this._register(this._instantiationService.createInstance(TerminalTabList, this._tabListElement)); + this._tabList = this._createTabsWidget(); this._tabListDomElement = this._tabList.getHTMLElement(); + this._tabDragAndDrop = this._register(this._instantiationService.createInstance(TerminalTabsDragAndDrop, instances => { + const indexes = instances.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0); + this._tabList.setSelection(indexes); + this._tabList.setFocus(indexes.slice(0, 1)); + })); this._chatEntry = this._register(this._instantiationService.createInstance(TerminalTabsChatEntry, tabListContainer, this._tabContainer)); const terminalOuterContainer = $('.terminal-outer-container'); @@ -115,38 +133,64 @@ export class TerminalTabbedView extends Disposable { this._terminalTabsFocusContextKey = TerminalContextKeys.tabsFocus.bindTo(contextKeyService); this._terminalTabsMouseContextKey = TerminalContextKeys.tabsMouse.bindTo(contextKeyService); - this._tabTreeIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 0 : 1; - this._terminalContainerIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 1 : 0; + this._tabTreeIndex = this._location === 'left' || this._location === 'top' ? 0 : 1; + this._terminalContainerIndex = 1 - this._tabTreeIndex; this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(TerminalSettingId.TabsLocation) && this._location !== this._terminalConfigurationService.config.tabs.location) { + const selection = this._tabList.getSelectedElements(); + const focused = this._tabList.getFocusedElements(); + const activeElement = dom.getActiveElement(); + const hadTabsFocus = this._tabContainer.contains(activeElement); + const horizontal = this._isHorizontal; + const editing = this._terminalEditingService.getEditingTerminal(); + if (editing) { + this._terminalEditingService.getEditableData(editing)?.onFinish('', false); + } + this._location = this._terminalConfigurationService.config.tabs.location; + this._tabTreeIndex = this._location === 'left' || this._location === 'top' ? 0 : 1; + this._terminalContainerIndex = 1 - this._tabTreeIndex; + if (horizontal !== this._isHorizontal) { + this._tabsDisposables.clear(); + dom.clearNode(this._tabListElement); + this._tabList = this._createTabsWidget(); + this._tabListDomElement = this._tabList.getHTMLElement(); + this._tabList.setSelection(selection.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0)); + this._tabList.setFocus(focused.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0)); + } + this._removeSashListener(); + this._splitView.el.remove(); + this._layoutDisposables.clear(); + this._splitView = this._createSplitView(parentElement); + this._setupSplitView(terminalOuterContainer); + this.layout(this._width ?? 0, this._height ?? 0); + if (hadTabsFocus) { + if (this._shouldShowTabs()) { + this._focusTabsWidget(); + } else { + this.focus(); + } + } else if (dom.isHTMLElement(activeElement) && this._terminalContainer.contains(activeElement)) { + activeElement.focus(); + } + } if (e.affectsConfiguration(TerminalSettingId.TabsEnabled) || - e.affectsConfiguration(TerminalSettingId.TabsHideCondition)) { + e.affectsConfiguration(TerminalSettingId.TabsHideCondition) || + e.affectsConfiguration(TerminalSettingId.TabsLocation)) { this._refreshShowTabs(); - } else if (e.affectsConfiguration(TerminalSettingId.TabsLocation)) { - this._tabTreeIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 0 : 1; - this._terminalContainerIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 1 : 0; - if (this._shouldShowTabs()) { - this._splitView.swapViews(0, 1); - this._removeSashListener(); - this._addSashListener(); - this._splitView.resizeView(this._tabTreeIndex, this._getLastListWidth()); - } } })); this._register(Event.any(this._terminalGroupService.onDidChangeInstances, this._terminalGroupService.onDidChangeGroups)(() => { - this._refreshShowTabs(); - this._updateChatTerminalsEntry(); + this._refreshShowTabs(true); })); this._register(Event.any(this._terminalChatService.onDidRegisterTerminalInstanceWithToolSession, this._terminalService.onDidChangeInstances, this._terminalService.onDidDisposeInstance)(() => { this._refreshShowTabs(); - this._updateChatTerminalsEntry(); })); this._register(contextKeyService.onDidChangeContext(e => { if (e.affectsSome(new Set([TerminalContribContextKeyStrings.ChatHasHiddenTerminals]))) { this._refreshShowTabs(); - this._updateChatTerminalsEntry(); } })); this._attachEventListeners(parentElement, this._terminalContainer); @@ -160,11 +204,27 @@ export class TerminalTabbedView extends Disposable { } })); - this._splitView = new SplitView(parentElement, { orientation: Orientation.HORIZONTAL, proportionalLayout: false }); + this._splitView = this._createSplitView(parentElement); this._setupSplitView(terminalOuterContainer); this._updateChatTerminalsEntry(); } + private get _isHorizontal(): boolean { + return this._location === 'top' || this._location === 'bottom'; + } + + private _createTabsWidget(): ITerminalTabsWidget { + return this._tabsDisposables.add(this._isHorizontal + ? this._instantiationService.createInstance(TerminalTabsBar, this._tabListElement, undefined) + : this._instantiationService.createInstance(TerminalTabList, this._tabListElement)); + } + + private _createSplitView(parent: HTMLElement): SplitView { + this._tabContainer.classList.toggle('horizontal-tabs', this._isHorizontal); + this._tabContainer.classList.toggle('tabs-bottom', this._location === 'bottom'); + return this._layoutDisposables.add(new SplitView(parent, { orientation: this._isHorizontal ? Orientation.VERTICAL : Orientation.HORIZONTAL, proportionalLayout: false })); + } + private _shouldShowTabs(): boolean { const enabled = this._terminalConfigurationService.config.tabs.enabled; const hide = this._terminalConfigurationService.config.tabs.hideCondition; @@ -193,13 +253,14 @@ export class TerminalTabbedView extends Disposable { return false; } - private _refreshShowTabs() { + private _refreshShowTabs(forceLayout = false) { + const hadTabsFocus = this._tabContainer.contains(dom.getActiveElement()); + const previousViewCount = this._splitView.length; + const chatEntryChanged = this._updateChatTerminalsEntry(); if (this._shouldShowTabs()) { if (this._splitView.length === 1) { this._addTabTree(); this._addSashListener(); - this._splitView.resizeView(this._tabTreeIndex, this._getLastListWidth()); - this.rerenderTabs(); } } else { if (this._splitView.length === 2 && !this._terminalTabsMouseContextKey.get()) { @@ -208,10 +269,24 @@ export class TerminalTabbedView extends Disposable { this._removeSashListener(); } } + if ((forceLayout || previousViewCount !== this._splitView.length || chatEntryChanged) && this._width !== undefined && this._height !== undefined) { + this.layout(this._width, this._height); + } + if (hadTabsFocus && !this._tabContainer.contains(dom.getActiveElement())) { + if (this._shouldShowTabs()) { + this._focusTabsWidget(); + } else { + this.focus(); + } + } } - private _updateChatTerminalsEntry(): void { + private _updateChatTerminalsEntry(): boolean { + const count = this._terminalChatService.getToolSessionTerminalInstances(true).length; + const changed = count !== this._hiddenChatTerminalCount; + this._hiddenChatTerminalCount = count; this._chatEntry?.update(); + return changed; } private _getLastListWidth(): number { @@ -280,16 +355,18 @@ export class TerminalTabbedView extends Disposable { } private _setupSplitView(terminalOuterContainer: HTMLElement): void { - this._register(this._splitView.onDidSashReset(() => this._handleOnDidSashReset())); - this._register(this._splitView.onDidSashChange(() => this._handleOnDidSashChange())); + if (!this._isHorizontal) { + this._layoutDisposables.add(this._splitView.onDidSashReset(() => this._handleOnDidSashReset())); + this._layoutDisposables.add(this._splitView.onDidSashChange(() => this._handleOnDidSashChange())); + } if (this._shouldShowTabs()) { this._addTabTree(); } this._splitView.addView({ element: terminalOuterContainer, - layout: width => this._terminalGroupService.groups.forEach(tab => tab.layout(width, this._height || 0)), - minimumSize: 120, + layout: size => this._terminalGroupService.groups.forEach(group => group.layout(this._isHorizontal ? this._width ?? 0 : size, this._isHorizontal ? size : this._height ?? 0)), + minimumSize: this._isHorizontal ? 0 : 120, maximumSize: Number.POSITIVE_INFINITY, onDidChange: () => Disposable.None, priority: LayoutPriority.High @@ -303,9 +380,9 @@ export class TerminalTabbedView extends Disposable { private _addTabTree() { this._splitView.addView({ element: this._tabContainer, - layout: width => this._tabList.layout(this._height || 0, width), - minimumSize: TerminalTabsListSizes.NarrowViewWidth, - maximumSize: TerminalTabsListSizes.MaximumWidth, + layout: size => this._layoutTabs(this._isHorizontal ? this._width ?? 0 : size, this._isHorizontal ? size : this._height ?? 0), + minimumSize: this._isHorizontal ? TerminalTabsBar.HEIGHT : TerminalTabsListSizes.NarrowViewWidth, + maximumSize: this._isHorizontal ? TerminalTabsBar.HEIGHT : TerminalTabsListSizes.MaximumWidth, onDidChange: () => Disposable.None, priority: LayoutPriority.Low }, Sizing.Distribute, this._tabTreeIndex); @@ -318,44 +395,50 @@ export class TerminalTabbedView extends Disposable { } private _addSashListener() { - let interval: IDisposable; - this._sashDisposables = [ - this._splitView.sashes[0].onDidStart(e => { - interval = dom.disposableWindowInterval(dom.getWindow(this._splitView.el), () => { - this.rerenderTabs(); - }, 100); - }), - this._splitView.sashes[0].onDidEnd(e => { - interval.dispose(); - }) - ]; + if (this._isHorizontal) { + return; + } + this._sashDisposables.clear(); + const interval = this._sashDisposables.add(new MutableDisposable()); + this._sashDisposables.add(this._splitView.sashes[0].onDidStart(() => { + interval.value = dom.disposableWindowInterval(dom.getWindow(this._splitView.el), () => this.rerenderTabs(), 100); + })); + this._sashDisposables.add(this._splitView.sashes[0].onDidEnd(() => interval.clear())); } private _removeSashListener() { - if (this._sashDisposables) { - dispose(this._sashDisposables); - this._sashDisposables = undefined; - } + this._sashDisposables.clear(); } private _updateHasText() { - const hasText = this._tabListElement.clientWidth > TerminalTabsListSizes.MidpointViewWidth; + const hasText = this._isHorizontal || this._tabListElement.clientWidth > TerminalTabsListSizes.MidpointViewWidth; this._tabContainer.classList.toggle('has-text', hasText); this._terminalIsTabsNarrowContextKey.set(!hasText); - this._updateChatTerminalsEntry(); + this._chatEntry?.update(); } layout(width: number, height: number): void { - const chatItemHeight = this._chatEntry?.element.style.display === 'none' ? 0 : this._chatEntry?.element.clientHeight; - this._height = height - (chatItemHeight ?? 0); - this._width = width; - this._splitView.layout(width); - if (this._shouldShowTabs()) { - this._splitView.resizeView(this._tabTreeIndex, this._getLastListWidth()); + this._height = Math.max(0, height); + this._width = Math.max(0, width); + this._splitView.layout(this._isHorizontal ? this._height : this._width); + if (this._splitView.length === 2) { + this._splitView.resizeView(this._tabTreeIndex, this._isHorizontal ? TerminalTabsBar.HEIGHT : this._getLastListWidth()); } this._updateHasText(); + if (this._splitView.length === 2) { + const size = this._splitView.getViewSize(this._tabTreeIndex); + this._layoutTabs(this._isHorizontal ? this._width : size, this._isHorizontal ? size : this._height); + } } + private _layoutTabs(width: number, height: number): void { + const chat = this._chatEntry?.element; + const chatVisible = chat && chat.style.display !== 'none'; + this._tabList.layout( + Math.max(0, height - (!this._isHorizontal && chatVisible ? chat.clientHeight : 0)), + Math.max(0, width - (this._isHorizontal && chatVisible ? chat.clientWidth : 0)) + ); + } private _attachEventListeners(parentDomElement: HTMLElement, terminalContainer: HTMLElement): void { this._register(dom.addDisposableListener(this._tabContainer, 'mouseleave', async (event: MouseEvent) => { @@ -405,7 +488,7 @@ export class TerminalTabbedView extends Disposable { if (!this._shouldHandleEmptyAreaDrop(event)) { return; } - void this._handleContainerDrop(event); + void this._handleContainerDrop(event).catch(onUnexpectedError); })); this._register(dom.addDisposableListener(terminalContainer, 'mousedown', async (event: MouseEvent) => { const terminal = this._terminalGroupService.activeInstance; @@ -441,10 +524,13 @@ export class TerminalTabbedView extends Disposable { } // Put the focused item first as it's used as the first positional argument - const selectedInstances = this._tabList.getSelectedElements(); + const selectedInstances = getSelectedTerminalTabInstances(this._tabList); const focusedInstance = this._tabList.getFocusedElements()?.[0]; if (focusedInstance) { - selectedInstances.splice(selectedInstances.findIndex(e => e.instanceId === focusedInstance.instanceId), 1); + const index = selectedInstances.indexOf(focusedInstance); + if (index !== -1) { + selectedInstances.splice(index, 1); + } selectedInstances.unshift(focusedInstance); } @@ -468,6 +554,7 @@ export class TerminalTabbedView extends Disposable { })); this._register(dom.addDisposableListener(this._tabContainer, dom.EventType.FOCUS_IN, () => { this._terminalTabsFocusContextKey.set(true); + this._terminalGroupService.lastAccessedMenu = 'tab-list'; })); this._register(dom.addDisposableListener(this._tabContainer, dom.EventType.FOCUS_OUT, () => { this._terminalTabsFocusContextKey.set(false); @@ -497,69 +584,27 @@ export class TerminalTabbedView extends Disposable { event.preventDefault(); event.stopPropagation(); this._resetEmptyAreaDropState(); - const primaryBackend = this._terminalService.getPrimaryBackend(); - const resources = getTerminalResourcesFromDragEvent(event); - let sourceInstances: ITerminalInstance[] | undefined; - const promises: Promise[] = []; - if (resources) { - for (const uri of resources) { - const instance = this._terminalService.getInstanceFromResource(uri); - if (instance) { - if (sourceInstances) { - sourceInstances.push(instance); - } else { - sourceInstances = [instance]; - } - this._terminalService.moveToTerminalView(instance); - } else if (primaryBackend) { - const terminalIdentifier = parseTerminalUri(uri); - if (terminalIdentifier.instanceId) { - promises.push(primaryBackend.requestDetachInstance(terminalIdentifier.workspaceId, terminalIdentifier.instanceId)); - } - } - } - } - if (promises.length) { - const processes = (await Promise.all(promises)).filter((process): process is IProcessDetails => !!process); - let lastInstance: ITerminalInstance | undefined; - for (const attachPersistentProcess of processes) { - lastInstance = await this._terminalService.createTerminal({ config: { attachPersistentProcess } }); - } - if (lastInstance) { - this._terminalService.setActiveInstance(lastInstance); - } - return; - } - if (!sourceInstances || !sourceInstances.length) { - sourceInstances = this._tabList.getSelectedElements(); - if (!sourceInstances.length) { - return; - } - } - this._terminalGroupService.moveGroupToEnd(sourceInstances); - this._terminalService.setActiveInstance(sourceInstances[0]); - const indexes = sourceInstances - .map(instance => this._terminalGroupService.instances.indexOf(instance)) - .filter(index => index >= 0); - if (indexes.length) { - this._tabList.setSelection(indexes); - this._tabList.setFocus([indexes[0]]); - } + await this._tabDragAndDrop.drop(new NativeDragAndDropData(), undefined, undefined, undefined, event); } private _getTabActions(): IAction[] { + this._tabActionsDisposables.clear(); + const locations: { location: ITerminalConfiguration['tabs']['location']; label: string }[] = [ + { location: 'left', label: localize('moveTabsLeft', "Move Tabs Left") }, + { location: 'right', label: localize('moveTabsRight', "Move Tabs Right") }, + { location: 'top', label: localize('moveTabsTop', "Move Tabs to Top") }, + { location: 'bottom', label: localize('moveTabsBottom', "Move Tabs to Bottom") } + ]; return [ new Separator(), - this._configurationService.inspect(TerminalSettingId.TabsLocation).userValue === 'left' ? - new Action('moveRight', localize('moveTabsRight', "Move Tabs Right"), undefined, undefined, async () => { - this._configurationService.updateValue(TerminalSettingId.TabsLocation, 'right'); - }) : - new Action('moveLeft', localize('moveTabsLeft', "Move Tabs Left"), undefined, undefined, async () => { - this._configurationService.updateValue(TerminalSettingId.TabsLocation, 'left'); - }), - new Action('hideTabs', localize('hideTabs', "Hide Tabs"), undefined, undefined, async () => { - this._configurationService.updateValue(TerminalSettingId.TabsEnabled, false); - }) + ...locations.filter(({ location }) => location !== this._location).map(({ location, label }) => this._tabActionsDisposables.add(new Action(`moveTabs.${location}`, label, undefined, true, async () => { + await this._configurationService.updateValue(TerminalSettingId.TabsLocation, location); + this.focusTabs(); + }))), + this._tabActionsDisposables.add(new Action('hideTabs', localize('hideTabs', "Hide Tabs"), undefined, undefined, async () => { + await this._configurationService.updateValue(TerminalSettingId.TabsEnabled, false); + this.focus(); + })) ]; } @@ -570,16 +615,31 @@ export class TerminalTabbedView extends Disposable { this._tabList.refresh(false); } + getSelectedTabInstances(): ITerminalInstance[] | undefined { + if (!this._shouldShowTabs() || (this._tabList instanceof TerminalTabList && this._listService.lastFocusedList !== this._tabList)) { + return undefined; + } + return getSelectedTerminalTabInstances(this._tabList); + } + focusTabs(): void { if (!this._shouldShowTabs()) { return; } - this._terminalTabsFocusContextKey.set(true); const selected = this._tabList.getSelection(); - this._tabList.domFocus(); - if (selected) { - this._tabList.setFocus(selected); + if (selected.length) { + this._tabList.setFocus(selected.slice(0, 1)); + } else { + const active = this._terminalGroupService.activeInstance; + const activeIndex = active ? this._terminalGroupService.instances.indexOf(active) : -1; + this._tabList.setFocus(activeIndex >= 0 ? [activeIndex] : []); } + this._focusTabsWidget(); + } + + private _focusTabsWidget(): void { + this._terminalTabsFocusContextKey.set(true); + this._tabList.domFocus(); } focus() { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts new file mode 100644 index 00000000000000..cf4c425bbaa02a --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts @@ -0,0 +1,556 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/terminalTabsBar.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; +import { ElementsDragAndDropData, NativeDragAndDropData } from '../../../../base/browser/ui/list/listView.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IDecorationsService } from '../../../services/decorations/common/decorations.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { TerminalLocation, TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../browser/labels.js'; +import { IEditableData } from '../../../common/views.js'; +import { TerminalContextKeys } from '../common/terminalContextKey.js'; +import { TerminalStorageKeys } from '../common/terminalStorageKeys.js'; +import { ITerminalEditingService, ITerminalGroupService, ITerminalInstance, ITerminalService } from './terminal.js'; +import { ITerminalTabEntryTemplate, TabDecorationsProvider, TerminalTabsAccessibilityProvider, TerminalTabsDragAndDrop, TerminalTabsRenderer } from './terminalTabsList.js'; +import { ITerminalTabsWidget } from './terminalTabsWidget.js'; +import { getInstanceHoverInfo } from './terminalTooltip.js'; + +interface ITerminalBarEntry { + readonly element: HTMLElement; + readonly template: ITerminalTabEntryTemplate; + readonly disposables: DisposableStore; + editing: IEditableData | undefined; + rendered: boolean; + dispose(): void; +} + +export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { + static readonly HEIGHT = 28; + + private readonly _element = dom.$('.terminal-tabs-bar', { role: 'tablist', 'aria-orientation': 'horizontal', 'aria-multiselectable': 'true' }); + private readonly _scrollable: DomScrollableElement; + private readonly _renderer: TerminalTabsRenderer; + private readonly _accessibilityProvider: TerminalTabsAccessibilityProvider; + private readonly _dnd: TerminalTabsDragAndDrop; + private readonly _entries = this._register(new DisposableMap()); + private readonly _dragScroll = this._register(new MutableDisposable()); + private readonly _scan = this._register(new MutableDisposable()); + private readonly _singleSelected: IContextKey; + private readonly _splitFocused: IContextKey; + private _instances: readonly ITerminalInstance[] = []; + private readonly _instanceIndexes = new Map(); + private _selection: ITerminalInstance[] = []; + private _focused: ITerminalInstance | undefined; + private _anchor: ITerminalInstance | undefined; + private _activating = false; + private _contextMenuKeyDown = false; + + constructor( + container: HTMLElement, + private readonly _os: OperatingSystem = OS, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, + @ITerminalEditingService private readonly _terminalEditingService: ITerminalEditingService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IDecorationsService decorationsService: IDecorationsService, + @IThemeService themeService: IThemeService, + @IHoverService private readonly _hoverService: IHoverService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + const labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + this._renderer = this._instantiationService.createInstance(TerminalTabsRenderer, labels, () => this.getSelectedElements(), { + getHasText: () => true, + getHasActionBar: () => true, + horizontal: true, + focusTab: instance => { + this._focused = instance; + this.domFocus(); + }, + focusNext: () => this.domFocus() + }); + this._accessibilityProvider = this._instantiationService.createInstance(TerminalTabsAccessibilityProvider); + this._element.setAttribute('aria-label', this._accessibilityProvider.getWidgetAriaLabel()); + this._singleSelected = TerminalContextKeys.tabsSingularSelection.bindTo(contextKeyService); + this._splitFocused = TerminalContextKeys.splitTerminalTabFocused.bindTo(contextKeyService); + this._scrollable = this._register(new DomScrollableElement(this._element, { + horizontal: ScrollbarVisibility.Auto, + vertical: ScrollbarVisibility.Hidden, + horizontalScrollbarSize: 3, + scrollYToX: true, + consumeMouseWheelIfScrollbarIsNeeded: true + })); + container.appendChild(this._scrollable.getDomNode()); + this._register(decorationsService.registerDecorationsProvider(this._register(this._instantiationService.createInstance(TabDecorationsProvider)))); + this._dnd = this._register(this._instantiationService.createInstance(TerminalTabsDragAndDrop, instances => { + this.setSelection(instances.map(instance => this._instances.indexOf(instance))); + this.setFocus(instances.length ? [this._instances.indexOf(instances[0])] : []); + })); + + this._register(Event.any( + this._terminalGroupService.onDidChangeInstances, + this._terminalGroupService.onDidChangeGroups, + this._terminalGroupService.onDidShow, + this._terminalService.onDidChangeConnectionState, + themeService.onDidColorThemeChange + )(() => this.refresh())); + this._register(Event.any( + this._terminalGroupService.onDidChangeInstanceCapability, + this._terminalService.onAnyInstanceTitleChange, + this._terminalService.onAnyInstancePrimaryStatusChange + )(instance => this._refreshInstance(instance))); + this._register(this._terminalService.onAnyInstanceIconChange(({ instance }) => this._refreshInstance(instance))); + this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, TerminalStorageKeys.TabsShowDetailed, this._store)(() => this.refresh())); + this._register(this._terminalGroupService.onDidChangeActiveInstance(instance => { + if (!this._activating && instance) { + this._selection = [instance]; + this._focused = instance; + this._anchor = instance; + } + if (instance && !this._instanceIndexes.has(instance)) { + this.refresh(); + } else { + this._updateState(); + } + this._reveal(instance); + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.DBLCLICK, e => { + if (!this._getEntry(e.target)) { + void this._createTerminal().catch(onUnexpectedError); + } + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.CONTEXT_MENU, e => { + if (this._contextMenuKeyDown) { + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + const instance = this._getEntry(e.target); + if (instance) { + this.setFocus([this._instances.indexOf(instance)]); + } else { + this.setSelection([]); + this.setFocus([]); + } + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.DRAG_OVER, e => this._onDragOver(e))); + this._register(dom.addDisposableListener(this._element, dom.EventType.DROP, e => { + void this._onDrop(e).catch(onUnexpectedError); + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.DRAG_LEAVE, e => { + if (!dom.isHTMLElement(e.relatedTarget) || !this._element.contains(e.relatedTarget)) { + this._clearDrag(); + } + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.DRAG_END, () => this._clearDrag())); + this._register(dom.addDisposableListener(this._element, dom.EventType.SCROLL, () => this._scrollable.scanDomNode())); + this._register(dom.addDisposableListener(this._element, dom.EventType.FOCUS, () => { + if (this._instances.length) { + this.domFocus(); + } + })); + this._register(dom.addDisposableListener(this._element, dom.EventType.FOCUS_OUT, e => { + if (!dom.isHTMLElement(e.relatedTarget) || !this._element.contains(e.relatedTarget)) { + this._contextMenuKeyDown = false; + } + })); + const active = this._terminalGroupService.activeInstance; + this._selection = active ? [active] : []; + this._focused = active; + this._anchor = active; + this.refresh(); + } + + getHTMLElement(): HTMLElement { return this._element; } + getSelection(): number[] { return this._selection.map(instance => this._instances.indexOf(instance)).filter(index => index >= 0); } + getFocus(): number[] { return this._focused && this._instances.includes(this._focused) ? [this._instances.indexOf(this._focused)] : []; } + getSelectedElements(): ITerminalInstance[] { return this._selection.slice(); } + getFocusedElements(): ITerminalInstance[] { return this._focused ? [this._focused] : []; } + + setSelection(indexes: number[]): void { + this._selection = [...new Set(indexes)].filter(index => index >= 0 && index < this._instances.length).map(index => this._instances[index]); + this._updateState(); + } + + setFocus(indexes: number[]): void { + this._focused = this._instances[indexes[0]]; + this._updateState(); + this._reveal(this._focused); + } + + domFocus(): void { + this._focused ??= this._terminalGroupService.activeInstance ?? this._instances[0]; + this._updateState(); + (this._focused ? this._entries.get(this._focused.instanceId)?.element : undefined)?.focus(); + if (!this._focused) { + this._element.focus(); + } + this._reveal(this._focused); + } + + focusHover(): void { + const instance = this._focused ?? this._selection[0]; + const entry = instance && this._entries.get(instance.instanceId); + if (instance && entry) { + this._hoverService.showInstantHover({ ...getInstanceHoverInfo(instance, this._storageService), target: entry.element, trapFocus: true }, true); + } + } + + layout(height: number, width: number): void { + this._scrollable.getDomNode().style.width = `${Math.max(0, width)}px`; + this._element.style.height = `${Math.max(0, height)}px`; + this._scrollable.scanDomNode(); + this._reveal(this._focused ?? this._terminalGroupService.activeInstance); + } + + refresh(): void { + const hadFocus = this._element.contains(dom.getActiveElement()); + const oldFocusedIndex = this._focused ? this._instances.indexOf(this._focused) : 0; + this._instances = this._terminalGroupService.instances.slice(); + this._instanceIndexes.clear(); + this._instances.forEach((instance, index) => this._instanceIndexes.set(instance, index)); + this._selection = this._selection.filter(instance => this._instanceIndexes.has(instance)); + if (!this._focused || !this._instanceIndexes.has(this._focused)) { + this._focused = this._instances[Math.max(0, Math.min(oldFocusedIndex, this._instances.length - 1))]; + } + if (!this._anchor || !this._instanceIndexes.has(this._anchor)) { + this._anchor = this._focused; + } + const instanceIds = new Set(this._instances.map(instance => instance.instanceId)); + for (const [id] of this._entries) { + if (!instanceIds.has(id)) { + this._entries.deleteAndDispose(id); + } + } + this._instances.forEach((instance, index) => { + let entry = this._entries.get(instance.instanceId); + if (!entry) { + entry = this._createEntry(instance); + this._entries.set(instance.instanceId, entry); + } + this._renderEntry(instance, index, entry); + entry.element.dataset.index = String(index); + entry.element.setAttribute('aria-label', this._accessibilityProvider.getAriaLabel(instance)); + const group = this._terminalGroupService.getGroupForInstance(instance); + entry.element.classList.toggle('group-start', index > 0 && group !== this._terminalGroupService.getGroupForInstance(this._instances[index - 1])); + if (this._element.children[index] !== entry.element) { + this._element.insertBefore(entry.element, this._element.children[index] ?? null); + } + }); + this._updateState(); + this._scrollable.scanDomNode(); + if (hadFocus && !this._element.contains(dom.getActiveElement())) { + this.domFocus(); + } + } + + private _renderEntry(instance: ITerminalInstance, index: number, entry: ITerminalBarEntry): void { + const editing = this._terminalEditingService.getEditableData(instance); + if (!entry.rendered || entry.editing !== editing) { + this._renderer.disposeElement(instance, index, entry.template); + entry.editing = editing; + this._renderer.renderElement(instance, index, entry.template); + entry.rendered = true; + } else if (!editing) { + this._renderer.updateElement(instance, entry.template); + this._renderer.updateActionBar(instance, entry.template); + } + entry.element.draggable = !editing; + } + + private _refreshInstance(instance: ITerminalInstance): void { + const entry = this._entries.get(instance.instanceId); + const index = this._instanceIndexes.get(instance); + if (!entry || index === undefined) { + return; // The terminal may be hidden or hosted in an editor. + } + this._renderEntry(instance, index, entry); + entry.element.setAttribute('aria-label', this._accessibilityProvider.getAriaLabel(instance)); + if (!this._scan.value) { + this._scan.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this._element), () => { + this._scan.clear(); + this._scrollable.scanDomNode(); + }); + } + } + + private _createEntry(instance: ITerminalInstance): ITerminalBarEntry { + const element = dom.$('.terminal-tabs-bar-tab', { role: 'tab', 'data-instance-id': instance.instanceId }); + this._element.appendChild(element); + const disposables = new DisposableStore(); + const template = this._renderer.renderTemplate(element); + disposables.add(dom.addDisposableListener(element, dom.EventType.CLICK, e => { + if (!this._isEntryAction(e)) { + void this._onClick(instance, e).catch(onUnexpectedError); + } + })); + disposables.add(dom.addDisposableListener(element, dom.EventType.DBLCLICK, e => { + if (!this._isEntryAction(e) && this._configurationService.getValue(TerminalSettingId.TabsFocusMode) === 'doubleClick') { + instance.focus(true); + } + })); + disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_DOWN, e => this._onKeyDown(instance, e))); + disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_UP, e => this._onKeyUp(instance, e))); + disposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS, () => { + this._focused = instance; + this._terminalGroupService.lastAccessedMenu = 'tab-list'; + this._updateState(); + })); + disposables.add(dom.addDisposableListener(element, dom.EventType.DRAG_START, e => { + if (!e.dataTransfer || !this._dnd.getDragURI(instance)) { + e.preventDefault(); + return; + } + if (!this._selection.includes(instance)) { + this.setSelection([this._instances.indexOf(instance)]); + } + e.dataTransfer.effectAllowed = 'copyMove'; + this._dnd.onDragStart(new ElementsDragAndDropData(this._selection.slice()), e); + applyDragImage(e, element, this._selection.length === 1 ? instance.title : `${this._selection.length}`); + })); + return { + element, template, disposables, editing: undefined, rendered: false, + dispose: () => { + disposables.dispose(); + this._renderer.disposeElement(instance, 0, template); + this._renderer.disposeTemplate(template); + element.remove(); + } + }; + } + + private _isEntryAction(event: MouseEvent): boolean { + return dom.isHTMLElement(event.target) && !!event.target.closest('.actions, .monaco-inputbox'); + } + + private async _onClick(instance: ITerminalInstance, event: MouseEvent): Promise { + this._terminalGroupService.lastAccessedMenu = 'tab-list'; + const useAltForSelection = this._configurationService.getValue('workbench.list.multiSelectModifier') === 'alt'; + if (event.altKey && !useAltForSelection) { + await this._terminalService.createTerminal({ location: { parentTerminal: instance } }); + return; + } + const selectionModifier = useAltForSelection ? event.altKey : this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey; + this._select(instance, event.shiftKey, selectionModifier); + this.domFocus(); + if (!event.shiftKey && !selectionModifier) { + this._activate(instance); + if (this._configurationService.getValue(TerminalSettingId.TabsFocusMode) === 'singleClick') { + instance.focus(true); + } + } + } + + private _select(instance: ITerminalInstance, range: boolean, toggle: boolean): void { + if (range) { + const anchor = this._instances.indexOf(this._anchor ?? this._focused ?? instance); + const index = this._instances.indexOf(instance); + this._selection = this._instances.slice(Math.min(anchor, index), Math.max(anchor, index) + 1); + } else { + this._anchor = instance; + this._selection = toggle + ? this._selection.includes(instance) ? this._selection.filter(selected => selected !== instance) : [...this._selection, instance] + : [instance]; + } + this._focused = instance; + this._updateState(); + } + + private _onKeyDown(instance: ITerminalInstance, event: KeyboardEvent): void { + if (event.target !== this._entries.get(instance.instanceId)?.element) { + return; + } + const keyboardEvent = new StandardKeyboardEvent(event); + this._contextMenuKeyDown = this._isContextMenuKey(keyboardEvent); + if (this._contextMenuKeyDown) { + event.preventDefault(); + event.stopPropagation(); + return; + } + const modifier = this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey; + let index = this._instances.indexOf(instance); + switch (keyboardEvent.keyCode) { + case KeyCode.LeftArrow: index--; break; + case KeyCode.RightArrow: index++; break; + case KeyCode.Home: index = 0; break; + case KeyCode.End: index = this._instances.length - 1; break; + case KeyCode.KeyA: + if (!modifier || event.shiftKey || event.altKey || (this._os === OperatingSystem.Macintosh ? event.ctrlKey : event.metaKey)) { + return; + } + this.setSelection(this._instances.map((_, i) => i)); + event.preventDefault(); + event.stopPropagation(); + return; + case KeyCode.Enter: + if (this._os === OperatingSystem.Macintosh) { + return; + } + this._activate(instance); + instance.focus(true); + event.preventDefault(); + event.stopPropagation(); + return; + case KeyCode.Space: + if (modifier) { + this._select(instance, false, true); + } else { + this._activate(instance); + instance.focus(true); + } + event.preventDefault(); + event.stopPropagation(); + return; + default: return; + } + event.preventDefault(); + event.stopPropagation(); + const target = this._instances[Math.max(0, Math.min(index, this._instances.length - 1))]; + if (modifier && !event.shiftKey) { + this._focused = target; + } else { + this._select(target, event.shiftKey, false); + if (!event.shiftKey) { + this._activate(target); + } + } + this.domFocus(); + } + + private _isContextMenuKey(event: StandardKeyboardEvent): boolean { + return event.keyCode !== KeyCode.KEY_IN_COMPOSITION && + (event.keyCode === KeyCode.ContextMenu || event.code === 'ContextMenu' || (event.shiftKey && event.keyCode === KeyCode.F10)); + } + + private _onKeyUp(instance: ITerminalInstance, event: KeyboardEvent): void { + this._contextMenuKeyDown = false; + const element = this._entries.get(instance.instanceId)?.element; + if (!element || event.target !== element || !this._isContextMenuKey(new StandardKeyboardEvent(event))) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const bounds = element.getBoundingClientRect(); + element.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: bounds.left, clientY: bounds.bottom })); + } + + private _activate(instance: ITerminalInstance): void { + this._activating = true; + try { + this._terminalGroupService.setActiveInstance(instance); + } finally { + this._activating = false; + } + } + + private _updateState(): void { + this._singleSelected.set(this._selection.length === 1); + this._splitFocused.set(!!this._focused && this._terminalGroupService.instanceIsSplit(this._focused)); + this._element.tabIndex = this._focused ? -1 : 0; + const selectedInstances = new Set(this._selection); + const activeInstance = this._terminalGroupService.activeInstance; + for (const instance of this._instances) { + const entry = this._entries.get(instance.instanceId); + if (entry) { + const selected = selectedInstances.has(instance); + entry.element.tabIndex = instance === this._focused ? 0 : -1; + entry.element.classList.toggle('selected', selected); + entry.element.setAttribute('aria-selected', String(selected)); + entry.element.setAttribute('aria-current', String(instance === activeInstance)); + entry.template.element.classList.toggle('is-active', instance === activeInstance); + } + } + } + + private _reveal(instance: ITerminalInstance | undefined): void { + const entry = instance && this._entries.get(instance.instanceId); + if (!entry || this._element.clientWidth === 0) { + return; + } + const left = entry.element.offsetLeft; + const right = left + entry.element.offsetWidth; + const scrollLeft = this._element.scrollLeft; + if (left < scrollLeft || entry.element.offsetWidth > this._element.clientWidth) { + this._scrollable.setScrollPosition({ scrollLeft: left }); + } else if (right > scrollLeft + this._element.clientWidth) { + this._scrollable.setScrollPosition({ scrollLeft: right - this._element.clientWidth }); + } + } + + private _getEntry(target: EventTarget | null): ITerminalInstance | undefined { + const element = dom.isHTMLElement(target) ? target.closest('.terminal-tabs-bar-tab') : null; + return element ? this._instances.find(instance => String(instance.instanceId) === element.dataset.instanceId) : undefined; + } + + private _onDragOver(event: DragEvent): void { + const instance = this._getEntry(event.target); + const reaction = this._dnd.onDragOver(new NativeDragAndDropData(), instance, instance ? this._instances.indexOf(instance) : undefined, undefined, event); + if (!reaction || (typeof reaction !== 'boolean' && !reaction.accept)) { + this._clearDrag(); + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + this._element.classList.toggle('drop-target', !instance); + for (const [id, entry] of this._entries) { + entry.element.classList.toggle('drop-target', id === instance?.instanceId); + } + const bounds = this._element.getBoundingClientRect(); + const direction = event.clientX < bounds.left + 24 ? -1 : event.clientX > bounds.right - 24 ? 1 : 0; + this._dragScroll.clear(); + if (direction) { + this._dragScroll.value = dom.animate(dom.getWindow(this._element), () => { + this._scrollable.setScrollPosition({ scrollLeft: this._element.scrollLeft + direction * 8 }); + }); + } + } + + private async _onDrop(event: DragEvent): Promise { + const instance = this._getEntry(event.target); + const reaction = this._dnd.onDragOver(new NativeDragAndDropData(), instance, undefined, undefined, event); + if (!reaction || (typeof reaction !== 'boolean' && !reaction.accept)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this._clearDrag(); + await this._dnd.drop(new NativeDragAndDropData(), instance, undefined, undefined, event); + } + + private _clearDrag(): void { + this._dragScroll.clear(); + this._dnd.onDragLeave(); + this._element.classList.remove('drop-target'); + for (const [, entry] of this._entries) { + entry.element.classList.remove('drop-target'); + } + } + + private async _createTerminal(): Promise { + const instance = await this._terminalService.createTerminal({ location: TerminalLocation.Panel }); + this._terminalGroupService.setActiveInstance(instance); + await instance.focusWhenReady(); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 3628fada4862df..6a360801f7ae08 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -67,6 +67,7 @@ export class TerminalTabsChatEntry extends Disposable { this._register(dom.addDisposableListener(this._entry, dom.EventType.KEY_DOWN, e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); + e.stopPropagation(); runChatTerminalsCommand(); } })); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index 013ee56258f7a2..482fcf46dac034 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IListService, WorkbenchList } from '../../../../platform/list/browser/listService.js'; -import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; +import { IListAccessibilityProvider, isSelectionSingleChangeEvent } from '../../../../base/browser/ui/list/listWidget.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -18,7 +18,7 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { TerminalCommandId } from '../common/terminal.js'; -import { ITerminalBackend, TerminalLocation, TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; +import { TerminalLocation, TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Action } from '../../../../base/common/actions.js'; import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../browser/labels.js'; @@ -41,7 +41,6 @@ import { KeyCode } from '../../../../base/common/keyCodes.js'; import { CodeDataTransfers, containsDragType, getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; import { terminalStrings } from '../common/terminalStrings.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; -import { IProcessDetails } from '../../../../platform/terminal/common/terminalProcess.js'; import { TerminalContextKeys } from '../common/terminalContextKey.js'; import { getTerminalResourcesFromDragEvent, parseTerminalUri } from './terminalUri.js'; import { getInstanceHoverInfo } from './terminalTooltip.js'; @@ -56,6 +55,10 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { TerminalStorageKeys } from '../common/terminalStorageKeys.js'; import { isObject } from '../../../../base/common/types.js'; +import { ITerminalTabsWidget } from './terminalTabsWidget.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; const $ = DOM.$; @@ -69,7 +72,7 @@ export const enum TerminalTabsListSizes { MaximumWidth = 500 } -export class TerminalTabList extends WorkbenchList { +export class TerminalTabList extends WorkbenchList implements ITerminalTabsWidget { private _decorationsProvider: TabDecorationsProvider | undefined; private _terminalTabsSingleSelectedContextKey: IContextKey; private _isSplitContextKey: IContextKey; @@ -95,14 +98,26 @@ export class TerminalTabList extends WorkbenchList { @ILifecycleService lifecycleService: ILifecycleService, @IHoverService private readonly _hoverService: IHoverService, ) { + const labels = instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER); + const dnd = instantiationService.createInstance(TerminalTabsDragAndDrop, instances => { + const indexes = instances.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0); + this.setSelection(indexes); + this.setFocus(indexes.slice(0, 1)); + }); super('TerminalTabsList', container, { getHeight: () => TerminalTabsListSizes.TabHeight, getTemplateId: () => 'terminal.tabs' }, - [instantiationService.createInstance(TerminalTabsRenderer, container, instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER), () => this.getSelectedElements(), { + [instantiationService.createInstance(TerminalTabsRenderer, labels, () => this.getSelectedElements(), { getHasText: () => this.hasText, - getHasActionBar: () => this.hasActionBar + getHasActionBar: () => this.hasActionBar, + horizontal: false, + focusTab: instance => { + this.setFocus([this._terminalGroupService.instances.indexOf(instance)]); + this.domFocus(); + }, + focusNext: () => this.focusNext() })], { horizontalScrolling: false, @@ -115,7 +130,7 @@ export class TerminalTabList extends WorkbenchList { smoothScrolling: _configurationService.getValue('workbench.list.smoothScrolling'), multipleSelectionSupport: true, paddingBottom: TerminalTabsListSizes.TabHeight, - dnd: instantiationService.createInstance(TerminalTabsDragAndDrop), + dnd, openOnSingleClick: true }, contextKeyService, @@ -123,6 +138,8 @@ export class TerminalTabList extends WorkbenchList { _configurationService, instantiationService, ); + this.disposables.add(labels); + this.disposables.add(dnd); const instanceDisposables: IDisposable[] = [ this._terminalGroupService.onDidChangeInstances(() => this.refresh()), @@ -182,9 +199,10 @@ export class TerminalTabList extends WorkbenchList { return; } - if (e.browserEvent.altKey && e.element) { + const selectionModifier = this.useAltAsMultipleSelectionModifier ? e.browserEvent.altKey : isSelectionSingleChangeEvent(e); + if (e.browserEvent.altKey && !this.useAltAsMultipleSelectionModifier && e.element) { await this._terminalService.createTerminal({ location: { parentTerminal: e.element } }); - } else if (this._getFocusMode() === 'singleClick') { + } else if (this._getFocusMode() === 'singleClick' && !selectionModifier && !e.browserEvent.shiftKey) { if (this.getSelection().length <= 1) { e.element?.focus(true); } @@ -271,11 +289,10 @@ export class TerminalTabList extends WorkbenchList { } } -class TerminalTabsRenderer implements IListRenderer { +export class TerminalTabsRenderer implements IListRenderer { templateId = 'terminal.tabs'; constructor( - _container: HTMLElement, private readonly _labels: ResourceLabels, private readonly _getSelection: () => ITerminalInstance[], private readonly _getVisibilityState: ITerminalTabsRendererOptions, @@ -286,7 +303,6 @@ class TerminalTabsRenderer implements IListRenderer { + e.stopImmediatePropagation(); + if (e.button === 1/*middle*/) { + void this._terminalService.safeDisposeTerminal(instance); + } + })); + + const editableData = this._terminalEditingService.getEditableData(instance); + template.label.element.classList.toggle('editable-tab', !!editableData); + if (editableData) { + // eslint-disable-next-line no-restricted-syntax + template.elementDisposables.add(this._renderInputBox(template.label.element.querySelector('.monaco-icon-label-container')!, instance, editableData)); + template.actionBar.clear(); + } + } + + updateElement(instance: ITerminalInstance, template: ITerminalTabEntryTemplate): void { const hasText = this._getVisibilityState.getHasText(); - const hasActionBar = this._getVisibilityState.getHasActionBar(); const group = this._terminalGroupService.getGroupForInstance(instance); if (!group) { @@ -356,7 +403,7 @@ class TerminalTabsRenderer implements IListRenderer 1) { + if (!this._getVisibilityState.horizontal && group.terminalInstances.length > 1) { const terminalIndex = group.terminalInstances.indexOf(instance); if (terminalIndex === 0) { prefix = `┌ `; @@ -381,7 +428,6 @@ class TerminalTabsRenderer implements IListRenderer { - e.stopImmediatePropagation(); - if (e.button === 1/*middle*/) { - this._terminalService.safeDisposeTerminal(instance); - } - })); - const extraClasses: string[] = []; const colorClass = getColorClass(instance); if (colorClass) { @@ -427,13 +461,6 @@ class TerminalTabsRenderer implements IListRenderer { + const action = template.actionBar.getAction(index + 1); + return action?.id === definition.id && action.label === definition.label && + action.class === (definition.icon ? ThemeIcon.asClassName(definition.icon) : ''); + })) { + return; + } + + this.fillActionBar(instance, template); + } + fillActionBar(instance: ITerminalInstance, template: ITerminalTabEntryTemplate): void { + const activeElement = DOM.getActiveElement(); + const focusedAction = DOM.isHTMLElement(activeElement) ? template.actionBar.getAction(activeElement) : undefined; + let focusedIndex = -1; + if (focusedAction) { + for (let i = 0; i < template.actionBar.length(); i++) { + if (template.actionBar.getAction(i) === focusedAction) { + focusedIndex = i; + break; + } + } + // Keep focus-revealed controls visible while replacing the focused action. + this._getVisibilityState.focusTab(instance); + } + template.actionBar.clear(); + template.actionDisposables.clear(); + // If the instance is within the selection, split all selected const actions = [ - template.elementDisposables.add(new Action(TerminalCommandId.SplitActiveTab, terminalStrings.split.short, ThemeIcon.asClassName(Codicon.splitHorizontal), true, async () => { - this._runForSelectionOrInstance(instance, async e => { - this._terminalService.createTerminal({ location: { parentTerminal: e } }); + template.actionDisposables.add(new Action(TerminalCommandId.SplitActiveTab, terminalStrings.split.short, ThemeIcon.asClassName(Codicon.splitHorizontal), true, async () => { + await this._runForSelectionOrInstance(instance, async e => { + await this._terminalService.createTerminal({ location: { parentTerminal: e } }); }); })), ]; if (instance.shellLaunchConfig.tabActions) { - for (const action of instance.shellLaunchConfig.tabActions) { - actions.push(template.elementDisposables.add(new Action(action.id, action.label, action.icon ? ThemeIcon.asClassName(action.icon) : undefined, true, async () => { - this._runForSelectionOrInstance(instance, e => this._commandService.executeCommand(action.id, instance)); + for (const { id, label, icon } of instance.shellLaunchConfig.tabActions) { + actions.push(template.actionDisposables.add(new Action(id, label, icon ? ThemeIcon.asClassName(icon) : undefined, true, async () => { + await this._runForSelectionOrInstance(instance, async e => { + await this._commandService.executeCommand(id, e); + }); }))); } } - actions.push(template.elementDisposables.add(new Action(TerminalCommandId.KillActiveTab, terminalStrings.kill.short, ThemeIcon.asClassName(Codicon.trashcan), true, async () => { - this._runForSelectionOrInstance(instance, e => this._terminalService.safeDisposeTerminal(e)); + actions.push(template.actionDisposables.add(new Action(TerminalCommandId.KillActiveTab, terminalStrings.kill.short, ThemeIcon.asClassName(Codicon.trashcan), true, async () => { + await this._runForSelectionOrInstance(instance, e => this._terminalService.safeDisposeTerminal(e)); }))); - // TODO: Cache these in a way that will use the correct instance - template.actionBar.clear(); for (const action of actions) { template.actionBar.push(action, { icon: true, label: false, keybinding: this._keybindingService.lookupKeybinding(action.id)?.getLabel() }); } + if (focusedAction) { + const index = actions.findIndex(action => action.id === focusedAction.id); + template.actionBar.focus(index === -1 ? Math.min(focusedIndex, actions.length - 1) : index); + } } - private _runForSelectionOrInstance(instance: ITerminalInstance, callback: (instance: ITerminalInstance) => void) { + private async _runForSelectionOrInstance(instance: ITerminalInstance, callback: (instance: ITerminalInstance) => Promise): Promise { const selection = this._getSelection(); - if (selection.includes(instance)) { - for (const s of selection) { - if (s) { - callback(s); - } - } - } else { - callback(instance); - } + await Promise.all((selection.includes(instance) ? selection : [instance]).map(callback)); this._terminalGroupService.focusTabs(); - this._listService.lastFocusedList?.focusNext(); + this._getVisibilityState.focusNext(); } } interface ITerminalTabsRendererOptions { getHasText: () => boolean; getHasActionBar: () => boolean; + readonly horizontal: boolean; + focusTab(instance: ITerminalInstance): void; + focusNext(): void; } -interface ITerminalTabEntryTemplate { +export interface ITerminalTabEntryTemplate { readonly element: HTMLElement; readonly label: IResourceLabel; readonly actionBar: ActionBar; @@ -577,11 +633,12 @@ interface ITerminalTabEntryTemplate { hoverActions?: IHoverAction[]; }; readonly elementDisposables: DisposableStore; + readonly actionDisposables: DisposableStore; readonly templateDisposables: DisposableStore; } -class TerminalTabsAccessibilityProvider implements IListAccessibilityProvider { +export class TerminalTabsAccessibilityProvider implements IListAccessibilityProvider { constructor( @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, ) { } @@ -617,19 +674,19 @@ class TerminalTabsAccessibilityProvider implements IListAccessibilityProvider { +export class TerminalTabsDragAndDrop extends Disposable implements IListDragAndDrop { private _autoFocusInstance: ITerminalInstance | undefined; private _autoFocusDisposable: IDisposable = Disposable.None; - private _primaryBackend: ITerminalBackend | undefined; constructor( + private readonly _selectInstances: (instances: ITerminalInstance[]) => void, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @ITerminalEditingService private readonly _terminalEditingService: ITerminalEditingService, - @IListService private readonly _listService: IListService, + @INotificationService private readonly _notificationService: INotificationService, + @ILogService private readonly _logService: ILogService, ) { super(); - this._primaryBackend = this._terminalService.getPrimaryBackend(); } getDragURI(instance: ITerminalInstance): string | null { @@ -650,6 +707,11 @@ class TerminalTabsDragAndDrop extends Disposable implements IListDragAndDrop { + /** Reports a user-initiated drop failure once and returns whether the entire operation succeeded. */ + async drop(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): Promise { + try { + await this.performDrop(data, targetInstance, targetIndex, targetSector, originalEvent); + return true; + } catch (error) { + const reportedError = error instanceof Error ? error : new Error(toErrorMessage(error)); + this._logService.error(reportedError); + this._notificationService.error(reportedError); + return false; + } + } + + /** Performs a drop, rejecting with partial-transfer context on failure. */ + async performDrop(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): Promise { this._autoFocusDisposable.dispose(); this._autoFocusInstance = undefined; - let sourceInstances: ITerminalInstance[] | undefined; - const promises: Promise[] = []; - const resources = getTerminalResourcesFromDragEvent(originalEvent); - if (resources) { - for (const uri of resources) { - const instance = this._terminalService.getInstanceFromResource(uri); - if (instance) { - if (Array.isArray(sourceInstances)) { + let completedTransfers = 0; + try { + let sourceInstances: ITerminalInstance[] | undefined; + const primaryBackend = this._terminalService.getPrimaryBackend(); + const resources = getTerminalResourcesFromDragEvent(originalEvent); + if (resources) { + for (const uri of resources) { + const instance = this._terminalService.getInstanceFromResource(uri); + if (instance) { + const alreadyInView = this._terminalGroupService.instances.includes(instance); + await this._terminalService.moveToTerminalView(instance); + if (!alreadyInView) { + completedTransfers++; + } + sourceInstances ??= []; sourceInstances.push(instance); + } else if (primaryBackend) { + const terminalIdentifier = parseTerminalUri(uri); + if (terminalIdentifier.instanceId) { + const attachPersistentProcess = await primaryBackend.requestDetachInstance(terminalIdentifier.workspaceId, terminalIdentifier.instanceId); + if (!attachPersistentProcess) { + throw new Error(localize('terminalDropDetachFailed', "Cannot move the terminal because it could not be detached from its original window.")); + } + sourceInstances ??= []; + sourceInstances.push(await this._terminalService.createTerminal({ config: { attachPersistentProcess } })); + completedTransfers++; + } } else { - sourceInstances = [instance]; - } - this._terminalService.moveToTerminalView(instance); - } else if (this._primaryBackend) { - const terminalIdentifier = parseTerminalUri(uri); - if (terminalIdentifier.instanceId) { - promises.push(this._primaryBackend.requestDetachInstance(terminalIdentifier.workspaceId, terminalIdentifier.instanceId)); + throw new Error(localize('terminalDropBackendUnavailable', "Cannot move the terminal because its terminal connection is unavailable.")); } } } - } - if (promises.length) { - let processes = await Promise.all(promises); - processes = processes.filter(p => p !== undefined); - let lastInstance: ITerminalInstance | undefined; - for (const attachPersistentProcess of processes) { - lastInstance = await this._terminalService.createTerminal({ config: { attachPersistentProcess } }); - } - if (lastInstance) { - this._terminalService.setActiveInstance(lastInstance); - } - return; - } + if (sourceInstances === undefined) { + if (!(data instanceof ElementsDragAndDropData)) { + await this._handleExternalDrop(targetInstance, originalEvent); + return; + } - if (sourceInstances === undefined) { - if (!(data instanceof ElementsDragAndDropData)) { - this._handleExternalDrop(targetInstance, originalEvent); - return; - } + const draggedElement = data.getData(); + if (!draggedElement || !Array.isArray(draggedElement)) { + return; + } - const draggedElement = data.getData(); - if (!draggedElement || !Array.isArray(draggedElement)) { - return; + sourceInstances = []; + for (const e of draggedElement) { + if (isTerminalInstance(e)) { + sourceInstances.push(e as ITerminalInstance); + } + } } - sourceInstances = []; - for (const e of draggedElement) { - if (isTerminalInstance(e)) { - sourceInstances.push(e as ITerminalInstance); - } + if (!targetInstance) { + this._terminalGroupService.moveGroupToEnd(sourceInstances); + this._terminalService.setActiveInstance(sourceInstances[0]); + this._selectInstances(sourceInstances); + return; } - } - if (!targetInstance) { - this._terminalGroupService.moveGroupToEnd(sourceInstances); + this._terminalGroupService.moveGroup(sourceInstances, targetInstance); this._terminalService.setActiveInstance(sourceInstances[0]); - const targetGroup = this._terminalGroupService.getGroupForInstance(sourceInstances[0]); - if (targetGroup) { - const index = this._terminalGroupService.groups.indexOf(targetGroup); - this._listService.lastFocusedList?.setSelection([index]); + this._selectInstances(sourceInstances); + } catch (error) { + if (completedTransfers > 0) { + const message = completedTransfers === 1 + ? localize('terminalDropPartialSingle', "The terminal drop failed after moving one terminal. It remains available in this window. {0}", toErrorMessage(error)) + : localize('terminalDropPartialMultiple', "The terminal drop failed after moving {0} terminals. They remain available in this window. {1}", completedTransfers, toErrorMessage(error)); + throw new Error(message, { cause: error }); } - return; - } - - this._terminalGroupService.moveGroup(sourceInstances, targetInstance); - this._terminalService.setActiveInstance(sourceInstances[0]); - const targetGroup = this._terminalGroupService.getGroupForInstance(sourceInstances[0]); - if (targetGroup) { - const index = this._terminalGroupService.groups.indexOf(targetGroup); - this._listService.lastFocusedList?.setSelection([index]); + throw error; } } @@ -807,7 +882,7 @@ class TerminalTabsDragAndDrop extends Disposable implements IListDragAndDrop()); 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 00000000000000..e40b524d988488 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsWidget.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { ITerminalInstance } from './terminal.js'; + +export interface ITerminalTabsWidget extends IDisposable { + getHTMLElement(): HTMLElement; + layout(height: number, width: number): void; + refresh(cancelEditing?: boolean): void; + domFocus(): void; + focusHover(): void; + getSelection(): number[]; + setSelection(indexes: number[]): void; + getFocus(): number[]; + setFocus(indexes: number[]): void; + getSelectedElements(): ITerminalInstance[]; + getFocusedElements(): ITerminalInstance[]; +} + +export function getSelectedTerminalTabInstances(tabs: ITerminalTabsWidget): ITerminalInstance[] { + const selection = tabs.getSelectedElements(); + const focused = tabs.getFocusedElements()[0]; + return focused && !selection.includes(focused) ? [focused] : selection; +} diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 0532a06259c3f9..e2029c83c21a9b 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -193,7 +193,7 @@ export interface ITerminalConfiguration { enabled: boolean; hideCondition: 'never' | 'singleTerminal' | 'singleGroup'; showActiveTerminal: 'always' | 'singleTerminal' | 'singleTerminalOrNarrow' | 'singleGroup' | 'never'; - location: 'left' | 'right'; + location: 'left' | 'right' | 'top' | 'bottom'; focusMode: 'singleClick' | 'doubleClick'; title: string; description: string; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 83b558e99db32d..23f1a2f48be43b 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -60,7 +60,7 @@ const terminalConfiguration: IStringDictionary = { scope: ConfigurationScope.RESOURCE }, [TerminalSettingId.TabsEnabled]: { - description: localize('terminal.integrated.tabs.enabled', 'Controls whether terminal tabs display as a list to the side of the terminal. When this is disabled a dropdown will display instead.'), + description: localize('terminal.integrated.tabs.enabled', "Controls whether terminal tabs are displayed. When this is disabled a dropdown will display instead."), type: 'boolean', default: true, }, @@ -106,13 +106,15 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.TabsLocation]: { type: 'string', - enum: ['left', 'right'], + enum: ['left', 'right', 'top', 'bottom'], enumDescriptions: [ localize('terminal.integrated.tabs.location.left', "Show the terminal tabs view to the left of the terminal"), - localize('terminal.integrated.tabs.location.right', "Show the terminal tabs view to the right of the terminal") + localize('terminal.integrated.tabs.location.right', "Show the terminal tabs view to the right of the terminal"), + localize('terminal.integrated.tabs.location.top', "Show terminal tabs in a horizontal row above the terminal"), + localize('terminal.integrated.tabs.location.bottom', "Show terminal tabs in a horizontal row below the terminal") ], default: 'right', - description: localize('terminal.integrated.tabs.location', "Controls the location of the terminal tabs, either to the left or right of the actual terminal(s).") + description: localize('terminal.integrated.tabs.location', "Controls the location of the terminal tabs. Tabs on the left or right form a vertical list; tabs on the top or bottom form a horizontally scrolling row.") }, [TerminalSettingId.DefaultLocation]: { type: 'string', diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts new file mode 100644 index 00000000000000..0b0d2af22af24d --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts @@ -0,0 +1,1084 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../browser/media/terminal.css'; +import { deepStrictEqual, ok, rejects, strictEqual } from 'assert'; +import { restore, spy, stub } from 'sinon'; +import { $, addDisposableListener, getActiveElement, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; +import { ElementsDragAndDropData, NativeDragAndDropData } from '../../../../../base/browser/ui/list/listView.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { DataTransfers } from '../../../../../base/browser/dnd.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { isMacintosh, OS, OperatingSystem } from '../../../../../base/common/platform.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IShellLaunchConfig, ITerminalBackend, TitleEventSource } from '../../../../../platform/terminal/common/terminal.js'; +import { IProcessDetails } from '../../../../../platform/terminal/common/terminalProcess.js'; +import { IEditableData } from '../../../../common/views.js'; +import { TestTerminalGroupService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ICreateTerminalOptions, ITerminalChatService, ITerminalEditingService, ITerminalGroup, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState, TerminalDataTransfers } from '../../browser/terminal.js'; +import { TerminalTabbedView } from '../../browser/terminalTabbedView.js'; +import { InstanceContext } from '../../browser/terminalContextMenu.js'; +import { TerminalTabsBar } from '../../browser/terminalTabsBar.js'; +import { TerminalStatusList } from '../../browser/terminalStatusList.js'; +import { TerminalTabList, TerminalTabsDragAndDrop, TerminalTabsRenderer } from '../../browser/terminalTabsList.js'; +import { getSelectedTerminalTabInstances } from '../../browser/terminalTabsWidget.js'; +import { ITerminalConfiguration } from '../../common/terminal.js'; +import { TerminalStorageKeys } from '../../common/terminalStorageKeys.js'; + +class TabInstance extends mock() { + override title = 'shell'; + override description = 'workspace'; + override icon = Codicon.terminal; + override shellLaunchConfig: IShellLaunchConfig = {}; + override readonly resource: URI; + focusCount = 0; + readonly paths: (string | URI)[] = []; + pathError: Error | undefined; + + constructor( + override readonly instanceId: number, + override readonly statusList: TerminalStatusList, + ) { + super(); + this.resource = URI.from({ scheme: Schemas.vscodeTerminal, path: `/test/${instanceId}` }); + } + + override focus(): void { this.focusCount++; } + override async focusWhenReady(): Promise { this.focus(); } + override async sendPath(path: string | URI): Promise { + if (this.pathError) { + throw this.pathError; + } + this.paths.push(path); + } +} + +class TabGroup extends mock() { + size = { width: 0, height: 0 }; + constructor(override terminalInstances: ITerminalInstance[]) { super(); } + override layout(width: number, height: number): void { this.size = { width, height }; } +} + +function processDetails(): IProcessDetails { + return { + id: 99, pid: 100, title: 'external shell', titleSource: TitleEventSource.Process, + cwd: '/', workspaceId: 'external', workspaceName: 'external', isOrphan: true, + icon: undefined, color: undefined, fixedDimensions: undefined, environmentVariableCollections: undefined, + hasChildProcesses: false, shellIntegrationNonce: '' + }; +} + +suite('Terminal tabs', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let tabs: ITerminalConfiguration['tabs']; + let container: HTMLElement; + let instances: TabInstance[]; + let groups: TabGroup[]; + let groupService: TestTerminalGroupService; + let changes: Emitter; + let titles: Emitter; + let statuses: Emitter; + let icons: Emitter<{ instance: ITerminalInstance; userInitiated: boolean }>; + let connectionChanges: Emitter; + let hiddenChanges: Emitter; + let activeChanges: Emitter; + let editable: { instance: ITerminalInstance; data: IEditableData } | undefined; + let editingTerminal: ITerminalInstance | undefined; + let terminalContainer: HTMLElement | undefined; + let hiddenInstances: ITerminalInstance[]; + let backend: ITerminalBackend | undefined; + let moves: { sources: number[]; target?: number }[]; + let created: (ICreateTerminalOptions | undefined)[]; + let disposed: ITerminalInstance[]; + let executedCommands: string[]; + + setup(() => { + tabs = { + enabled: true, + hideCondition: 'never', + showActiveTerminal: 'singleTerminalOrNarrow', + location: 'top', + focusMode: 'doubleClick', + title: '${process}', + description: '${cwdFolder}', + separator: ' - ', + allowAgentCliTitle: false + }; + configurationService = new TestConfigurationService({ terminal: { integrated: { tabs, rightClickBehavior: 'default' } } }); + store.add(configurationService.onDidChangeConfigurationEmitter); + instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, store); + const workbench = $('.monaco-workbench'); + const pane = $('.pane-body.integrated-terminal'); + workbench.appendChild(pane); + mainWindow.document.body.appendChild(workbench); + container = pane; + container.style.width = '600px'; + container.style.height = '300px'; + instances = [1, 2, 3].map(id => new TabInstance(id, store.add(instantiationService.createInstance(TerminalStatusList)))); + groups = [new TabGroup(instances.slice(0, 2)), new TabGroup(instances.slice(2))]; + changes = store.add(new Emitter()); + titles = store.add(new Emitter()); + statuses = store.add(new Emitter()); + icons = store.add(new Emitter<{ instance: ITerminalInstance; userInitiated: boolean }>()); + connectionChanges = store.add(new Emitter()); + hiddenChanges = store.add(new Emitter()); + activeChanges = store.add(new Emitter()); + groupService = new class extends TestTerminalGroupService { + override groups = groups; + override instances = instances; + override activeInstance: ITerminalInstance | undefined = instances[0]; + override onDidChangeInstances = changes.event; + override onDidChangeActiveInstance = activeChanges.event; + override getGroupForInstance(instance: ITerminalInstance): ITerminalGroup | undefined { return this.groups.find(group => group.terminalInstances.includes(instance)); } + override instanceIsSplit(instance: ITerminalInstance): boolean { return (this.getGroupForInstance(instance)?.terminalInstances.length ?? 0) > 1; } + override setActiveInstance(instance: ITerminalInstance): void { + this.activeInstance = instance; + activeChanges.fire(instance); + } + override moveGroup(source: ITerminalInstance | ITerminalInstance[], target: ITerminalInstance): void { + moves.push({ sources: (Array.isArray(source) ? source : [source]).map(instance => instance.instanceId), target: target.instanceId }); + } + override moveGroupToEnd(source: ITerminalInstance | ITerminalInstance[]): void { + moves.push({ sources: (Array.isArray(source) ? source : [source]).map(instance => instance.instanceId) }); + } + override focusTabs(): void { } + }; + backend = undefined; + moves = []; + created = []; + disposed = []; + executedCommands = []; + instantiationService.stub(ITerminalGroupService, groupService); + instantiationService.stub(ITerminalService, new class extends mock() { + override onAnyInstanceTitleChange = titles.event; + override onAnyInstanceIconChange = icons.event; + override onAnyInstancePrimaryStatusChange = statuses.event; + override onDidChangeConnectionState = connectionChanges.event; + override onDidChangeInstances = Event.None; + override onDidDisposeInstance = Event.None; + override connectionState = TerminalConnectionState.Connected; + override getPrimaryBackend(): ITerminalBackend | undefined { return backend; } + override setContainers(_parent: HTMLElement, element: HTMLElement): void { terminalContainer = element; } + override setActiveInstance(instance: ITerminalInstance): void { groupService.setActiveInstance(instance); } + override getInstanceFromResource(resource: URI): ITerminalInstance | undefined { return instances.find(instance => instance.resource.toString() === resource.toString()); } + override async moveToTerminalView(): Promise { } + override async createTerminal(options?: ICreateTerminalOptions): Promise { + created.push(options); + const instance = new TabInstance(instances.length + 1, store.add(instantiationService.createInstance(TerminalStatusList))); + instances.push(instance); + groups.push(new TabGroup([instance])); + changes.fire(); + return instance; + } + override async safeDisposeTerminal(instance: ITerminalInstance): Promise { + disposed.push(instance); + hiddenInstances = hiddenInstances.filter(hidden => hidden !== instance); + hiddenChanges.fire(instance); + } + }); + editable = undefined; + editingTerminal = undefined; + instantiationService.stub(ITerminalEditingService, new class extends mock() { + override getEditableData(instance: ITerminalInstance): IEditableData | undefined { return editable?.instance === instance ? editable.data : undefined; } + override isEditable(instance: ITerminalInstance | undefined): boolean { return !!editable && (!instance || editable.instance === instance); } + override getEditingTerminal(): ITerminalInstance | undefined { return editingTerminal; } + override setEditingTerminal(instance: ITerminalInstance | undefined): void { editingTerminal = instance; } + }); + hiddenInstances = []; + instantiationService.stub(ITerminalChatService, new class extends mock() { + override onDidRegisterTerminalInstanceWithToolSession = hiddenChanges.event; + override getToolSessionTerminalInstances(): ITerminalInstance[] { return hiddenInstances; } + }); + instantiationService.stub(ICommandService, new class extends mock() { + override async executeCommand(id: string): Promise { + executedCommands.push(id); + return undefined; + } + }); + }); + + teardown(() => { + restore(); + container.parentElement?.remove(); + }); + + function createBar(os = OS): TerminalTabsBar { + const tabsContainer = $('.tabs-container.horizontal-tabs.has-text'); + const listContainer = $('.tabs-list-container'); + const list = $('.tabs-list'); + container.appendChild(tabsContainer); + tabsContainer.appendChild(listContainer); + listContainer.appendChild(list); + const bar = store.add(instantiationService.createInstance(TerminalTabsBar, list, os)); + bar.layout(TerminalTabsBar.HEIGHT, 300); + return bar; + } + + function changeConfiguration(values: Partial): void { + Object.assign(tabs, values); + const keys = Object.keys(values).map(key => `terminal.integrated.tabs.${key}`); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: key => key === 'terminal.integrated' || keys.includes(key), + affectedKeys: new Set(keys), + change: { keys, overrides: [] }, + source: ConfigurationTarget.USER + }); + } + + function keyboardEvent(type: 'keydown' | 'keyup', key: string, options: KeyboardEventInit = {}): KeyboardEvent { + const keyCodes: Record = { a: 65, A: 65, ArrowLeft: 37, ArrowRight: 39, Home: 36, End: 35, Enter: 13, ' ': 32, F2: 113, F10: 121, ContextMenu: 93 }; + return new KeyboardEvent(type, { key, keyCode: keyCodes[key], code: key === 'ContextMenu' ? 'ContextMenu' : '', bubbles: true, cancelable: true, ...options }); + } + + function press(bar: TerminalTabsBar, key: string, options: KeyboardEventInit = {}): void { + const element = bar.getHTMLElement().querySelector(`[data-index="${bar.getFocus()[0]}"]`)!; + element.dispatchEvent(keyboardEvent('keydown', key, options)); + element.dispatchEvent(keyboardEvent('keyup', key, options)); + } + + test('renders individual split terminals with accessible names and group boundaries', () => { + const bar = createBar(); + deepStrictEqual(Array.from(bar.getHTMLElement().children).map(element => ({ + role: element.getAttribute('role'), + label: element.getAttribute('aria-label'), + selected: element.getAttribute('aria-selected'), + groupStart: element.classList.contains('group-start') + })), [ + { role: 'tab', label: 'Terminal 1 shell, split 1 of 2', selected: 'true', groupStart: false }, + { role: 'tab', label: 'Terminal 2 shell, split 2 of 2', selected: 'false', groupStart: false }, + { role: 'tab', label: 'Terminal 3 shell', selected: 'false', groupStart: true } + ]); + }); + + test('exposes the current terminal independently of multi-selection and focused tabs', () => { + const bar = createBar(); + const states = () => Array.from(bar.getHTMLElement().children).map(element => ({ + selected: element.getAttribute('aria-selected'), + current: element.getAttribute('aria-current') + })); + bar.setSelection([0, 1]); + bar.setFocus([2]); + const multiSelected = states(); + groupService.setActiveInstance(instances[1]); + const activated = states(); + groupService.activeInstance = undefined; + activeChanges.fire(undefined); + deepStrictEqual({ multiSelected, activated, inactive: states() }, { + multiSelected: [ + { selected: 'true', current: 'true' }, + { selected: 'true', current: 'false' }, + { selected: 'false', current: 'false' } + ], + activated: [ + { selected: 'false', current: 'false' }, + { selected: 'true', current: 'true' }, + { selected: 'false', current: 'false' } + ], + inactive: [ + { selected: 'false', current: 'false' }, + { selected: 'true', current: 'false' }, + { selected: 'false', current: 'false' } + ] + }); + }); + + for (const location of ['left', 'right', 'top', 'bottom'] as const) { + test(`${location}: context menus target unselected terminals alone and preserve selected groups`, () => { + tabs.location = location; + const contexts: number[][] = []; + stub(instantiationService.get(IContextMenuService), 'showContextMenu').callsFake(delegate => { + try { + const context = delegate.getActionsContext?.(); + ok(Array.isArray(context) && context.every(instance => instance instanceof InstanceContext)); + contexts.push(context.map(instance => instance.instanceId)); + } finally { + delegate.onHide?.(false); + } + }); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const entries = () => container.querySelectorAll('.terminal-tabs-bar-tab, .monaco-list-row'); + entries()[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + entries()[2].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })); + entries()[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + entries()[1].dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + entries()[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })); + entries()[2].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })); + deepStrictEqual(contexts, [[3], [2, 1], [3]]); + }); + + test(`${location}: Focus Terminal Tabs returns to the selection instead of an old focused row`, () => { + tabs.location = location; + stub(instantiationService.get(IContextMenuService), 'showContextMenu').callsFake(delegate => delegate.onHide?.(false)); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const rows = container.querySelectorAll('.terminal-tabs-bar-tab, .monaco-list-row'); + rows[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + view.focusTabs(); + rows[2].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + view.focusTabs(); + const focusedIndex = location === 'left' || location === 'right' + ? container.querySelector('.monaco-list-row.focused')?.dataset.index + : (getActiveElement() as HTMLElement).dataset.index; + deepStrictEqual({ + focusedIndex, + selected: Array.from(container.querySelectorAll('.terminal-tabs-bar-tab.selected, .monaco-list-row.selected')).map(row => row.dataset.index) + }, { focusedIndex: '0', selected: ['0'] }); + }); + } + + test('layout changes preserve the focused tab separately from explicit tab focusing', () => { + tabs.location = 'right'; + stub(instantiationService.get(IContextMenuService), 'showContextMenu').callsFake(delegate => delegate.onHide?.(false)); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + container.querySelector('.monaco-list-row[data-index="0"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + view.focusTabs(); + container.querySelector('.monaco-list-row[data-index="2"]')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + changeConfiguration({ location: 'top' }); + const afterLayout = (getActiveElement() as HTMLElement).dataset.index; + view.focusTabs(); + deepStrictEqual({ afterLayout, afterCommand: (getActiveElement() as HTMLElement).dataset.index }, { afterLayout: '2', afterCommand: '0' }); + }); + + test('Focus Terminal Tabs uses the active terminal when there is no selection', () => { + stub(instantiationService.get(IContextMenuService), 'showContextMenu').callsFake(delegate => delegate.onHide?.(false)); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + groupService.setActiveInstance(instances[1]); + container.querySelector('.terminal-tabs-bar')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + view.focusTabs(); + deepStrictEqual({ + focusedIndex: (getActiveElement() as HTMLElement).dataset.index, + selectedCount: container.querySelectorAll('.terminal-tabs-bar-tab.selected').length + }, { focusedIndex: '1', selectedCount: 0 }); + }); + + test('lays out once for instance changes and avoids unchanged mouse and hidden-terminal updates', () => { + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const layout = spy(view, 'layout'); + changes.fire(); + const instanceChange = layout.callCount; + layout.resetHistory(); + container.querySelector('.tabs-container')!.dispatchEvent(new MouseEvent('mouseleave')); + const mouseLeave = layout.callCount; + layout.resetHistory(); + hiddenInstances = [instances[0]]; + hiddenChanges.fire(instances[0]); + const hiddenAdded = layout.callCount; + layout.resetHistory(); + hiddenChanges.fire(instances[0]); + const unchangedHidden = layout.callCount; + layout.resetHistory(); + changeConfiguration({ location: 'bottom' }); + deepStrictEqual({ instanceChange, mouseLeave, hiddenAdded, unchangedHidden, locationChange: layout.callCount }, + { instanceChange: 1, mouseLeave: 0, hiddenAdded: 1, unchangedHidden: 0, locationChange: 1 }); + }); + + test('new groups receive current dimensions even when the tabs remain visible', async () => { + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + await instantiationService.get(ITerminalService).createTerminal(); + deepStrictEqual(groups.map(group => group.size), [ + { width: 600, height: 272 }, { width: 600, height: 272 }, { width: 600, height: 272 } + ]); + }); + + test('vertical hidden-terminal entries reduce the list height, not terminal content height', () => { + tabs.location = 'right'; + hiddenInstances = [instances[0]]; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + deepStrictEqual({ + content: groups[0].size, + listHeight: container.querySelector('.monaco-list')!.clientHeight, + entryHeight: container.querySelector('.terminal-tabs-chat-entry')!.clientHeight + }, { content: { width: 480, height: 300 }, listHeight: 278, entryHeight: 22 }); + }); + + test('adds late task actions without losing focused Kill or rebuilding unchanged controls', async () => { + const fill = spy(TerminalTabsRenderer.prototype, 'fillActionBar'); + const bar = createBar(); + bar.domFocus(); + const firstTab = bar.getHTMLElement().children[0]; + const kill = firstTab.querySelector('.action-label.codicon-trashcan')!; + kill.focus(); + instances[0].shellLaunchConfig.tabActions = [{ id: 'test.rerunTask', label: 'Rerun Task', icon: Codicon.refresh }]; + titles.fire(instances[0]); + const template = fill.lastCall.args[1]; + deepStrictEqual({ + labels: Array.from(firstTab.querySelectorAll('.action-label')).map(element => element.getAttribute('aria-label')), + focusedKill: getActiveElement() === firstTab.querySelector('.action-label.codicon-trashcan') + }, { labels: ['Split', 'Rerun Task', 'Kill'], focusedKill: true }); + await template.actionBar.getAction(1)!.run(); + deepStrictEqual(executedCommands, ['test.rerunTask']); + const controls = Array.from(firstTab.querySelectorAll('.action-label')); + fill.resetHistory(); + statuses.fire(instances[0]); + titles.fire(instances[0]); + bar.refresh(); + deepStrictEqual({ retained: controls.every(control => control.isConnected), rebuilds: fill.callCount }, { retained: true, rebuilds: 0 }); + }); + + test('reconciles changed, replaced and removed task action definitions on refresh', () => { + const rerun = { id: 'test.rerunTask', label: 'Rerun Task', icon: Codicon.refresh }; + instances[0].shellLaunchConfig.tabActions = [rerun]; + const bar = createBar(); + bar.domFocus(); + const firstTab = bar.getHTMLElement().children[0]; + firstTab.querySelector('.action-label.codicon-refresh')!.focus(); + rerun.label = 'Restart Task'; + rerun.icon = Codicon.debugRestart; + bar.refresh(); + const labels = () => Array.from(firstTab.querySelectorAll('.action-label')).map(element => element.getAttribute('aria-label')); + const changed = { labels: labels(), focused: getActiveElement()?.getAttribute('aria-label'), icon: firstTab.querySelector('.codicon-debug-restart') !== null }; + const changedControls = Array.from(firstTab.querySelectorAll('.action-label')); + instances[0].shellLaunchConfig.tabActions = [{ ...rerun }]; + bar.refresh(); + const retained = changedControls.every(control => control.isConnected); + instances[0].shellLaunchConfig.tabActions = []; + bar.refresh(); + deepStrictEqual({ + changed, retained, removed: labels(), focusRemainsInTab: firstTab.contains(getActiveElement()) + }, { + changed: { labels: ['Split', 'Restart Task', 'Kill'], focused: 'Restart Task', icon: true }, + retained: true, removed: ['Split', 'Kill'], focusRemainsInTab: true + }); + }); + + test('navigates horizontally, selects ranges, and targets focused unselected tabs', () => { + const bar = createBar(); + bar.domFocus(); + press(bar, 'ArrowRight'); + press(bar, 'End', { shiftKey: true }); + const range = bar.getSelectedElements().map(instance => instance.instanceId); + press(bar, 'Home', isMacintosh ? { metaKey: true } : { ctrlKey: true }); + deepStrictEqual({ + range, + focused: bar.getFocus(), + targets: getSelectedTerminalTabInstances(bar).map(instance => instance.instanceId), + active: groupService.activeInstance?.instanceId, + terminalFocusCalls: instances.map(instance => instance.focusCount) + }, { range: [2, 3], focused: [0], targets: [1], active: 2, terminalFocusCalls: [0, 0, 0] }); + }); + + test('does not intercept the macOS Enter rename binding', () => { + const bar = createBar(); + const event = keyboardEvent('keydown', 'Enter'); + bar.getHTMLElement().children[0].dispatchEvent(event); + deepStrictEqual({ prevented: event.defaultPrevented, focused: instances[0].focusCount }, { prevented: !isMacintosh, focused: isMacintosh ? 0 : 1 }); + }); + + test('preserves identity selection and DOM across title changes and reordering', () => { + const bar = createBar(); + bar.setSelection([0, 1]); + bar.setFocus([1]); + const element = bar.getHTMLElement().children[1]; + instances[1].title = 'renamed'; + titles.fire(instances[1]); + groupService.instances = [instances[2], instances[0], instances[1]]; + changes.fire(); + deepStrictEqual({ + sameElement: bar.getHTMLElement().children[2] === element, + selection: bar.getSelection(), + focus: bar.getFocus(), + targets: getSelectedTerminalTabInstances(bar).map(instance => instance.instanceId) + }, { sameElement: true, selection: [1, 2], focus: [2], targets: [1, 2] }); + }); + + test('keeps unfinished rename input through title and status refreshes', () => { + const bar = createBar(); + editable = { instance: instances[0], data: { validationMessage: () => null, onFinish: () => { throw new Error('Rename should not finish during refresh'); } } }; + editingTerminal = instances[0]; + bar.refresh(); + const input = bar.getHTMLElement().querySelector('input')!; + input.value = 'unfinished'; + titles.fire(instances[0]); + bar.refresh(); + deepStrictEqual({ + sameInput: input === bar.getHTMLElement().querySelector('input'), + value: input.value, + focused: getActiveElement() === input + }, { sameInput: true, value: 'unfinished', focused: true }); + }); + + test('preserves keyboard focus on tab actions when a terminal title changes', () => { + const bar = createBar(); + bar.domFocus(); + const action = bar.getHTMLElement().querySelector('.action-label.codicon-trashcan')!; + action.focus(); + instances[0].title = 'updated title'; + titles.fire(instances[0]); + deepStrictEqual({ + connected: action.isConnected, + focused: getActiveElement() === action, + title: bar.getHTMLElement().querySelector('.label-name')?.textContent?.trim() + }, { connected: true, focused: true, title: 'updated title' }); + }); + + test('preserves focused controls through status, icon, theme-style, and reconnect refreshes', () => { + const bar = createBar(); + bar.domFocus(); + const action = bar.getHTMLElement().querySelector('.action-label.codicon-split-horizontal')!; + action.focus(); + statuses.fire(instances[0]); + icons.fire({ instance: instances[0], userInitiated: true }); + bar.refresh(); + connectionChanges.fire(); + deepStrictEqual({ + sameControl: action === bar.getHTMLElement().querySelector('.action-label.codicon-split-horizontal'), + focused: getActiveElement() === action, + selection: bar.getSelection() + }, { sameControl: true, focused: true, selection: [0] }); + }); + + test('bounds update work to the changed terminal with 250 open tabs and 1000 title/status events', async () => { + instances = Array.from({ length: 250 }, (_, index) => new TabInstance(index + 1, store.add(instantiationService.createInstance(TerminalStatusList)))); + groups = instances.map(instance => new TabGroup([instance])); + groupService.instances = instances; + groupService.groups = groups; + groupService.activeInstance = instances[0]; + const bar = createBar(); + const render = spy(TerminalTabsRenderer.prototype, 'updateElement'); + const templates = spy(TerminalTabsRenderer.prototype, 'renderTemplate'); + const actions = spy(TerminalTabsRenderer.prototype, 'fillActionBar'); + const scans = spy(DomScrollableElement.prototype, 'scanDomNode'); + const controls = Array.from(bar.getHTMLElement().querySelectorAll('.action-label')); + for (let i = 0; i < 500; i++) { + instances[125].title = `task ${i}`; + titles.fire(instances[125]); + statuses.fire(instances[125]); + } + const synchronousScans = scans.callCount; + await new Promise(resolve => store.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + deepStrictEqual({ + renders: render.callCount, + targets: [...new Set(render.args.map(([instance]) => instance.instanceId))], + newTemplates: templates.callCount, + rebuiltActions: actions.callCount, + synchronousScans, + scansAfterFrame: scans.callCount, + retainedControls: controls.every(control => control.isConnected), + tabs: bar.getHTMLElement().children.length + }, { renders: 1000, targets: [126], newTemplates: 0, rebuiltActions: 0, synchronousScans: 0, scansAfterFrame: 1, retainedControls: true, tabs: 250 }); + }); + + for (const { name, os } of [ + { name: 'macOS', os: OperatingSystem.Macintosh }, + { name: 'Windows', os: OperatingSystem.Windows }, + { name: 'Linux', os: OperatingSystem.Linux } + ]) { + test(`${name}: platform selection modifiers, range navigation and rename keys`, () => { + const bar = createBar(os); + const modifier = os === OperatingSystem.Macintosh ? { metaKey: true } : { ctrlKey: true }; + bar.domFocus(); + press(bar, 'a', modifier); + const all = bar.getSelection(); + press(bar, 'ArrowRight', modifier); + press(bar, ' ', modifier); + const toggled = bar.getSelection(); + press(bar, 'Home'); + press(bar, 'End', { shiftKey: true }); + const rename = keyboardEvent('keydown', os === OperatingSystem.Macintosh ? 'Enter' : 'F2'); + getActiveElement()!.dispatchEvent(rename); + deepStrictEqual({ all, toggled, range: bar.getSelection(), focus: bar.getFocus(), renameHandled: rename.defaultPrevented }, + { all: [0, 1, 2], toggled: [0, 2], range: [0, 1, 2], focus: [2], renameHandled: false }); + }); + + test(`${name}: mouse multi-selection and keyboard context menu`, () => { + const bar = createBar(os); + const second = bar.getHTMLElement().children[1]; + second.dispatchEvent(new MouseEvent('click', { bubbles: true, ...os === OperatingSystem.Macintosh ? { metaKey: true } : { ctrlKey: true } })); + let contextMenus = 0; + store.add(addDisposableListener(bar.getHTMLElement(), 'contextmenu', () => contextMenus++)); + press(bar, 'F10', { shiftKey: true }); + deepStrictEqual({ selection: bar.getSelection(), focus: bar.getFocus(), contextMenus }, { selection: [0, 1], focus: [1], contextMenus: 1 }); + }); + + test(`${name}: Enter activation and Space focus follow platform conventions`, () => { + const bar = createBar(os); + bar.domFocus(); + const enter = keyboardEvent('keydown', 'Enter'); + getActiveElement()!.dispatchEvent(enter); + const enterFocusCount = instances[0].focusCount; + press(bar, ' '); + deepStrictEqual({ enterHandled: enter.defaultPrevented, enterFocusCount, totalFocusCount: instances[0].focusCount }, + { enterHandled: os !== OperatingSystem.Macintosh, enterFocusCount: os === OperatingSystem.Macintosh ? 0 : 1, totalFocusCount: os === OperatingSystem.Macintosh ? 1 : 2 }); + }); + + test(`${name}: Select All handles Caps Lock and non-Latin keys without intercepting other chords`, () => { + const bar = createBar(os); + const modifier = os === OperatingSystem.Macintosh ? { metaKey: true } : { ctrlKey: true }; + bar.domFocus(); + const selections: number[][] = []; + for (const key of ['A', '\u0444']) { + bar.setSelection([0]); + press(bar, key, { ...modifier, keyCode: 65, code: 'KeyA' }); + selections.push(bar.getSelection()); + } + bar.setSelection([0]); + press(bar, 'A', { ...modifier, shiftKey: true }); + selections.push(bar.getSelection()); + press(bar, 'a', { ...modifier, isComposing: true }); + selections.push(bar.getSelection()); + deepStrictEqual(selections, [[0, 1, 2], [0, 1, 2], [0], [0]]); + }); + + test(`${name}: honors Alt multi-selection without splitting terminals`, async () => { + await configurationService.setUserConfiguration('workbench.list.multiSelectModifier', 'alt'); + tabs.focusMode = 'singleClick'; + const bar = createBar(os); + bar.domFocus(); + bar.getHTMLElement().children[1].dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + deepStrictEqual({ selection: bar.getSelection(), created: created.length, focused: instances.map(instance => instance.focusCount) }, + { selection: [0, 1], created: 0, focused: [0, 0, 0] }); + await configurationService.setUserConfiguration('workbench.list.multiSelectModifier', 'ctrlCmd'); + const create = spy(instantiationService.get(ITerminalService), 'createTerminal'); + bar.getHTMLElement().children[1].dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + await create.firstCall.returnValue; + deepStrictEqual(created, [{ location: { parentTerminal: instances[1] } }]); + }); + } + + for (const key of ['ContextMenu', 'F10']) { + test(`${key}: opens on keyup and suppresses native context menus during the key press`, () => { + const bar = createBar(); + bar.domFocus(); + const tab = bar.getHTMLElement().children[0]; + let menus = 0; + store.add(addDisposableListener(bar.getHTMLElement(), 'contextmenu', () => menus++)); + const down = keyboardEvent('keydown', key, { shiftKey: key === 'F10' }); + tab.dispatchEvent(down); + const afterDown = menus; + const nativeMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + tab.dispatchEvent(nativeMenu); + const afterNative = menus; + const up = keyboardEvent('keyup', key, { shiftKey: key === 'F10' }); + tab.dispatchEvent(up); + const afterUp = menus; + tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + deepStrictEqual({ + menus: [afterDown, afterNative, afterUp, menus], + prevented: [down.defaultPrevented, nativeMenu.defaultPrevented, up.defaultPrevented] + }, { menus: [0, 0, 1, 2], prevented: [true, true, true] }); + }); + } + + test('vertical Alt multi-selection does not also split or focus terminal content', async () => { + await configurationService.setUserConfiguration('workbench.list.multiSelectModifier', 'alt'); + tabs.location = 'right'; + tabs.focusMode = 'singleClick'; + const list = store.add(instantiationService.createInstance(TerminalTabList, container)); + list.layout(300, 120); + list.setSelection([0]); + list.domFocus(); + list.getHTMLElement().querySelector('.monaco-list-row[data-index="1"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + deepStrictEqual({ selection: list.getSelection(), created: created.length, focused: instances.map(instance => instance.focusCount) }, + { selection: [0, 1], created: 0, focused: [0, 0, 0] }); + await configurationService.setUserConfiguration('workbench.list.multiSelectModifier', 'ctrlCmd'); + const key = 'workbench.list.multiSelectModifier'; + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: configuration => configuration === key, + affectedKeys: new Set([key]), + change: { keys: [key], overrides: [] }, + source: ConfigurationTarget.USER + }); + const create = spy(instantiationService.get(ITerminalService), 'createTerminal'); + list.getHTMLElement().querySelector('.monaco-list-row[data-index="1"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + await create.firstCall.returnValue; + deepStrictEqual(created, [{ location: { parentTerminal: instances[1] } }]); + }); + + for (const surface of ['horizontal', 'vertical', 'empty-area'] as const) { + test(`${surface}: a failed drop reports one visible error and a failure outcome`, async () => { + tabs.location = surface === 'vertical' ? 'right' : 'top'; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const notifications = spy(instantiationService.get(INotificationService), 'error'); + const logged = spy(instantiationService.get(ILogService), 'error'); + const drop = spy(TerminalTabsDragAndDrop.prototype, 'drop'); + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([URI.from({ scheme: Schemas.vscodeTerminal, path: '/external/99' }).toString()])); + const target = container.querySelector(surface === 'vertical' ? '.monaco-list-row' : surface === 'empty-area' ? '.tabs-container' : '.terminal-tabs-bar-tab')!; + target.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + strictEqual(drop.callCount, 1); + const result = await drop.firstCall.returnValue; + deepStrictEqual({ + result, notifications: notifications.callCount, logged: logged.callCount, + message: notifications.firstCall.args[0] instanceof Error ? notifications.firstCall.args[0].message : notifications.firstCall.args[0] + }, { result: false, notifications: 1, logged: 1, message: 'Cannot move the terminal because its terminal connection is unavailable.' }); + }); + } + + for (const completedTransfers of [1, 2]) { + test(`failed drop reports ${completedTransfers} completed transfers without reporting batch success`, async () => { + backend = new class extends mock() { + override async requestDetachInstance(_workspaceId: string, instanceId: number): Promise { + return instanceId < 99 + completedTransfers ? { ...processDetails(), id: instanceId } : undefined; + } + }; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, () => { throw new Error('Failed batch must not select a successful drop'); })); + const notifications = spy(instantiationService.get(INotificationService), 'error'); + const logged = spy(instantiationService.get(ILogService), 'error'); + const dataTransfer = new DataTransfer(); + const resources = Array.from({ length: completedTransfers + 1 }, (_, index) => URI.from({ scheme: Schemas.vscodeTerminal, path: `/external/${99 + index}` }).toString()); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify(resources)); + const result = await dnd.drop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })); + const error = notifications.firstCall.args[0]; + ok(error instanceof Error && error.cause instanceof Error); + const partialMessage = completedTransfers === 1 + ? 'The terminal drop failed after moving one terminal. It remains available in this window.' + : 'The terminal drop failed after moving 2 terminals. They remain available in this window.'; + deepStrictEqual({ + result, notifications: notifications.callCount, logged: logged.callCount, + message: error.message, + cause: error.cause.message, + transferred: created.length, + visible: groupService.instances.length, + moves + }, { + result: false, notifications: 1, logged: 1, + message: `${partialMessage} Cannot move the terminal because it could not be detached from its original window.`, + cause: 'Cannot move the terminal because it could not be detached from its original window.', + transferred: completedTransfers, visible: 3 + completedTransfers, moves: [] + }); + }); + } + + test('scrolls a single row and reveals keyboard navigation targets', () => { + const bar = createBar(); + bar.layout(TerminalTabsBar.HEIGHT, 120); + bar.domFocus(); + press(bar, 'End'); + const element = bar.getHTMLElement(); + const entries = Array.from(element.children) as HTMLElement[]; + ok(element.scrollWidth > element.clientWidth && element.scrollLeft > 0); + strictEqual(new Set(entries.map(entry => entry.offsetTop)).size, 1); + }); + + test('background label updates do not reveal the active tab over a manually scrolled position', async () => { + const bar = createBar(); + bar.layout(TerminalTabsBar.HEIGHT, 120); + const element = bar.getHTMLElement(); + element.scrollLeft = 100; + const position = element.scrollLeft; + instances[0].title = 'task'; + titles.fire(instances[0]); + await new Promise(resolve => store.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + deepStrictEqual({ before: position, after: element.scrollLeft, focused: bar.getFocus() }, { before: 100, after: 100, focused: [0] }); + }); + + test('restores an entry focus when a context menu returns focus to the empty strip', () => { + const bar = createBar(); + bar.setSelection([]); + bar.setFocus([]); + bar.getHTMLElement().focus(); + press(bar, 'ArrowRight'); + deepStrictEqual({ focus: bar.getFocus(), selection: bar.getSelection() }, { focus: [1], selection: [1] }); + }); + + test('shared drops return terminal instances rather than group indexes to the widget', async () => { + let selected: ITerminalInstance[] = []; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, instances => selected = instances)); + await dnd.drop(new ElementsDragAndDropData([instances[1]]), instances[2], 2, undefined, new DragEvent('drop')); + deepStrictEqual({ moves, selected: selected.map(instance => instance.instanceId) }, { moves: [{ sources: [2], target: 3 }], selected: [2] }); + }); + + test('native terminal drops on empty strip space move the group to the end', async () => { + let selected: ITerminalInstance[] = []; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, instances => selected = instances)); + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([instances[1].resource.toString()])); + await dnd.drop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })); + deepStrictEqual({ moves, selected: selected.map(instance => instance.instanceId) }, { moves: [{ sources: [2] }], selected: [2] }); + }); + + for (const source of ['local', 'cross-window'] as const) { + test(`vertical ${source} drops align selection, focus and command targets`, async () => { + tabs.location = 'right'; + backend = new class extends mock() { + override async requestDetachInstance(): Promise { return processDetails(); } + }; + const list = store.add(instantiationService.createInstance(TerminalTabList, container)); + list.layout(300, 120); + list.setSelection([0]); + list.setFocus([0]); + list.domFocus(); + const drop = spy(TerminalTabsDragAndDrop.prototype, 'drop'); + const dataTransfer = new DataTransfer(); + const resources = source === 'local' + ? [instances[1].resource, instances[2].resource] + : [URI.from({ scheme: Schemas.vscodeTerminal, path: '/external/99' })]; + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify(resources.map(resource => resource.toString()))); + const target = list.getHTMLElement().querySelector('.monaco-list-row[data-index="0"]')!; + target.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + strictEqual(drop.callCount, 1); + await drop.firstCall.returnValue; + const expected = source === 'local' ? [2, 3] : [4]; + deepStrictEqual({ + selected: list.getSelectedElements().map(instance => instance.instanceId), + focused: list.getFocusedElements().map(instance => instance.instanceId), + targets: getSelectedTerminalTabInstances(list).map(instance => instance.instanceId), + active: groupService.activeInstance?.instanceId + }, { selected: expected, focused: expected.slice(0, 1), targets: expected, active: expected[0] }); + }); + } + + test('propagates cross-window detach failures', async () => { + backend = new class extends mock() { + override async requestDetachInstance(): Promise { throw new Error('detach failed'); } + }; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, () => { throw new Error('Failed drop must not change selection'); })); + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([URI.from({ scheme: Schemas.vscodeTerminal, path: '/external/99' }).toString()])); + await rejects(dnd.performDrop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })), /detach failed/); + }); + + test('uses a reconnected backend for successful cross-window attachment and preserves the drop target', async () => { + let selected: ITerminalInstance[] = []; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, instances => selected = instances)); + const process = processDetails(); + const detached: { workspaceId: string; instanceId: number }[] = []; + backend = new class extends mock() { + override async requestDetachInstance(workspaceId: string, instanceId: number): Promise { + detached.push({ workspaceId, instanceId }); + return process; + } + }; + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([URI.from({ scheme: Schemas.vscodeTerminal, path: '/external/99' }).toString()])); + await dnd.drop(new NativeDragAndDropData(), instances[2], 2, undefined, new DragEvent('drop', { dataTransfer })); + deepStrictEqual({ + detached, created, moves, + selected: selected.map(instance => instance.instanceId), + active: groupService.activeInstance?.instanceId + }, { + detached: [{ workspaceId: 'external', instanceId: 99 }], + created: [{ config: { attachPersistentProcess: process } }], + moves: [{ sources: [4], target: 3 }], + selected: [4], active: 4 + }); + }); + + test('reports unavailable cross-window connections instead of silently discarding the drop', async () => { + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, () => { throw new Error('Failed drop must not change selection'); })); + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([URI.from({ scheme: Schemas.vscodeTerminal, path: '/external/99' }).toString()])); + const event = new DragEvent('drop', { dataTransfer }); + await rejects(dnd.performDrop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /connection is unavailable/); + backend = new class extends mock() { + override async requestDetachInstance(): Promise { return undefined; } + }; + await rejects(dnd.performDrop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /could not be detached/); + deepStrictEqual({ created, moves }, { created: [], moves: [] }); + }); + + test('reattaches each detached process before a subsequent transfer failure', async () => { + const process = processDetails(); + backend = new class extends mock() { + override async requestDetachInstance(_workspaceId: string, instanceId: number): Promise { + return instanceId === 99 ? process : undefined; + } + }; + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, () => { throw new Error('Failed batch must not report successful selection'); })); + const dataTransfer = new DataTransfer(); + dataTransfer.setData(TerminalDataTransfers.Terminals, JSON.stringify([99, 100].map(id => URI.from({ scheme: Schemas.vscodeTerminal, path: `/external/${id}` }).toString()))); + await rejects(dnd.performDrop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })), /failed after moving one terminal.*could not be detached/); + deepStrictEqual({ + created, moves, + visibleInstances: groupService.instances.map(instance => instance.instanceId) + }, { + created: [{ config: { attachPersistentProcess: process } }], moves: [], + visibleInstances: [1, 2, 3, 4] + }); + }); + + test('file drops target the selected terminal and propagate write failures', async () => { + const dnd = store.add(instantiationService.createInstance(TerminalTabsDragAndDrop, () => { throw new Error('File drops do not change tab selection'); })); + const dataTransfer = new DataTransfer(); + const resource = URI.file('/workspace/file.txt'); + dataTransfer.setData(DataTransfers.RESOURCES, JSON.stringify([resource.toString()])); + await dnd.drop(new NativeDragAndDropData(), instances[1], 1, undefined, new DragEvent('drop', { dataTransfer })); + deepStrictEqual({ paths: instances[1].paths.map(path => path.toString()), active: groupService.activeInstance?.instanceId, moves }, { paths: [resource.toString()], active: 2, moves: [] }); + instances[1].pathError = new Error('terminal disconnected'); + await rejects(dnd.performDrop(new NativeDragAndDropData(), instances[1], 1, undefined, new DragEvent('drop', { dataTransfer })), /terminal disconnected/); + }); + + for (const location of ['top', 'bottom'] as const) { + test(`${location}: gives terminals full width and reserves exactly one row`, () => { + tabs.location = location; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + deepStrictEqual(groups.map(group => group.size), [{ width: 600, height: 272 }, { width: 600, height: 272 }]); + }); + } + + test('switches all locations without replacing the terminal container or saved side widths', () => { + const storage = instantiationService.get(IStorageService); + storage.store(TerminalStorageKeys.TabsListWidthHorizontal, 150, StorageScope.PROFILE, StorageTarget.USER); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const original = terminalContainer; + const sizes: { width: number; height: number }[] = []; + for (const location of ['left', 'top', 'right', 'bottom', 'left'] as const) { + changeConfiguration({ location }); + sizes.push(groups[0].size); + strictEqual(terminalContainer, original); + } + deepStrictEqual({ + sizes, + storedWidth: storage.getNumber(TerminalStorageKeys.TabsListWidthHorizontal, StorageScope.PROFILE) + }, { + sizes: [{ width: 450, height: 300 }, { width: 600, height: 272 }, { width: 450, height: 300 }, { width: 600, height: 272 }, { width: 450, height: 300 }], + storedWidth: 150 + }); + }); + + test('handles simultaneous configuration changes and a location change while hidden', () => { + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + changeConfiguration({ enabled: false, location: 'bottom' }); + const disabled = groups[0].size; + changeConfiguration({ location: 'right' }); + changeConfiguration({ enabled: true, location: 'top' }); + deepStrictEqual({ disabled, enabled: groups[0].size }, { disabled: { width: 600, height: 300 }, enabled: { width: 600, height: 272 } }); + }); + + for (const location of ['left', 'right', 'top', 'bottom'] as const) { + test(`${location}: restores terminal focus when a location change also disables tabs`, () => { + tabs.location = location; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const terminalInput = $('textarea'); + terminalContainer!.appendChild(terminalInput); + stub(instances[0], 'focus').callsFake(() => terminalInput.focus()); + view.focusTabs(); + ok(container.querySelector('.tabs-container')!.contains(getActiveElement())); + changeConfiguration({ location: location === 'top' ? 'bottom' : 'top', enabled: false }); + deepStrictEqual({ + tabsRemoved: container.querySelector('.tabs-container') === null, + terminalFocused: getActiveElement() === terminalInput + }, { tabsRemoved: true, terminalFocused: true }); + }); + } + + test('restores terminal focus when a location change also hides the single group', () => { + groupService.groups = [new TabGroup(instances)]; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const terminalInput = $('textarea'); + terminalContainer!.appendChild(terminalInput); + stub(instances[0], 'focus').callsFake(() => terminalInput.focus()); + view.focusTabs(); + changeConfiguration({ location: 'left', hideCondition: 'singleGroup' }); + deepStrictEqual({ + tabsRemoved: container.querySelector('.tabs-container') === null, + terminalFocused: getActiveElement() === terminalInput + }, { tabsRemoved: true, terminalFocused: true }); + }); + + test('combined location and visibility changes do not steal focus from another control', () => { + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + const otherInput = $('input'); + container.parentElement!.appendChild(otherInput); + otherInput.focus(); + changeConfiguration({ location: 'right', enabled: false }); + deepStrictEqual({ + otherControlFocused: getActiveElement() === otherInput, + terminalFocusCalls: instances[0].focusCount + }, { otherControlFocused: true, terminalFocusCalls: 0 }); + }); + + test('updates side-list text visibility from the current allocated width', () => { + tabs.location = 'right'; + const storage = instantiationService.get(IStorageService); + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + const visibility: boolean[] = []; + for (const width of [120, 46, 120]) { + storage.store(TerminalStorageKeys.TabsListWidthHorizontal, width, StorageScope.PROFILE, StorageTarget.USER); + view.layout(600, 300); + visibility.push(container.querySelector('.tabs-container')!.classList.contains('has-text')); + } + deepStrictEqual(visibility, [true, false, true]); + }); + + test('hidden chat terminals do not take a second row from horizontal terminal content', () => { + hiddenInstances = [instances[0]]; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + deepStrictEqual(groups[0].size, { width: 600, height: 272 }); + }); + + test('hidden chat terminals remain keyboard accessible without triggering tab rename', () => { + hiddenInstances = [instances[1], instances[2]]; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + container.style.width = '260px'; + view.layout(260, 300); + const entry = container.querySelector('.terminal-tabs-chat-entry')!; + const bar = container.querySelector('.terminal-tabs-bar')!; + ok(entry.clientWidth <= 260 * 0.4 && bar.clientWidth + entry.clientWidth <= 260); + let bubbled = 0; + store.add(addDisposableListener(container, 'keydown', () => bubbled++)); + entry.focus(); + entry.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + const label = entry.getAttribute('aria-label'); + entry.querySelector('.terminal-tabs-chat-entry-delete')!.click(); + deepStrictEqual({ + label, executedCommands, bubbled, + disposed: disposed.map(instance => instance.instanceId), + hidden: entry.style.display, + content: groups[0].size + }, { + label: 'Show 2 hidden chat terminals', executedCommands: ['workbench.action.terminal.chat.viewHiddenChatTerminals'], + bubbled: 0, disposed: [2, 3], hidden: 'none', content: { width: 260, height: 272 } + }); + }); + + test('restores terminal focus when the last hidden chat entry disappears', () => { + tabs.hideCondition = 'singleTerminal'; + groupService.instances = [instances[0]]; + groupService.groups = [new TabGroup([instances[0]])]; + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(600, 300); + hiddenInstances = [instances[1]]; + hiddenChanges.fire(instances[1]); + const entry = container.querySelector('.terminal-tabs-chat-entry')!; + entry.focus(); + hiddenInstances = []; + hiddenChanges.fire(instances[1]); + deepStrictEqual({ + focusedTerminal: instances[0].focusCount, + tabsRemoved: container.querySelector('.tabs-container') === null, + content: (groupService.groups[0] as TabGroup).size + }, { focusedTerminal: 1, tabsRemoved: true, content: { width: 600, height: 300 } }); + }); + + test('small and zero-height containers never pass negative terminal dimensions', () => { + const view = store.add(instantiationService.createInstance(TerminalTabbedView, container)); + view.layout(100, 10); + const small = groups[0].size; + view.layout(0, 0); + deepStrictEqual({ small, zero: groups[0].size }, { small: { width: 100, height: 0 }, zero: { width: 0, height: 0 } }); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts index a01ec503ca1848..cbdcad5982bb56 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import type { Terminal } from '@xterm/xterm'; +import { getActiveElement, isHTMLElement } from '../../../../../base/browser/dom.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Event } from '../../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { isWindows } from '../../../../../base/common/platform.js'; import { Position } from '../../../../../editor/common/core/position.js'; -import { localize2 } from '../../../../../nls.js'; -import { AccessibleViewProviderId, IAccessibleViewService, NavigationType } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService, NavigationType } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -22,9 +24,10 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ICurrentPartialCommand, isFullTerminalCommand } from '../../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; -import { accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { AccessibilityVerbositySettingId, accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityHelpAction, AccessibleViewAction } from '../../../accessibility/browser/accessibleViewActions.js'; -import { ITerminalContribution, ITerminalInstance, ITerminalService, IXtermTerminal } from '../../../terminal/browser/terminal.js'; +import { ITerminalContribution, ITerminalGroupService, ITerminalInstance, ITerminalService, IXtermTerminal } from '../../../terminal/browser/terminal.js'; +import { TerminalCommandId } from '../../../terminal/common/terminal.js'; import { registerTerminalAction } from '../../../terminal/browser/terminalActions.js'; import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; @@ -264,6 +267,45 @@ export class TerminalAccessibilityHelpContribution extends Disposable { } registerTerminalContribution(TerminalAccessibilityHelpContribution.ID, TerminalAccessibilityHelpContribution); +AccessibleViewRegistry.register({ + priority: 110, + name: 'terminal-tabs', + type: AccessibleViewType.Help, + when: TerminalContextKeys.tabsFocus, + getProvider: accessor => { + const groupService = accessor.get(ITerminalGroupService); + const configurationService = accessor.get(IConfigurationService); + const previousFocus = getActiveElement(); + return new AccessibleContentProvider( + AccessibleViewProviderId.TerminalTabsHelp, + { type: AccessibleViewType.Help, readMoreUrl: 'https://code.visualstudio.com/docs/editor/accessibility#_terminal-accessibility' }, + () => { + const location = configurationService.getValue(TerminalSettingId.TabsLocation); + const horizontal = location === 'top' || location === 'bottom'; + return [ + localize('terminalTabsHelp.overview', "You are in the terminal tabs. Each tab represents a terminal, including individual terminals in a split group."), + localize('terminalTabsHelp.current', "The current tab identifies the active terminal. Multiple tabs can be selected for batch actions."), + horizontal + ? localize('terminalTabsHelp.horizontal', "Use Left and Right Arrow to navigate tabs, or Home and End to move to the first and last tab. Hold Shift to select a range. Press Space to focus the terminal.") + : localize('terminalTabsHelp.vertical', "Use Up and Down Arrow to navigate terminals. Hold Shift to select a range."), + localize('terminalTabsHelp.rename', "Rename the selected terminal. While renaming, press Enter to confirm or Escape to cancel.", TerminalCommandId.RenameActiveTab), + localize('terminalTabsHelp.actions', "Split the selected terminals or kill them. Press Shift+F10 to open the context menu.", TerminalCommandId.SplitActiveTab, TerminalCommandId.KillActiveTab), + localize('terminalTabsHelp.selectionModifier', "Configure the mouse multi-selection modifier with `{0}`. When set to Alt, Alt-click selects terminals instead of splitting them.", 'workbench.list.multiSelectModifier'), + localize('terminalTabsHelp.location', "Use `{0}` to place tabs on the left, right, top, or bottom.", TerminalSettingId.TabsLocation), + ].join('\n'); + }, + () => { + if (isHTMLElement(previousFocus) && previousFocus.isConnected) { + previousFocus.focus(); + } else { + groupService.focusTabs(); + } + }, + AccessibilityVerbositySettingId.Terminal + ); + } +}); + // #endregion // #region Actions diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts index 1a8e72b6dc9945..d27a777bf07227 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts @@ -64,6 +64,7 @@ export class TerminalAccessibilityHelpProvider extends Disposable implements IAc localize('openDetectedLink', 'The Open Detected Link command enables screen readers to easily open links found in the terminal.', TerminalLinksCommandId.OpenDetectedLink), localize('newWithProfile', 'The Create New Terminal (With Profile) command allows for easy terminal creation using a specific profile.', TerminalCommandId.NewWithProfile), localize('focusAfterRun', 'Configure what gets focused after running selected text in the terminal with `{0}`.', TerminalSettingId.FocusAfterRun), + localize('terminalTabs', "Focus terminal tabs to select, rename, split, or close terminals. Configure `{1}` to show a vertical list on the left or right, or a horizontal row on the top or bottom.", TerminalCommandId.FocusTabs, TerminalSettingId.TabsLocation), ]; if (!this._configurationService.getValue(TerminalAccessibilitySettingId.AccessibleViewFocusOnCommandExecution)) { diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/terminalTabsAccessibilityHelp.test.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/terminalTabsAccessibilityHelp.test.ts new file mode 100644 index 00000000000000..992af716af0848 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/terminalTabsAccessibilityHelp.test.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../browser/terminal.accessibility.contribution.js'; +import { deepStrictEqual, ok } from 'assert'; +import { $, getActiveElement } from '../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry } from '../../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; + +suite('Terminal tabs accessibility help', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('has an independent help identity, reuses terminal verbosity, and restores the original focus', () => { + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ 'terminal.integrated.tabs.location': 'top' }) + }, store); + const button = $('button'); + mainWindow.document.body.appendChild(button); + try { + button.focus(); + const implementation = AccessibleViewRegistry.getImplementations().find(candidate => candidate.name === 'terminal-tabs'); + ok(implementation); + const provider = instantiationService.invokeFunction(accessor => implementation.getProvider(accessor)); + ok(provider instanceof AccessibleContentProvider); + store.add(provider); + const content = provider.provideContent(); + button.blur(); + provider.onClose(); + deepStrictEqual({ + id: provider.id, + distinctFromTerminalHelp: provider.id !== AccessibleViewProviderId.TerminalHelp, + type: provider.options.type, + verbosity: provider.verbositySettingKey, + hasReadMore: !!provider.options.readMoreUrl, + explainsCurrent: content.includes('current tab'), + restoredFocus: getActiveElement() === button + }, { + id: 'terminal-tabs-help', distinctFromTerminalHelp: true, + type: AccessibleViewType.Help, verbosity: AccessibilityVerbositySettingId.Terminal, + hasReadMore: true, explainsCurrent: true, restoredFocus: true + }); + } finally { + button.remove(); + } + }); +}); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index e72aeed3424116..f5e0201ebd2e79 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1897,6 +1897,7 @@ export class TestTerminalGroupService implements ITerminalGroupService { showPanel(focus?: boolean): Promise { throw new Error('Method not implemented.'); } hidePanel(): void { throw new Error('Method not implemented.'); } focusTabs(): void { throw new Error('Method not implemented.'); } + getSelectedTabInstances(): ITerminalInstance[] | undefined { throw new Error('Method not implemented.'); } focusHover(): void { throw new Error('Method not implemented.'); } setActiveInstance(instance: ITerminalInstance): void { throw new Error('Method not implemented.'); } focusActiveInstance(): Promise { throw new Error('Method not implemented.'); } diff --git a/test/automation/src/terminal.ts b/test/automation/src/terminal.ts index 70d807a6e4a843..ff14f1a02e9270 100644 --- a/test/automation/src/terminal.ts +++ b/test/automation/src/terminal.ts @@ -25,6 +25,7 @@ export enum Selector { EditorTab = '.terminal-tab', SingleTab = '.single-terminal-tab', Tabs = '.tabs-list .monaco-list-row', + HorizontalTabs = '.terminal-tabs-bar-tab', SplitButton = '.editor .codicon-split-horizontal', XtermSplitIndex0 = '#terminal .terminal-groups-container .split-view-view:nth-child(1) .terminal-wrapper', XtermSplitIndex1 = '#terminal .terminal-groups-container .split-view-view:nth-child(2) .terminal-wrapper', @@ -211,6 +212,12 @@ export class Terminal { } } + async assertHorizontalTabNames(names: (string | undefined)[]): Promise { + await this.code.waitForElements(`${Selector.HorizontalTabs} ${Selector.Name}`, true, entries => + entries.length === names.length && names.every((name, index) => name === undefined || entries[index].textContent.trim() === name) + ); + } + async getTerminalGroups(): Promise { const tabCount = (await this.code.waitForElements(Selector.Tabs, true)).length; const groups: TerminalGroup[] = []; diff --git a/test/smoke/src/areas/terminal/terminal-tabs.test.ts b/test/smoke/src/areas/terminal/terminal-tabs.test.ts index 0a34a0dfe95490..04437c8d206eac 100644 --- a/test/smoke/src/areas/terminal/terminal-tabs.test.ts +++ b/test/smoke/src/areas/terminal/terminal-tabs.test.ts @@ -106,5 +106,36 @@ export function setup(options?: { skipSuite: boolean }) { await terminal.runCommand(TerminalCommandId.MoveToEditor); await terminal.assertEditorGroupCount(1); }); + + for (const location of ['top', 'bottom']) { + it(`should support ${location} tabs with splits, keyboard rename, and full terminal width`, async function () { + const app = this.app as Application; + await settingsEditor.clearUserSettings(); + await setTerminalTestSettings(app, [['terminal.integrated.tabs.location', `"${location}"`]]); + try { + await app.workbench.quickaccess.runCommand('workbench.action.closeAllEditors'); + await terminal.createTerminal(); + await terminal.runCommand(TerminalCommandId.Split); + await terminal.assertHorizontalTabNames([undefined, undefined]); + const page = await terminal.getPage(); + await app.workbench.quickaccess.runCommand('workbench.action.terminal.focusTabs'); + await page.keyboard.press('Home'); + await page.keyboard.press(process.platform === 'darwin' ? 'Enter' : 'F2'); + const input = page.locator('.terminal-tabs-bar-tab input'); + await input.fill(`horizontal-${location}`); + await input.press('Enter'); + await terminal.assertHorizontalTabNames([`horizontal-${location}`, undefined]); + await page.waitForFunction((expectedLocation: string) => { + const tabs = document.querySelector('.pane-body.integrated-terminal .terminal-tabs-bar')?.getBoundingClientRect(); + const terminal = document.querySelector('.pane-body.integrated-terminal .terminal-outer-container')?.getBoundingClientRect(); + return tabs && terminal && Math.abs(tabs.width - terminal.width) < 1 && + (expectedLocation === 'top' ? tabs.bottom <= terminal.top + 1 : terminal.bottom <= tabs.top + 1); + }, location); + } finally { + await settingsEditor.clearUserSettings(); + await setTerminalTestSettings(app); + } + }); + } }); }