From 315b1f19d25d887ead80a6307e0325219c7d0a88 Mon Sep 17 00:00:00 2001 From: Alex Stockwell Date: Thu, 24 Sep 2026 11:31:23 -0400 Subject: [PATCH 1/4] feat(terminal): support tabs on top and bottom Add a horizontal terminal tab strip with shared actions, accessible keyboard navigation, live layout switching, and regression coverage for #252647. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/terminalTabsBar.css | 131 ++++ .../contrib/terminal/browser/terminal.ts | 1 + .../terminal/browser/terminalActions.ts | 39 +- .../terminal/browser/terminalGroupService.ts | 5 + .../terminal/browser/terminalTabbedView.ts | 281 ++++--- .../terminal/browser/terminalTabsBar.ts | 527 +++++++++++++ .../terminal/browser/terminalTabsChatEntry.ts | 1 + .../terminal/browser/terminalTabsList.ts | 218 +++--- .../terminal/browser/terminalTabsWidget.ts | 27 + .../contrib/terminal/common/terminal.ts | 2 +- .../terminal/common/terminalConfiguration.ts | 10 +- .../test/browser/terminalTabs.test.ts | 738 ++++++++++++++++++ .../terminal.accessibility.contribution.ts | 48 +- .../browser/terminalAccessibilityHelp.ts | 1 + .../test/browser/workbenchTestServices.ts | 1 + test/automation/src/terminal.ts | 7 + .../src/areas/terminal/terminal-tabs.test.ts | 31 + 17 files changed, 1821 insertions(+), 247 deletions(-) create mode 100644 src/vs/workbench/contrib/terminal/browser/media/terminalTabsBar.css create mode 100644 src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts create mode 100644 src/vs/workbench/contrib/terminal/browser/terminalTabsWidget.ts create mode 100644 src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts 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..853532a195724f 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; @@ -87,8 +96,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 +113,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,22 +132,47 @@ 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 && this._shouldShowTabs()) { + this.focusTabs(); + } 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)(() => { @@ -160,11 +202,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; @@ -194,11 +252,12 @@ export class TerminalTabbedView extends Disposable { } private _refreshShowTabs() { + const hadTabsFocus = this._tabContainer.contains(dom.getActiveElement()); if (this._shouldShowTabs()) { if (this._splitView.length === 1) { this._addTabTree(); this._addSashListener(); - this._splitView.resizeView(this._tabTreeIndex, this._getLastListWidth()); + this._splitView.resizeView(this._tabTreeIndex, this._isHorizontal ? TerminalTabsBar.HEIGHT : this._getLastListWidth()); this.rerenderTabs(); } } else { @@ -208,10 +267,23 @@ export class TerminalTabbedView extends Disposable { this._removeSashListener(); } } + if (this._width !== undefined && this._height !== undefined) { + this.layout(this._width, this._height); + } + if (hadTabsFocus && !this._tabContainer.contains(dom.getActiveElement())) { + if (this._shouldShowTabs()) { + this.focusTabs(); + } else { + this.focus(); + } + } } private _updateChatTerminalsEntry(): void { this._chatEntry?.update(); + if (this._width !== undefined && this._height !== undefined) { + this.layout(this._width, this._height); + } } private _getLastListWidth(): number { @@ -280,16 +352,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 +377,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 +392,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 +485,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 +521,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 +551,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 +581,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 +612,23 @@ 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.getFocus().length) { + this._tabList.setFocus(selected.slice(0, 1)); } + 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..e50f4ce5133137 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts @@ -0,0 +1,527 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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; + + 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 => { + 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(); + } + })); + 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.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'; + if (event.altKey) { + await this._terminalService.createTerminal({ location: { parentTerminal: instance } }); + return; + } + this._select(instance, event.shiftKey, this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey); + this.domFocus(); + if (!event.shiftKey && !(this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey)) { + 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 modifier = this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey; + let index = this._instances.indexOf(instance); + switch (event.key) { + case 'ArrowLeft': index--; break; + case 'ArrowRight': index++; break; + case 'Home': index = 0; break; + case 'End': index = this._instances.length - 1; break; + case 'a': + if (!modifier) { + return; + } + this.setSelection(this._instances.map((_, i) => i)); + event.preventDefault(); + event.stopPropagation(); + return; + case 'Enter': + if (this._os === OperatingSystem.Macintosh) { + return; + } + this._activate(instance); + instance.focus(true); + event.preventDefault(); + event.stopPropagation(); + return; + case ' ': + if (modifier) { + this._select(instance, false, true); + } else { + this._activate(instance); + instance.focus(true); + } + event.preventDefault(); + event.stopPropagation(); + return; + case 'ContextMenu': + case 'F10': { + if (event.key === 'F10' && !event.shiftKey) { + return; + } + const element = this._entries.get(instance.instanceId)!.element; + const bounds = element.getBoundingClientRect(); + element.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: bounds.left, clientY: bounds.bottom })); + 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 _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.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..5f0baf9de7e7cc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -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,7 @@ 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'; const $ = DOM.$; @@ -69,7 +69,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 +95,24 @@ 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 => { + this.setSelection(instances.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0)); + }); 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 +125,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 +133,8 @@ export class TerminalTabList extends WorkbenchList { _configurationService, instantiationService, ); + this.disposables.add(labels); + this.disposables.add(dnd); const instanceDisposables: IDisposable[] = [ this._terminalGroupService.onDidChangeInstances(() => this.refresh()), @@ -271,11 +283,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 +297,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 +397,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 +422,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 +455,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 +627,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 +668,17 @@ 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, ) { super(); - this._primaryBackend = this._terminalService.getPrimaryBackend(); } getDragURI(instance: ITerminalInstance): string | null { @@ -650,6 +699,11 @@ class TerminalTabsDragAndDrop extends Disposable implements IListDragAndDrop[] = []; + const primaryBackend = this._terminalService.getPrimaryBackend(); const resources = getTerminalResourcesFromDragEvent(originalEvent); if (resources) { for (const uri of resources) { @@ -712,32 +766,26 @@ class TerminalTabsDragAndDrop extends Disposable implements IListDragAndDrop 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)) { - this._handleExternalDrop(targetInstance, originalEvent); + await this._handleExternalDrop(targetInstance, originalEvent); return; } @@ -757,21 +805,13 @@ 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 f1bcb84e4d7009..a8039999cf63b4 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -192,7 +192,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 80c5267d6751d0..df9578df3727a3 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..ed90cfd26a8023 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts @@ -0,0 +1,738 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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 press(bar: TerminalTabsBar, key: string, options: KeyboardEventInit = {}): void { + const element = bar.getHTMLElement().querySelector(`[data-index="${bar.getFocus()[0]}"]`)!; + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...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 } + ]); + }); + + 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('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 = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + 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 = new KeyboardEvent('keydown', { key: os === OperatingSystem.Macintosh ? 'Enter' : 'F2', bubbles: true, cancelable: true }); + 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 = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + 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('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] }); + }); + + 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.drop(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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /connection is unavailable/); + backend = new class extends mock() { + override async requestDetachInstance(): Promise { return undefined; } + }; + await rejects(dnd.drop(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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })), /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.drop(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 } }); + }); + + 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..b9bfc64d343113 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,43 @@ 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.TerminalHelp, + { type: AccessibleViewType.Help }, + () => { + 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."), + 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.location', "Use terminal.integrated.tabs.location to place tabs on the left, right, top, or bottom."), + ].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..35dd6a0896f631 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 terminal.integrated.tabs.location to show a vertical list on the left or right, or a horizontal row on the top or bottom.", TerminalCommandId.FocusTabs), ]; if (!this._configurationService.getValue(TerminalAccessibilitySettingId.AccessibleViewFocusOnCommandExecution)) { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index ed894111920482..e8868f54a000ca 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1885,6 +1885,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); + } + }); + } }); } From ab6a90bad961a9bfc36f285440e9d00ec9d6f9ee Mon Sep 17 00:00:00 2001 From: Alex Stockwell Date: Thu, 24 Sep 2026 11:53:48 -0400 Subject: [PATCH 2/4] fix(terminal): restore tab focus after settings changes and drops Restore terminal focus when changing tab location also hides the tabs. Keep vertical drop selection, focus, and command targets aligned, with regressions for both PR review findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../terminal/browser/terminalTabbedView.ts | 8 +- .../terminal/browser/terminalTabsList.ts | 4 +- .../test/browser/terminalTabs.test.ts | 80 ++++++++++++++++++- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 853532a195724f..2bff4101c46aa0 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -163,8 +163,12 @@ export class TerminalTabbedView extends Disposable { this._splitView = this._createSplitView(parentElement); this._setupSplitView(terminalOuterContainer); this.layout(this._width ?? 0, this._height ?? 0); - if (hadTabsFocus && this._shouldShowTabs()) { - this.focusTabs(); + if (hadTabsFocus) { + if (this._shouldShowTabs()) { + this.focusTabs(); + } else { + this.focus(); + } } else if (dom.isHTMLElement(activeElement) && this._terminalContainer.contains(activeElement)) { activeElement.focus(); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index 5f0baf9de7e7cc..1612deb8897caf 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -97,7 +97,9 @@ export class TerminalTabList extends WorkbenchList implements ) { const labels = instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER); const dnd = instantiationService.createInstance(TerminalTabsDragAndDrop, instances => { - this.setSelection(instances.map(instance => this._terminalGroupService.instances.indexOf(instance)).filter(index => index >= 0)); + 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, { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts index ed90cfd26a8023..26adf722c2a1d3 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts @@ -33,7 +33,7 @@ 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 { TerminalTabsDragAndDrop, TerminalTabsRenderer } from '../../browser/terminalTabsList.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'; @@ -544,6 +544,38 @@ suite('Terminal tabs', () => { 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'); } @@ -664,6 +696,52 @@ suite('Terminal tabs', () => { 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); From 139edace915f4cd4b23b43262c752e5da3dd581c Mon Sep 17 00:00:00 2001 From: Alex Stockwell Date: Thu, 24 Sep 2026 16:03:55 -0400 Subject: [PATCH 3/4] fix(terminal): align tab keyboard interactions and report failed drops Handle keyboard context menus on keyup, honor configured multi-selection modifiers, and normalize Select All across keyboard layouts. Report failed drops once with explicit partial-transfer context while preserving core rejection semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../terminal/browser/terminalTabsBar.ts | 76 +++++--- .../terminal/browser/terminalTabsList.ts | 129 +++++++++----- .../test/browser/terminalTabs.test.ts | 165 +++++++++++++++++- .../terminal.accessibility.contribution.ts | 1 + 4 files changed, 290 insertions(+), 81 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts index e50f4ce5133137..95c9e6fc67a081 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts @@ -5,6 +5,7 @@ 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'; @@ -13,6 +14,7 @@ import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '. 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'; @@ -58,6 +60,7 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { private _focused: ITerminalInstance | undefined; private _anchor: ITerminalInstance | undefined; private _activating = false; + private _contextMenuKeyDown = false; constructor( container: HTMLElement, @@ -136,6 +139,11 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { } })); 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)]); @@ -160,6 +168,11 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { 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; @@ -296,6 +309,7 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { } })); 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'; @@ -330,13 +344,15 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { private async _onClick(instance: ITerminalInstance, event: MouseEvent): Promise { this._terminalGroupService.lastAccessedMenu = 'tab-list'; - if (event.altKey) { + const useAltForSelection = this._configurationService.getValue('workbench.list.multiSelectModifier') === 'alt'; + if (event.altKey && !useAltForSelection) { await this._terminalService.createTerminal({ location: { parentTerminal: instance } }); return; } - this._select(instance, event.shiftKey, this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey); + const selectionModifier = useAltForSelection ? event.altKey : this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey; + this._select(instance, event.shiftKey, selectionModifier); this.domFocus(); - if (!event.shiftKey && !(this._os === OperatingSystem.Macintosh ? event.metaKey : event.ctrlKey)) { + if (!event.shiftKey && !selectionModifier) { this._activate(instance); if (this._configurationService.getValue(TerminalSettingId.TabsFocusMode) === 'singleClick') { instance.focus(true); @@ -363,22 +379,29 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { 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 (event.key) { - case 'ArrowLeft': index--; break; - case 'ArrowRight': index++; break; - case 'Home': index = 0; break; - case 'End': index = this._instances.length - 1; break; - case 'a': - if (!modifier) { + 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 'Enter': + case KeyCode.Enter: if (this._os === OperatingSystem.Macintosh) { return; } @@ -387,7 +410,7 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { event.preventDefault(); event.stopPropagation(); return; - case ' ': + case KeyCode.Space: if (modifier) { this._select(instance, false, true); } else { @@ -397,18 +420,6 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { event.preventDefault(); event.stopPropagation(); return; - case 'ContextMenu': - case 'F10': { - if (event.key === 'F10' && !event.shiftKey) { - return; - } - const element = this._entries.get(instance.instanceId)!.element; - const bounds = element.getBoundingClientRect(); - element.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: bounds.left, clientY: bounds.bottom })); - event.preventDefault(); - event.stopPropagation(); - return; - } default: return; } event.preventDefault(); @@ -425,6 +436,23 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { 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 { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index 1612deb8897caf..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'; @@ -56,6 +56,9 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm 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.$; @@ -196,9 +199,10 @@ export class TerminalTabList extends WorkbenchList implements 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); } @@ -679,6 +683,8 @@ export class TerminalTabsDragAndDrop extends Disposable implements IListDragAndD @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @ITerminalEditingService private readonly _terminalEditingService: ITerminalEditingService, + @INotificationService private readonly _notificationService: INotificationService, + @ILogService private readonly _logService: ILogService, ) { super(); } @@ -752,68 +758,95 @@ export class TerminalTabsDragAndDrop extends Disposable implements IListDragAndD }; } - async drop(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): Promise { + /** 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 primaryBackend = this._terminalService.getPrimaryBackend(); - const resources = getTerminalResourcesFromDragEvent(originalEvent); - if (resources) { - for (const uri of resources) { - const instance = this._terminalService.getInstanceFromResource(uri); - if (instance) { - if (Array.isArray(sourceInstances)) { - sourceInstances.push(instance); - } else { - sourceInstances = [instance]; - } - await this._terminalService.moveToTerminalView(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.")); + 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(await this._terminalService.createTerminal({ config: { attachPersistentProcess } })); + 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 { + throw new Error(localize('terminalDropBackendUnavailable', "Cannot move the terminal because its terminal connection is unavailable.")); } - } else { - throw new Error(localize('terminalDropBackendUnavailable', "Cannot move the terminal because its terminal connection is unavailable.")); } } - } - if (sourceInstances === undefined) { - if (!(data instanceof ElementsDragAndDropData)) { - await this._handleExternalDrop(targetInstance, originalEvent); - return; - } + if (sourceInstances === undefined) { + if (!(data instanceof ElementsDragAndDropData)) { + await 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); + if (!targetInstance) { + this._terminalGroupService.moveGroupToEnd(sourceInstances); + this._terminalService.setActiveInstance(sourceInstances[0]); + this._selectInstances(sourceInstances); + return; + } + + this._terminalGroupService.moveGroup(sourceInstances, targetInstance); this._terminalService.setActiveInstance(sourceInstances[0]); this._selectInstances(sourceInstances); - return; + } 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 }); + } + throw error; } - - this._terminalGroupService.moveGroup(sourceInstances, targetInstance); - this._terminalService.setActiveInstance(sourceInstances[0]); - this._selectInstances(sourceInstances); } private async _handleExternalDrop(instance: ITerminalInstance | undefined, e: DragEvent) { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts index 26adf722c2a1d3..cf7cd3dc28098a 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts @@ -23,6 +23,8 @@ import { ConfigurationTarget } from '../../../../../platform/configuration/commo 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'; @@ -240,9 +242,15 @@ suite('Terminal tabs', () => { }); } + 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(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options })); + element.dispatchEvent(keyboardEvent('keydown', key, options)); + element.dispatchEvent(keyboardEvent('keyup', key, options)); } test('renders individual split terminals with accessible names and group boundaries', () => { @@ -353,7 +361,7 @@ suite('Terminal tabs', () => { test('does not intercept the macOS Enter rename binding', () => { const bar = createBar(); - const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + 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 }); }); @@ -468,7 +476,7 @@ suite('Terminal tabs', () => { const toggled = bar.getSelection(); press(bar, 'Home'); press(bar, 'End', { shiftKey: true }); - const rename = new KeyboardEvent('keydown', { key: os === OperatingSystem.Macintosh ? 'Enter' : 'F2', bubbles: true, cancelable: 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 }); @@ -487,13 +495,152 @@ suite('Terminal tabs', () => { test(`${name}: Enter activation and Space focus follow platform conventions`, () => { const bar = createBar(os); bar.domFocus(); - const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + 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', () => { @@ -583,7 +730,7 @@ suite('Terminal tabs', () => { 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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })), /detach failed/); + 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 () => { @@ -617,11 +764,11 @@ suite('Terminal tabs', () => { 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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /connection is unavailable/); + 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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /could not be detached/); + await rejects(dnd.performDrop(new NativeDragAndDropData(), undefined, undefined, undefined, event), /could not be detached/); deepStrictEqual({ created, moves }, { created: [], moves: [] }); }); @@ -635,7 +782,7 @@ suite('Terminal tabs', () => { 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.drop(new NativeDragAndDropData(), undefined, undefined, undefined, new DragEvent('drop', { dataTransfer })), /could not be detached/); + 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) @@ -653,7 +800,7 @@ suite('Terminal tabs', () => { 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.drop(new NativeDragAndDropData(), instances[1], 1, undefined, new DragEvent('drop', { dataTransfer })), /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) { 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 b9bfc64d343113..770482692794f7 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 @@ -289,6 +289,7 @@ AccessibleViewRegistry.register({ : 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 terminal.integrated.tabs.location to place tabs on the left, right, top, or bottom."), ].join('\n'); }, From 7a5cd88b0b2ba6d2c9b10518beaa111b6d4c9192 Mon Sep 17 00:00:00 2001 From: Alex Stockwell Date: Thu, 24 Sep 2026 16:27:51 -0400 Subject: [PATCH 4/4] fix(terminal): refine tab focus, layout, and accessibility semantics Restore selected-terminal focusing for the explicit command while preserving focus across layout changes. Avoid duplicate and unchanged-state layouts, give tab help an independent provider identity, and expose the active terminal separately from selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../accessibility/browser/accessibleView.ts | 1 + .../terminal/browser/terminalTabbedView.ts | 39 +++--- .../terminal/browser/terminalTabsBar.ts | 1 + .../test/browser/terminalTabs.test.ts | 121 ++++++++++++++++++ .../terminal.accessibility.contribution.ts | 7 +- .../browser/terminalAccessibilityHelp.ts | 2 +- .../terminalTabsAccessibilityHelp.test.ts | 53 ++++++++ 7 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/terminalTabsAccessibilityHelp.test.ts diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 001d8f3ca66c78..9614727b5eae29 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/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 2bff4101c46aa0..4741c2eee397d5 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -82,6 +82,7 @@ export class TerminalTabbedView extends Disposable { private _panelOrientation: Orientation | undefined; private _emptyAreaDropTargetCount = 0; + private _hiddenChatTerminalCount = 0; constructor( parentElement: HTMLElement, @@ -165,7 +166,7 @@ export class TerminalTabbedView extends Disposable { this.layout(this._width ?? 0, this._height ?? 0); if (hadTabsFocus) { if (this._shouldShowTabs()) { - this.focusTabs(); + this._focusTabsWidget(); } else { this.focus(); } @@ -180,19 +181,16 @@ export class TerminalTabbedView extends Disposable { } })); 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); @@ -255,14 +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._isHorizontal ? TerminalTabsBar.HEIGHT : this._getLastListWidth()); - this.rerenderTabs(); } } else { if (this._splitView.length === 2 && !this._terminalTabsMouseContextKey.get()) { @@ -271,23 +269,24 @@ export class TerminalTabbedView extends Disposable { this._removeSashListener(); } } - if (this._width !== undefined && this._height !== undefined) { + 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.focusTabs(); + 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(); - if (this._width !== undefined && this._height !== undefined) { - this.layout(this._width, this._height); - } + return changed; } private _getLastListWidth(): number { @@ -627,11 +626,19 @@ export class TerminalTabbedView extends Disposable { if (!this._shouldShowTabs()) { return; } - this._terminalTabsFocusContextKey.set(true); const selected = this._tabList.getSelection(); - if (selected.length && !this._tabList.getFocus().length) { + 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(); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts index 95c9e6fc67a081..cf4c425bbaa02a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsBar.ts @@ -475,6 +475,7 @@ export class TerminalTabsBar extends Disposable implements ITerminalTabsWidget { 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); } } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts index cf7cd3dc28098a..0b0d2af22af24d 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTabs.test.ts @@ -267,6 +267,38 @@ suite('Terminal tabs', () => { ]); }); + 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; @@ -291,8 +323,97 @@ suite('Terminal tabs', () => { 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(); 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 770482692794f7..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 @@ -277,20 +277,21 @@ AccessibleViewRegistry.register({ const configurationService = accessor.get(IConfigurationService); const previousFocus = getActiveElement(); return new AccessibleContentProvider( - AccessibleViewProviderId.TerminalHelp, - { type: AccessibleViewType.Help }, + 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 terminal.integrated.tabs.location to place tabs on the left, right, top, or bottom."), + localize('terminalTabsHelp.location', "Use `{0}` to place tabs on the left, right, top, or bottom.", TerminalSettingId.TabsLocation), ].join('\n'); }, () => { diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts index 35dd6a0896f631..d27a777bf07227 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts @@ -64,7 +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 terminal.integrated.tabs.location to show a vertical list on the left or right, or a horizontal row on the top or bottom.", TerminalCommandId.FocusTabs), + 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(); + } + }); +});