From b699bf8b186bc72cd09596b1f5afec22bd9b13ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Fri, 7 Apr 2023 16:38:15 +0200 Subject: [PATCH] Add "open tabs" drop down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a drop down allowing to chose among open editors when there is too little space to show all open tabs. Part of #12328 Contributed on behalf of STMicroelectronics Signed-off-by: Thomas Mäder --- packages/core/src/browser/shell/tab-bars.ts | 154 +++++++++++------- packages/core/src/browser/style/tabs.css | 72 +++++++- .../src/browser/widgets/select-component.tsx | 34 ++-- 3 files changed, 177 insertions(+), 83 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 812b091374640..237a3c2e8bcfa 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -34,6 +34,9 @@ import { IDragEvent } from '@phosphor/dragdrop'; import { LOCKED_CLASS, PINNED_CLASS } from '../widgets/widget'; import { CorePreferences } from '../core-preferences'; import { HoverService } from '../hover-service'; +import { Root, createRoot } from 'react-dom/client'; +import { SelectComponent } from '../widgets/select-component'; +import { createElement } from 'react'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -228,7 +231,7 @@ export class TabBarRenderer extends TabBar.Renderer { } else { width = ''; } - return { zIndex, height, width }; + return { zIndex, height, minWidth: width, maxWidth: width }; } /** @@ -575,13 +578,6 @@ export class TabBarRenderer extends TabBar.Renderer { } -export namespace ScrollableTabBar { - export interface Options { - minimumTabSize: number; - defaultTabSize: number; - } -} - /** * A specialized tab bar for the main and bottom areas. */ @@ -595,13 +591,18 @@ export class ScrollableTabBar extends TabBar { protected needsRecompute = false; protected tabSize = 0; private _dynamicTabOptions?: ScrollableTabBar.Options; + protected contentContainer: HTMLElement; + protected topRow: HTMLElement; protected readonly toDispose = new DisposableCollection(); + protected openTabsContainer: HTMLDivElement; + protected openTabsRoot: Root; constructor(options?: TabBar.IOptions & PerfectScrollbar.Options, dynamicTabOptions?: ScrollableTabBar.Options) { super(options); this.scrollBarFactory = () => new PerfectScrollbar(this.scrollbarHost, options); this._dynamicTabOptions = dynamicTabOptions; + this.rewireDOM(); } set dynamicTabOptions(options: ScrollableTabBar.Options | undefined) { @@ -621,6 +622,35 @@ export class ScrollableTabBar extends TabBar { this.toDispose.dispose(); } + /** + * Restructures the DOM defined in PhosphorJS. + * + * By default the tabs (`li`) are contained in the `this.contentNode` (`ul`) which is wrapped in a `div` (`this.node`). + * Instead of this structure, we add a container for the `this.contentNode` and for the toolbar. + * The scrollbar will only work for the `ul` part but it does not affect the toolbar, so it can be on the right hand-side. + */ + private rewireDOM(): void { + const contentNode = this.node.getElementsByClassName(ScrollableTabBar.Styles.TAB_BAR_CONTENT)[0]; + if (!contentNode) { + throw new Error("'this.node' does not have the content as a direct child with class name 'p-TabBar-content'."); + } + this.node.removeChild(contentNode); + this.contentContainer = document.createElement('div'); + this.contentContainer.classList.add(ScrollableTabBar.Styles.TAB_BAR_CONTENT_CONTAINER); + this.contentContainer.appendChild(contentNode); + + this.topRow = document.createElement('div'); + this.topRow.classList.add('theia-tabBar-tab-row'); + this.topRow.appendChild(this.contentContainer); + + this.openTabsContainer = document.createElement('div'); + this.openTabsContainer.classList.add('theia-tabBar-open-tabs'); + this.openTabsRoot = createRoot(this.openTabsContainer); + this.topRow.appendChild(this.openTabsContainer); + + this.node.appendChild(this.topRow); + } + protected override onAfterAttach(msg: Message): void { if (!this.scrollBar) { this.scrollBar = this.scrollBarFactory(); @@ -652,6 +682,15 @@ export class ScrollableTabBar extends TabBar { const content = []; if (this.dynamicTabOptions) { + + this.openTabsRoot.render(createElement(SelectComponent, { + options: this.titles, + onChange: (option, index) => { + this.currentIndex = index; + }, + alignment: 'right' + })); + if (this.isMouseOver) { this.needsRecompute = true; } else { @@ -659,8 +698,20 @@ export class ScrollableTabBar extends TabBar { if (this.orientation === 'horizontal') { this.tabSize = Math.max(Math.min(this.scrollbarHost.clientWidth / this.titles.length, this.dynamicTabOptions.defaultTabSize), this.dynamicTabOptions.minimumTabSize); + + let availableWidth = this.scrollbarHost.clientWidth; + if (!this.openTabsContainer.classList.contains('p-mod-hidden')) { + availableWidth += this.openTabsContainer.getBoundingClientRect().width; + } + if (this.dynamicTabOptions.minimumTabSize * this.titles.length <= availableWidth) { + this.openTabsContainer.classList.add('p-mod-hidden'); + } else { + this.openTabsContainer.classList.remove('p-mod-hidden'); + } } } + } else { + this.openTabsContainer.classList.add('p-mod-hidden'); } for (let i = 0, n = this.titles.length; i < n; ++i) { const title = this.titles[i]; @@ -739,10 +790,38 @@ export class ScrollableTabBar extends TabBar { return result; } + /** + * Overrides the `contentNode` property getter in PhosphorJS' TabBar. + */ + // @ts-expect-error TS2611 `TabBar.contentNode` is declared as `readonly contentNode` but is implemented as a getter. + get contentNode(): HTMLUListElement { + return this.tabBarContainer.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT)[0] as HTMLUListElement; + } + + /** + * Overrides the scrollable host from the parent class. + */ protected get scrollbarHost(): HTMLElement { - return this.node; + return this.tabBarContainer; + } + + protected get tabBarContainer(): HTMLElement { + return this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER)[0] as HTMLElement; + } +} + +export namespace ScrollableTabBar { + + export interface Options { + minimumTabSize: number; + defaultTabSize: number; } + export namespace Styles { + export const TAB_BAR_CONTENT = 'p-TabBar-content'; + export const TAB_BAR_CONTENT_CONTAINER = 'p-TabBar-content-container'; + + } } /** @@ -761,12 +840,9 @@ export class ScrollableTabBar extends TabBar { * */ export class ToolbarAwareTabBar extends ScrollableTabBar { - - protected contentContainer: HTMLElement; protected toolbar: TabBarToolbar | undefined; protected breadcrumbsContainer: HTMLElement; protected readonly breadcrumbsRenderer: BreadcrumbsRenderer; - protected topRow: HTMLElement; constructor( protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, @@ -777,7 +853,8 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { ) { super(options, dynamicTabOptions); this.breadcrumbsRenderer = this.breadcrumbsRendererFactory(); - this.rewireDOM(); + this.addBreadcrumbs(); + this.toolbar = this.tabBarToolbarFactory(); this.toDispose.push(this.tabBarToolbarRegistry.onDidChange(() => this.update())); this.toDispose.push(this.breadcrumbsRenderer); this.toDispose.push(this.breadcrumbsRenderer.onDidChangeActiveState(active => { @@ -792,25 +869,6 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { this.toDispose.push(Disposable.create(() => this.currentChanged.disconnect(handler))); } - /** - * Overrides the `contentNode` property getter in PhosphorJS' TabBar. - */ - // @ts-expect-error TS2611 `TabBar.contentNode` is declared as `readonly contentNode` but is implemented as a getter. - get contentNode(): HTMLUListElement { - return this.tabBarContainer.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT)[0] as HTMLUListElement; - } - - /** - * Overrides the scrollable host from the parent class. - */ - protected override get scrollbarHost(): HTMLElement { - return this.tabBarContainer; - } - - protected get tabBarContainer(): HTMLElement { - return this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER)[0] as HTMLElement; - } - protected async updateBreadcrumbs(): Promise { const current = this.currentTitle?.owner; const uri = NavigatableWidget.is(current) ? current.getResourceUri() : undefined; @@ -853,11 +911,9 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { } override handleEvent(event: Event): void { - if (this.toolbar && event instanceof MouseEvent && this.toolbar.shouldHandleMouseEvent(event)) { - // if the mouse event is over the toolbar part don't handle it. - return; + if (event.target instanceof Element && this.tabBarContainer.contains(event.target)) { + super.handleEvent(event); } - super.handleEvent(event); } /** @@ -867,20 +923,7 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { * Instead of this structure, we add a container for the `this.contentNode` and for the toolbar. * The scrollbar will only work for the `ul` part but it does not affect the toolbar, so it can be on the right hand-side. */ - protected rewireDOM(): void { - const contentNode = this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT)[0]; - if (!contentNode) { - throw new Error("'this.node' does not have the content as a direct child with class name 'p-TabBar-content'."); - } - this.node.removeChild(contentNode); - this.topRow = document.createElement('div'); - this.topRow.classList.add('theia-tabBar-tab-row'); - this.contentContainer = document.createElement('div'); - this.contentContainer.classList.add(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER); - this.contentContainer.appendChild(contentNode); - this.topRow.appendChild(this.contentContainer); - this.node.appendChild(this.topRow); - this.toolbar = this.tabBarToolbarFactory(); + private addBreadcrumbs(): void { this.breadcrumbsContainer = document.createElement('div'); this.breadcrumbsContainer.classList.add('theia-tabBar-breadcrumb-row'); this.breadcrumbsContainer.appendChild(this.breadcrumbsRenderer.host); @@ -888,17 +931,6 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { } } -export namespace ToolbarAwareTabBar { - - export namespace Styles { - - export const TAB_BAR_CONTENT = 'p-TabBar-content'; - export const TAB_BAR_CONTENT_CONTAINER = 'p-TabBar-content-container'; - - } - -} - /** * A specialized tab bar for side areas. */ diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 05569f1cd786b..3aeb409d941a8 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -22,8 +22,6 @@ } .p-TabBar[data-orientation='horizontal'] { - overflow-x: hidden; - overflow-y: hidden; min-height: var(--theia-horizontal-toolbar-height); } @@ -38,6 +36,7 @@ line-height: var(--theia-private-horizontal-tab-height); padding: 0px 8px; align-items: center; + overflow: hidden; } .p-TabBar[data-orientation='vertical'] .p-TabBar-tab { @@ -204,7 +203,7 @@ height: var(--theia-icon-size); width: var(--theia-icon-size); font: normal normal normal 16px/1 codicon; - display: inline-block; + display: none; text-decoration: none; text-rendering: auto; text-align: center; @@ -215,7 +214,13 @@ -ms-user-select: none; } -.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable > .p-TabBar-tabCloseIcon:hover { +.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-current > .p-TabBar-tabCloseIcon, +.p-TabBar.theia-app-centers .p-TabBar-tab:hover.p-mod-closable > .p-TabBar-tabCloseIcon, +.p-TabBar.theia-app-centers .p-TabBar-tab:hover.theia-mod-pinned > .p-TabBar-tabCloseIcon { + display: inline-block; +} + +.p-TabBar.theia-app-centers .p-TabBar-tab:hover.p-mod-closable > .p-TabBar-tabCloseIcon { border-radius: 5px; background-color: rgba(50%, 50%, 50%, 0.2); } @@ -303,6 +308,15 @@ bottom: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } +.p-TabBar[data-orientation='vertical'] .p-TabBar-content-container > .ps__rail-y { + width: var(--theia-private-horizontal-tab-scrollbar-rail-height); + z-index: 1000; +} + +.p-TabBar[data-orientation='vertical'] .p-TabBar-content-container > .ps__rail-y > .ps__thumb-y { + width: var(--theia-private-horizontal-tab-scrollbar-height) !important; + right: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); +} /*----------------------------------------------------------------------------- | Dragged tabs @@ -337,7 +351,7 @@ } .p-TabBar-content-container { - display: flex; + display: block; flex: 1; position: relative; /* This is necessary for perfect-scrollbar */ } @@ -405,18 +419,36 @@ flex-direction: column; } -.theia-tabBar-tab-row { +.p-TabBar[data-orientation='horizontal'] .theia-tabBar-tab-row { display: flex; flex-flow: row nowrap; min-width: 100%; } -.p-TabBar-tab .theia-tab-icon-label { +.p-TabBar[data-orientation='vertical'] .theia-tabBar-tab-row { + display: flex; + flex-flow: column nowrap; + height: 100%; +} + + +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content { + flex-direction: row; +} + +.p-TabBar[data-orientation='vertical'] .p-TabBar-content { + flex-direction: column; +} + +.p-TabBar.theia-app-centers[data-orientation='horizontal'] .p-TabBar-tabLabel { + mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 1) 15px); + -webkit-mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 1) 15px); flex: 1; } -.p-TabBar[data-orientation='horizontal'] .p-TabBar-tab.p-mod-closable:hover .theia-tab-icon-label, -.p-TabBar[data-orientation='horizontal'] .p-TabBar-tab.p-mod-current .theia-tab-icon-label { + +.p-TabBar[data-orientation='horizontal'] .p-TabBar-tab .theia-tab-icon-label { + flex: 1; overflow: hidden; } @@ -435,3 +467,25 @@ margin: 0px 0px; margin-top: 4px; } + +/*----------------------------------------------------------------------------- +| Open tabs dropdown +|----------------------------------------------------------------------------*/ +.theia-tabBar-open-tabs>.theia-select-component .theia-select-component-label { + display: none; +} + +.theia-tabBar-open-tabs>.theia-select-component { + min-width: auto; + height: 100%; +} + +.theia-tabBar-open-tabs { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.theia-tabBar-open-tabs.p-mod-hidden { + display: none +} diff --git a/packages/core/src/browser/widgets/select-component.tsx b/packages/core/src/browser/widgets/select-component.tsx index 576f78abb99e8..bc091d2279836 100644 --- a/packages/core/src/browser/widgets/select-component.tsx +++ b/packages/core/src/browser/widgets/select-component.tsx @@ -34,11 +34,12 @@ export interface SelectOption { } export interface SelectComponentProps { - options: SelectOption[] + options: readonly SelectOption[] defaultValue?: string | number onChange?: (option: SelectOption, index: number) => void, onBlur?: () => void, - onFocus?: () => void + onFocus?: () => void, + alignment?: 'left' | 'right'; } export interface SelectComponentState { @@ -81,7 +82,7 @@ export class SelectComponent extends React.Component e.label || e.value || '' + (e.detail || ''))); return Math.ceil(textWidth + 16); @@ -168,7 +173,7 @@ export class SelectComponent extends React.Component
{items}