@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
1,104 lines (1,004 loc) • 64.3 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import PerfectScrollbar from 'perfect-scrollbar';
import { TabBar, Title, Widget } from '@phosphor/widgets';
import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/virtualdom';
import { Disposable, DisposableCollection, MenuPath, notEmpty, SelectionService, CommandService, nls } from '../../common';
import { ContextMenuRenderer } from '../context-menu-renderer';
import { Signal, Slot } from '@phosphor/signaling';
import { Message, MessageLoop } from '@phosphor/messaging';
import { ArrayExt } from '@phosphor/algorithm';
import { ElementExt } from '@phosphor/domutils';
import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar';
import { TheiaDockPanel, MAIN_AREA_ID, BOTTOM_AREA_ID } from './theia-dock-panel';
import { WidgetDecoration } from '../widget-decoration';
import { TabBarDecoratorService } from './tab-bar-decorator';
import { IconThemeService } from '../icon-theme-service';
import { BreadcrumbsRenderer, BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer';
import { NavigatableWidget } from '../navigatable-types';
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';
import { PreviewableWidget } from '../widgets/previewable-widget';
import { EnhancedPreviewWidget } from '../widgets/enhanced-preview-widget';
/** The class name added to hidden content nodes, which are required to render vertical side bars. */
const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content';
/** Menu path for tab bars used throughout the application shell. */
export const SHELL_TABBAR_CONTEXT_MENU: MenuPath = ['shell-tabbar-context-menu'];
export const SHELL_TABBAR_CONTEXT_CLOSE: MenuPath = [...SHELL_TABBAR_CONTEXT_MENU, '0_close'];
export const SHELL_TABBAR_CONTEXT_COPY: MenuPath = [...SHELL_TABBAR_CONTEXT_MENU, '1_copy'];
// Kept here in anticipation of tab pinning behavior implemented in tab-bars.ts
export const SHELL_TABBAR_CONTEXT_PIN: MenuPath = [...SHELL_TABBAR_CONTEXT_MENU, '4_pin'];
export const SHELL_TABBAR_CONTEXT_SPLIT: MenuPath = [...SHELL_TABBAR_CONTEXT_MENU, '5_split'];
export const TabBarRendererFactory = Symbol('TabBarRendererFactory');
export type TabBarRendererFactory = () => TabBarRenderer;
/**
* Size information of DOM elements used for rendering tabs in side bars.
*/
export interface SizeData {
width: number;
height: number;
}
/**
* Extension of the rendering data used for tabs in side bars of the application shell.
*/
export interface SideBarRenderData extends TabBar.IRenderData<Widget> {
labelSize?: SizeData;
iconSize?: SizeData;
paddingTop?: number;
paddingBottom?: number;
visible?: boolean
}
export interface ScrollableRenderData extends TabBar.IRenderData<Widget> {
tabWidth?: number;
}
/**
* A tab bar renderer that offers a context menu. In addition, this renderer is able to
* set an explicit position and size on the icon and label of each tab in a side bar.
* This is necessary because the elements of side bar tabs are rotated using the CSS
* `transform` property, disrupting the browser's ability to arrange those elements
* automatically.
*/
export class TabBarRenderer extends TabBar.Renderer {
/**
* The menu path used to render the context menu.
*/
contextMenuPath?: MenuPath;
protected readonly toDispose = new DisposableCollection();
// TODO refactor shell, rendered should only receive props with event handlers
// events should be handled by clients, like ApplicationShell
// right now it is mess: (1) client logic belong to renderer, (2) cyclic dependencies between renderers and clients
constructor(
protected readonly contextMenuRenderer?: ContextMenuRenderer,
protected readonly decoratorService?: TabBarDecoratorService,
protected readonly iconThemeService?: IconThemeService,
protected readonly selectionService?: SelectionService,
protected readonly commandService?: CommandService,
protected readonly corePreferences?: CorePreferences,
protected readonly hoverService?: HoverService
) {
super();
if (this.decoratorService) {
this.toDispose.push(Disposable.create(() => this.resetDecorations()));
this.toDispose.push(this.decoratorService.onDidChangeDecorations(() => this.resetDecorations()));
}
if (this.iconThemeService) {
this.toDispose.push(this.iconThemeService.onDidChangeCurrent(() => {
if (this._tabBar) {
this._tabBar.update();
}
}));
}
}
dispose(): void {
this.toDispose.dispose();
}
protected _tabBar?: TabBar<Widget>;
protected readonly toDisposeOnTabBar = new DisposableCollection();
/**
* A reference to the tab bar is required in order to activate it when a context menu
* is requested.
*/
set tabBar(tabBar: TabBar<Widget> | undefined) {
if (this.toDispose.disposed) {
throw new Error('disposed');
}
if (this._tabBar === tabBar) {
return;
}
this.toDisposeOnTabBar.dispose();
this.toDispose.push(this.toDisposeOnTabBar);
this._tabBar = tabBar;
if (tabBar) {
const listener: Slot<Widget, TabBar.ITabCloseRequestedArgs<Widget>> = (_, { title }) => this.resetDecorations(title);
tabBar.tabCloseRequested.connect(listener);
this.toDisposeOnTabBar.push(Disposable.create(() => tabBar.tabCloseRequested.disconnect(listener)));
}
this.resetDecorations();
}
get tabBar(): TabBar<Widget> | undefined {
return this._tabBar;
}
/**
* Render tabs with the default DOM structure, but additionally register a context menu listener.
* @param {SideBarRenderData} data Data used to render the tab.
* @param {boolean} isInSidePanel An optional check which determines if the tab is in the side-panel.
* @param {boolean} isPartOfHiddenTabBar An optional check which determines if the tab is in the hidden horizontal tab bar.
* @returns {VirtualElement} The virtual element of the rendered tab.
*/
override renderTab(data: SideBarRenderData, isInSidePanel?: boolean, isPartOfHiddenTabBar?: boolean): VirtualElement {
const title = data.title;
const id = this.createTabId(title, isPartOfHiddenTabBar);
const key = this.createTabKey(data);
const style = this.createTabStyle(data);
const className = this.createTabClass(data);
const dataset = this.createTabDataset(data);
const closeIconTitle = data.title.className.includes(PINNED_CLASS)
? nls.localizeByDefault('Unpin')
: nls.localizeByDefault('Close');
const hover = this.tabBar && (this.tabBar.orientation === 'horizontal' && this.corePreferences?.['window.tabbar.enhancedPreview'] === 'classic')
? { title: title.caption }
: {
onmouseenter: this.handleMouseEnterEvent
};
return h.li(
{
...hover,
key, className, id, style, dataset,
oncontextmenu: this.handleContextMenuEvent,
ondblclick: this.handleDblClickEvent,
onauxclick: (e: MouseEvent) => {
// If user closes the tab using mouse wheel, nothing should be pasted to an active editor
e.preventDefault();
}
},
h.div(
{ className: 'theia-tab-icon-label' },
this.renderIcon(data, isInSidePanel),
this.renderLabel(data, isInSidePanel),
this.renderBadge(data, isInSidePanel),
this.renderLock(data, isInSidePanel)
),
h.div({
className: 'p-TabBar-tabCloseIcon action-label',
title: closeIconTitle,
onclick: this.handleCloseClickEvent
})
);
}
override createTabClass(data: SideBarRenderData): string {
let tabClass = super.createTabClass(data);
if (!(data.visible ?? true)) {
tabClass += ' p-mod-invisible';
}
return tabClass;
}
/**
* Generate ID for an entry in the tab bar
* @param {Title<Widget>} title Title of the widget controlled by this tab bar
* @param {boolean} isPartOfHiddenTabBar Tells us if this entry is part of the hidden horizontal tab bar.
* If yes, add a suffix to differentiate it's ID from the entry in the visible tab bar
* @returns {string} DOM element ID
*/
createTabId(title: Title<Widget>, isPartOfHiddenTabBar = false): string {
return 'shell-tab-' + title.owner.id + (isPartOfHiddenTabBar ? '-hidden' : '');
}
/**
* If size information is available for the label and icon, set an explicit height on the tab.
* The height value also considers padding, which should be derived from CSS settings.
*/
override createTabStyle(data: SideBarRenderData & ScrollableRenderData): ElementInlineStyle {
const zIndex = `${data.zIndex}`;
const labelSize = data.labelSize;
const iconSize = data.iconSize;
let height: string | undefined;
let width: string | undefined;
if (labelSize || iconSize) {
const labelHeight = labelSize ? (this.tabBar && this.tabBar.orientation === 'horizontal' ? labelSize.height : labelSize.width) : 0;
const iconHeight = iconSize ? iconSize.height : 0;
let paddingTop = data.paddingTop || 0;
if (labelHeight > 0 && iconHeight > 0) {
// Leave some extra space between icon and label
paddingTop = paddingTop * 1.5;
}
const paddingBottom = data.paddingBottom || 0;
height = `${labelHeight + iconHeight + paddingTop + paddingBottom}px`;
}
if (data.tabWidth) {
width = `${data.tabWidth}px`;
} else {
width = '';
}
return { zIndex, height, minWidth: width, maxWidth: width };
}
/**
* If size information is available for the label, set it as inline style.
* Tab padding and icon size are also considered in the `top` position.
* @param {SideBarRenderData} data Data used to render the tab.
* @param {boolean} isInSidePanel An optional check which determines if the tab is in the side-panel.
* @returns {VirtualElement} The virtual element of the rendered label.
*/
override renderLabel(data: SideBarRenderData, isInSidePanel?: boolean): VirtualElement {
const labelSize = data.labelSize;
const iconSize = data.iconSize;
let width: string | undefined;
let height: string | undefined;
let top: string | undefined;
if (labelSize) {
width = `${labelSize.width}px`;
height = `${labelSize.height}px`;
}
if (data.paddingTop || iconSize) {
const iconHeight = iconSize ? iconSize.height : 0;
let paddingTop = data.paddingTop || 0;
if (iconHeight > 0) {
// Leave some extra space between icon and label
paddingTop = paddingTop * 1.5;
}
top = `${paddingTop + iconHeight}px`;
}
const style: ElementInlineStyle = { width, height, top };
// No need to check for duplicate labels if the tab is rendered in the side panel (title is not displayed),
// or if there are less than two files in the tab bar.
if (isInSidePanel || (this.tabBar && this.tabBar.titles.length < 2)) {
return h.div({ className: 'p-TabBar-tabLabel', style }, data.title.label);
}
const originalToDisplayedMap = this.findDuplicateLabels([...this.tabBar!.titles]);
const labelDetails: string | undefined = originalToDisplayedMap.get(data.title.caption);
if (labelDetails) {
return h.div({ className: 'p-TabBar-tabLabelWrapper' },
h.div({ className: 'p-TabBar-tabLabel', style }, data.title.label),
h.div({ className: 'p-TabBar-tabLabelDetails', style }, labelDetails));
}
return h.div({ className: 'p-TabBar-tabLabel', style }, data.title.label);
}
renderBadge(data: SideBarRenderData, isInSidePanel?: boolean): VirtualElement {
const totalBadge = this.getDecorationData(data.title, 'badge').reduce((sum, badge) => sum! + badge!, 0);
if (!totalBadge) {
return h.div({});
}
const limitedBadge = totalBadge >= 100 ? '99+' : totalBadge;
return isInSidePanel
? h.div({ className: 'theia-badge-decorator-sidebar' }, `${limitedBadge}`)
: h.div({ className: 'theia-badge-decorator-horizontal' }, `${limitedBadge}`);
}
renderLock(data: SideBarRenderData, isInSidePanel?: boolean): VirtualElement {
return !isInSidePanel && data.title.className.includes(LOCKED_CLASS)
? h.div({ className: 'p-TabBar-tabLock' })
: h.div({});
}
protected readonly decorations = new Map<Title<Widget>, WidgetDecoration.Data[]>();
protected resetDecorations(title?: Title<Widget>): void {
if (title) {
this.decorations.delete(title);
} else {
this.decorations.clear();
}
if (this.tabBar) {
this.tabBar.update();
}
}
/**
* Get all available decorations of a given tab.
* @param {string} title The widget title.
*/
protected getDecorations(title: Title<Widget>): WidgetDecoration.Data[] {
if (this.tabBar && this.decoratorService) {
const owner: { resetTabBarDecorations?: () => void; } & Widget = title.owner;
if (!owner.resetTabBarDecorations) {
owner.resetTabBarDecorations = () => this.decorations.delete(title);
title.owner.disposed.connect(owner.resetTabBarDecorations);
}
const decorations = this.decorations.get(title) || this.decoratorService.getDecorations(title);
this.decorations.set(title, decorations);
return decorations;
}
return [];
}
/**
* Get the decoration data given the tab URI and the decoration data type.
* @param {string} title The title.
* @param {K} key The type of the decoration data.
*/
protected getDecorationData<K extends keyof WidgetDecoration.Data>(title: Title<Widget>, key: K): WidgetDecoration.Data[K][] {
return this.getDecorations(title).filter(data => data[key] !== undefined).map(data => data[key]);
}
/**
* Get the class of an icon.
* @param {string | string[]} iconName The name of the icon.
* @param {string[]} additionalClasses Additional classes of the icon.
*/
protected getIconClass(iconName: string | string[], additionalClasses: string[] = []): string {
const iconClass = (typeof iconName === 'string') ? ['a', 'fa', `fa-${iconName}`] : ['a'].concat(iconName);
return iconClass.concat(additionalClasses).join(' ');
}
/**
* Find duplicate labels from the currently opened tabs in the tab bar.
* Return the appropriate partial paths that can distinguish the identical labels.
*
* E.g., a/p/index.ts => a/..., b/p/index.ts => b/...
*
* To prevent excessively long path displayed, show at maximum three levels from the end by default.
* @param {Title<Widget>[]} titles Array of titles in the current tab bar.
* @returns {Map<string, string>} A map from each tab's original path to its displayed partial path.
*/
findDuplicateLabels(titles: Title<Widget>[]): Map<string, string> {
// Filter from all tabs to group them by the distinct label (file name).
// E.g., 'foo.js' => {0 (index) => 'a/b/foo.js', '2 => a/c/foo.js' },
// 'bar.js' => {1 => 'a/d/bar.js', ...}
const labelGroups = new Map<string, Map<number, string>>();
titles.forEach((title, index) => {
if (!labelGroups.has(title.label)) {
labelGroups.set(title.label, new Map<number, string>());
}
labelGroups.get(title.label)!.set(index, title.caption);
});
const originalToDisplayedMap = new Map<string, string>();
// Parse each group of editors with the same label.
labelGroups.forEach(labelGroup => {
// Filter to get groups that have duplicates.
if (labelGroup.size > 1) {
const paths: string[][] = [];
let maxPathLength = 0;
labelGroup.forEach((pathStr, index) => {
const steps = pathStr.split('/');
maxPathLength = Math.max(maxPathLength, steps.length);
paths[index] = (steps.slice(0, steps.length - 1));
// By default, show at maximum three levels from the end.
let defaultDisplayedPath = steps.slice(-4, -1).join('/');
if (steps.length > 4) {
defaultDisplayedPath = '.../' + defaultDisplayedPath;
}
originalToDisplayedMap.set(pathStr, defaultDisplayedPath);
});
// Iterate through the steps of the path from the left to find the step that can distinguish it.
// E.g., ['root', 'foo', 'c'], ['root', 'bar', 'd'] => 'foo', 'bar'
let i = 0;
while (i < maxPathLength - 1) {
// Store indexes of all paths that have the identical element in each step.
const stepOccurrences = new Map<string, number[]>();
// Compare the current step of all paths
paths.forEach((path, index) => {
const step = path[i];
if (path.length > 0) {
if (i > path.length - 1) {
paths[index] = [];
} else if (!stepOccurrences.has(step)) {
stepOccurrences.set(step, [index]);
} else {
stepOccurrences.get(step)!.push(index);
}
}
});
// Set the displayed path for each tab.
stepOccurrences.forEach((indexArr, displayedPath) => {
if (indexArr.length === 1) {
const originalPath = labelGroup.get(indexArr[0]);
if (originalPath) {
const originalElements = originalPath.split('/');
const displayedElements = displayedPath.split('/');
if (originalElements.slice(-2)[0] !== displayedElements.slice(-1)[0]) {
displayedPath += '/...';
}
if (originalElements[0] !== displayedElements[0]) {
displayedPath = '.../' + displayedPath;
}
originalToDisplayedMap.set(originalPath, displayedPath);
paths[indexArr[0]] = [];
}
}
});
i++;
}
}
});
return originalToDisplayedMap;
}
/**
* If size information is available for the icon, set it as inline style. Tab padding
* is also considered in the `top` position.
* @param {SideBarRenderData} data Data used to render the tab icon.
* @param {boolean} isInSidePanel An optional check which determines if the tab is in the side-panel.
*/
override renderIcon(data: SideBarRenderData, isInSidePanel?: boolean): VirtualElement {
if (!isInSidePanel && this.iconThemeService && this.iconThemeService.current === 'none') {
return h.div();
}
let top: string | undefined;
if (data.paddingTop) {
top = `${data.paddingTop || 0}px`;
}
const style: ElementInlineStyle = { top };
const baseClassName = this.createIconClass(data);
const overlayIcons: VirtualElement[] = [];
const decorationData = this.getDecorationData(data.title, 'iconOverlay');
// Check if the tab has decoration markers to be rendered on top.
if (decorationData.length > 0) {
const baseIcon: VirtualElement = h.div({ className: baseClassName, style }, data.title.iconLabel);
const wrapperClassName: string = WidgetDecoration.Styles.ICON_WRAPPER_CLASS;
const decoratorSizeClassName: string = isInSidePanel ? WidgetDecoration.Styles.DECORATOR_SIDEBAR_SIZE_CLASS : WidgetDecoration.Styles.DECORATOR_SIZE_CLASS;
decorationData
.filter(notEmpty)
.map(overlay => [overlay.position, overlay] as [WidgetDecoration.IconOverlayPosition, WidgetDecoration.IconOverlay | WidgetDecoration.IconClassOverlay])
.forEach(([position, overlay]) => {
const iconAdditionalClasses: string[] = [decoratorSizeClassName, WidgetDecoration.IconOverlayPosition.getStyle(position, isInSidePanel)];
const overlayIconStyle = (color?: string) => {
if (color === undefined) {
return {};
}
return { color };
};
// Parse the optional background (if it exists) of the overlay icon.
if (overlay.background) {
const backgroundIconClassName = this.getIconClass(overlay.background.shape, iconAdditionalClasses);
overlayIcons.push(
h.div({ key: data.title.label + '-background', className: backgroundIconClassName, style: overlayIconStyle(overlay.background.color) })
);
}
// Parse the overlay icon.
const overlayIcon = (overlay as WidgetDecoration.IconOverlay).icon || (overlay as WidgetDecoration.IconClassOverlay).iconClass;
const overlayIconClassName = this.getIconClass(overlayIcon, iconAdditionalClasses);
overlayIcons.push(
h.span({ key: data.title.label, className: overlayIconClassName, style: overlayIconStyle(overlay.color) })
);
});
return h.div({ className: wrapperClassName, style }, [baseIcon, ...overlayIcons]);
}
return h.div({ className: baseClassName, style }, data.title.iconLabel);
}
protected renderEnhancedPreview = (title: Title<Widget>) => {
const hoverBox = document.createElement('div');
hoverBox.classList.add('theia-horizontal-tabBar-hover-div');
const labelElement = document.createElement('p');
labelElement.classList.add('theia-horizontal-tabBar-hover-title');
labelElement.textContent = title.label;
hoverBox.append(labelElement);
const widget = title.owner;
if (EnhancedPreviewWidget.is(widget)) {
const enhancedPreviewNode = widget.getEnhancedPreviewNode();
if (enhancedPreviewNode) {
hoverBox.appendChild(enhancedPreviewNode);
}
} else if (title.caption) {
const captionElement = document.createElement('p');
captionElement.classList.add('theia-horizontal-tabBar-hover-caption');
captionElement.textContent = title.caption;
hoverBox.appendChild(captionElement);
}
return hoverBox;
};
protected renderVisualPreview(desiredWidth: number, title: Title<Widget>): HTMLElement | undefined {
const widget = title.owner;
// Check that the widget is not currently shown, is a PreviewableWidget and it was already loaded before
if (this.tabBar && this.tabBar.currentTitle !== title && PreviewableWidget.isPreviewable(widget)) {
const html = document.getElementById(widget.id);
if (html) {
const previewNode: Node | undefined = widget.getPreviewNode();
if (previewNode) {
const clonedNode = previewNode.cloneNode(true);
const visualPreviewDiv = document.createElement('div');
visualPreviewDiv.classList.add('enhanced-preview-container');
// Add the clonedNode and get it from the children to have a HTMLElement instead of a Node
visualPreviewDiv.append(clonedNode);
const visualPreview = visualPreviewDiv.children.item(visualPreviewDiv.children.length - 1);
if (visualPreview instanceof HTMLElement) {
visualPreview.classList.remove('p-mod-hidden');
visualPreview.classList.add('enhanced-preview');
visualPreview.id = `preview:${widget.id}`;
// Use the current visible editor as a fallback if not available
const height: number = visualPreview.style.height === '' ? this.tabBar.currentTitle!.owner.node.offsetHeight : parseFloat(visualPreview.style.height);
const width: number = visualPreview.style.width === '' ? this.tabBar.currentTitle!.owner.node.offsetWidth : parseFloat(visualPreview.style.width);
const desiredRatio = 9 / 16;
const desiredHeight = desiredWidth * desiredRatio;
const ratio = height / width;
visualPreviewDiv.style.width = `${desiredWidth}px`;
visualPreviewDiv.style.height = `${desiredHeight}px`;
// If the view is wider than the desiredRatio scale the width and crop the height. If the view is longer its the other way around.
const scale = ratio < desiredRatio ? (desiredHeight / height) : (desiredWidth / width);
visualPreview.style.transform = `scale(${scale},${scale})`;
visualPreview.style.removeProperty('top');
visualPreview.style.removeProperty('left');
// Copy canvases (They are cloned empty)
const originalCanvases = html.getElementsByTagName('canvas');
const previewCanvases = visualPreview.getElementsByTagName('canvas');
// If this is not given, something went wrong during the cloning
if (originalCanvases.length === previewCanvases.length) {
for (let i = 0; i < originalCanvases.length; i++) {
previewCanvases[i].getContext('2d')?.drawImage(originalCanvases[i], 0, 0);
}
}
return visualPreviewDiv;
}
}
}
}
return undefined;
}
protected handleMouseEnterEvent = (event: MouseEvent) => {
if (this.tabBar && this.hoverService && event.currentTarget instanceof HTMLElement) {
const id = event.currentTarget.id;
const title = this.tabBar.titles.find(t => this.createTabId(t) === id);
if (title) {
if (this.tabBar.orientation === 'horizontal') {
this.hoverService.requestHover({
content: this.renderEnhancedPreview(title),
target: event.currentTarget,
position: 'bottom',
cssClasses: ['extended-tab-preview'],
visualPreview: this.corePreferences?.['window.tabbar.enhancedPreview'] === 'visual' ? width => this.renderVisualPreview(width, title) : undefined
});
} else {
this.hoverService.requestHover({
content: title.caption,
target: event.currentTarget,
position: 'right'
});
}
}
}
};
protected handleContextMenuEvent = (event: MouseEvent) => {
if (this.contextMenuRenderer && this.contextMenuPath && event.currentTarget instanceof HTMLElement) {
event.stopPropagation();
event.preventDefault();
let widget: Widget | undefined = undefined;
if (this.tabBar) {
const titleIndex = Array.from(this.tabBar.contentNode.getElementsByClassName('p-TabBar-tab'))
.findIndex(node => node.contains(event.currentTarget as HTMLElement));
if (titleIndex !== -1) {
widget = this.tabBar.titles[titleIndex].owner;
}
}
const oldSelection = this.selectionService?.selection;
if (widget && this.selectionService) {
this.selectionService.selection = NavigatableWidget.is(widget) ? { uri: widget.getResourceUri() } : widget;
}
this.contextMenuRenderer.render({
menuPath: this.contextMenuPath!,
anchor: event,
args: [event],
// We'd like to wait until the command triggered by the context menu has been run, but this should let it get through the preamble, at least.
onHide: () => setTimeout(() => { if (this.selectionService) { this.selectionService.selection = oldSelection; } })
});
}
};
protected handleCloseClickEvent = (event: MouseEvent) => {
if (this.tabBar && event.currentTarget instanceof HTMLElement) {
const id = event.currentTarget.parentElement!.id;
const title = this.tabBar.titles.find(t => this.createTabId(t) === id);
if (title?.closable === false && title?.className.includes(PINNED_CLASS) && this.commandService) {
this.commandService.executeCommand('workbench.action.unpinEditor', event);
}
}
};
protected handleDblClickEvent = (event: MouseEvent) => {
if (!this.corePreferences?.get('workbench.tab.maximize')) {
return;
}
if (this.tabBar && event.currentTarget instanceof HTMLElement) {
const id = event.currentTarget.id;
const title = this.tabBar.titles.find(t => this.createTabId(t) === id);
const area = title?.owner.parent;
if (area instanceof TheiaDockPanel && (area.id === BOTTOM_AREA_ID || area.id === MAIN_AREA_ID)) {
area.toggleMaximized();
}
}
};
}
/**
* A specialized tab bar for the main and bottom areas.
*/
export class ScrollableTabBar extends TabBar<Widget> {
protected scrollBar?: PerfectScrollbar;
protected scrollBarFactory: () => PerfectScrollbar;
protected pendingReveal?: Promise<void>;
protected isMouseOver = false;
protected needsRecompute = false;
protected tabSize = 0;
protected _dynamicTabOptions?: ScrollableTabBar.Options;
protected contentContainer: HTMLElement;
protected topRow: HTMLElement;
protected readonly toDispose = new DisposableCollection();
protected openTabsContainer: HTMLDivElement;
protected openTabsRoot: Root;
constructor(options?: TabBar.IOptions<Widget> & 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) {
this._dynamicTabOptions = options;
this.updateTabs();
}
get dynamicTabOptions(): ScrollableTabBar.Options | undefined {
return this._dynamicTabOptions;
}
override dispose(): void {
if (this.isDisposed) {
return;
}
super.dispose();
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.
*/
protected 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();
}
this.node.addEventListener('mouseenter', () => { this.isMouseOver = true; });
this.node.addEventListener('mouseleave', () => {
this.isMouseOver = false;
if (this.needsRecompute) {
this.updateTabs();
}
});
super.onAfterAttach(msg);
}
protected override onBeforeDetach(msg: Message): void {
super.onBeforeDetach(msg);
if (this.scrollBar) {
this.scrollBar.destroy();
this.scrollBar = undefined;
}
}
protected override onUpdateRequest(msg: Message): void {
this.updateTabs();
}
protected updateTabs(): void {
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 {
this.needsRecompute = false;
if (this.orientation === 'horizontal') {
let availableWidth = this.scrollbarHost.clientWidth;
let effectiveWidth = availableWidth;
if (!this.openTabsContainer.classList.contains('p-mod-hidden')) {
availableWidth += this.openTabsContainer.getBoundingClientRect().width;
}
if (this.dynamicTabOptions.minimumTabSize * this.titles.length <= availableWidth) {
effectiveWidth += this.openTabsContainer.getBoundingClientRect().width;
this.openTabsContainer.classList.add('p-mod-hidden');
} else {
this.openTabsContainer.classList.remove('p-mod-hidden');
}
this.tabSize = Math.max(Math.min(effectiveWidth / this.titles.length,
this.dynamicTabOptions.defaultTabSize), this.dynamicTabOptions.minimumTabSize);
}
}
this.node.classList.add('dynamic-tabs');
} else {
this.openTabsContainer.classList.add('p-mod-hidden');
this.node.classList.remove('dynamic-tabs');
}
for (let i = 0, n = this.titles.length; i < n; ++i) {
const title = this.titles[i];
const current = title === this.currentTitle;
const zIndex = current ? n : n - i - 1;
const renderData: ScrollableRenderData = { title: title, current: current, zIndex: zIndex };
if (this.dynamicTabOptions && this.orientation === 'horizontal') {
renderData.tabWidth = this.tabSize;
}
content[i] = this.renderer.renderTab(renderData);
}
VirtualDOM.render(content, this.contentNode);
if (this.scrollBar) {
if (!(this.dynamicTabOptions && this.isMouseOver)) {
this.scrollBar.update();
}
}
}
protected override onResize(msg: Widget.ResizeMessage): void {
super.onResize(msg);
if (this.dynamicTabOptions) {
this.updateTabs();
}
if (this.scrollBar) {
if (this.currentIndex >= 0) {
this.revealTab(this.currentIndex);
}
this.scrollBar.update();
}
}
/**
* Reveal the tab with the given index by moving the scroll bar if necessary.
*/
revealTab(index: number): Promise<void> {
if (this.pendingReveal) {
// A reveal has already been scheduled
return this.pendingReveal;
}
const result = new Promise<void>((resolve, reject) => {
// The tab might not have been created yet, so wait until the next frame
window.requestAnimationFrame(() => {
const tab = this.contentNode.children[index] as HTMLElement;
if (tab && this.isVisible) {
const parent = this.scrollbarHost;
if (this.orientation === 'horizontal') {
const scroll = parent.scrollLeft;
const left = tab.offsetLeft;
if (scroll > left) {
parent.scrollLeft = left;
} else {
const right = left + tab.clientWidth - parent.clientWidth;
if (scroll < right && tab.clientWidth < parent.clientWidth) {
parent.scrollLeft = right;
}
}
} else {
const scroll = parent.scrollTop;
const top = tab.offsetTop;
if (scroll > top) {
parent.scrollTop = top;
} else {
const bottom = top + tab.clientHeight - parent.clientHeight;
if (scroll < bottom && tab.clientHeight < parent.clientHeight) {
parent.scrollTop = bottom;
}
}
}
}
if (this.pendingReveal === result) {
this.pendingReveal = undefined;
}
resolve();
});
});
this.pendingReveal = result;
return result;
}
/**
* Overrides the `contentNode` property getter in PhosphorJS' TabBar.
*/
// @ts-expect-error TS2611 `TabBar<T>.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.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';
}
}
/**
* Specialized scrollable tab-bar which comes with toolbar support.
* Instead of the following DOM structure.
*
* +-------------------------+
* |[TAB_0][TAB_1][TAB_2][TAB|
* +-------------Scrollable--+
*
* There is a dedicated HTML element for toolbar which does **not** contained in the scrollable element.
*
* +-------------------------+-----------------+
* |[TAB_0][TAB_1][TAB_2][TAB| Toolbar |
* +-------------Scrollable--+-Non-Scrollable-+
*
*/
export class ToolbarAwareTabBar extends ScrollableTabBar {
protected toolbar: TabBarToolbar | undefined;
protected breadcrumbsContainer: HTMLElement;
protected readonly breadcrumbsRenderer: BreadcrumbsRenderer;
constructor(
protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry,
protected readonly tabBarToolbarFactory: () => TabBarToolbar,
protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory,
options?: TabBar.IOptions<Widget> & PerfectScrollbar.Options,
dynamicTabOptions?: ScrollableTabBar.Options
) {
super(options, dynamicTabOptions);
this.breadcrumbsRenderer = this.breadcrumbsRendererFactory();
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 => {
this.node.classList.toggle('theia-tabBar-multirow', active);
if (this.parent) {
MessageLoop.sendMessage(this.parent, new Message('fit-request'));
}
}));
this.node.classList.toggle('theia-tabBar-multirow', this.breadcrumbsRenderer.active);
const handler = () => this.updateBreadcrumbs();
this.currentChanged.connect(handler);
this.toDispose.push(Disposable.create(() => this.currentChanged.disconnect(handler)));
}
protected async updateBreadcrumbs(): Promise<void> {
const current = this.currentTitle?.owner;
const uri = NavigatableWidget.is(current) ? current.getResourceUri() : undefined;
await this.breadcrumbsRenderer.refresh(uri);
}
protected override onAfterAttach(msg: Message): void {
if (this.toolbar) {
if (this.toolbar.isAttached) {
Widget.detach(this.toolbar);
}
Widget.attach(this.toolbar, this.topRow);
if (this.breadcrumbsContainer) {
this.node.appendChild(this.breadcrumbsContainer);
}
this.updateBreadcrumbs();
}
super.onAfterAttach(msg);
}
protected override onBeforeDetach(msg: Message): void {
if (this.toolbar && this.toolbar.isAttached) {
Widget.detach(this.toolbar);
}
super.onBeforeDetach(msg);
}
protected override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
this.updateToolbar();
}
protected updateToolbar(): void {
if (!this.toolbar) {
return;
}
const widget = this.currentTitle?.owner ?? undefined;
this.toolbar.updateTarget(widget);
this.updateTabs();
}
override handleEvent(event: Event): void {
if (event instanceof MouseEvent) {
if (this.toolbar && this.toolbar.shouldHandleMouseEvent(event) || this.isOver(event, this.openTabsContainer)) {
// if the mouse event is over the toolbar part don't handle it.
return;
}
}
super.handleEvent(event);
}
protected isOver(event: Event, element: Element): boolean {
return element && event.target instanceof Element && element.contains(event.target);
}
/**
* 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.
*/
protected addBreadcrumbs(): void {
this.breadcrumbsContainer = document.createElement('div');
this.breadcrumbsContainer.classList.add('theia-tabBar-breadcrumb-row');
this.breadcrumbsContainer.appendChild(this.breadcrumbsRenderer.host);
this.node.appendChild(this.breadcrumbsContainer);
}
}
/**
* A specialized tab bar for side areas.
*/
export class SideTabBar extends ScrollableTabBar {
protected static readonly DRAG_THRESHOLD = 5;
/**
* Emitted when a tab is added to the tab bar.
*/
readonly tabAdded = new Signal<this, { title: Title<Widget> }>(this);
/**
* Side panels can be collapsed by clicking on the currently selected tab. This signal is
* emitted when the mouse is released on the selected tab without initiating a drag.
*/
readonly collapseRequested = new Signal<this, Title<Widget>>(this);
/**
* Emitted when the set of overflowing/hidden tabs changes.
*/
readonly tabsOverflowChanged = new Signal<this, { titles: Title<Widget>[], startIndex: number }>(this);
protected mouseData?: {
pressX: number,
pressY: number,
mouseDownTabIndex: number
};
protected tabsOverflowData?: {
titles: Title<Widget>[],
startIndex: number
};
protected _rowGap: number;
constructor(options?: TabBar.IOptions<Widget> & PerfectScrollbar.Options) {
super(options);
// Create the hidden content node (see `hiddenContentNode` for explanation)
const hiddenContent = document.createElement('ul');
hiddenContent.className = HIDDEN_CONTENT_CLASS;
this.node.appendChild(hiddenContent);
}
/**
* Tab bars of the left and right side panel are arranged vertically by rotating their labels.
* Rotation is realized with the CSS `transform` property, which disrupts the browser's ability
* to arrange the involved elements automatically. Therefore the elements are arranged explicitly
* by the TabBarRenderer using inline `height` and `top` styles. However, the size of labels
* must still be computed by the browser, so the rendering is performed in two steps: first the
* tab bar is rendered horizontally inside a _hidden content node_, then it is rendered again
* vertically inside the proper content node. After the first step, size information is gathered
* from all labels so it can be applied during the second step.
*/
get hiddenContentNode(): HTMLUListElement {
return this.node.getElementsByClassName(HIDDEN_CONTENT_CLASS)[0] as HTMLUListElement;
}
override insertTab(index: number, value: Title<Widget> | Title.IOptions<Widget>): Title<Widget> {
const result = super.insertTab(index, value);
this.tabAdded.emit({ title: result });
return result;
}
protected override onAfterAttach(msg: Message): void {
this.updateTabs();
this.node.addEventListener('p-dragenter', this);
this.node.addEventListener('p-dragover', this);
this.node.addEventListener('p-dragleave', this);
document.addEventListener('p-drop', this);
}
protected override onAfterDetach(msg: Message): void {
super.onAfterDetach(msg);
this.node.removeEventListener('p-dragenter', this);
this.node.removeEventListener('p-dragover', this);
this.node.removeEventListener('p-dragleave', this);
document.removeEventListener('p-drop', this);
}
protected override onUpdateRequest(msg: Message): void {
this.updateTabs();
}
protected override onResize(msg: Widget.ResizeMessage): void {
// Tabs need to be updated if there are already overflowing tabs or the current tabs don't fit
if (this.tabsOverflowData || this.node.clientHeight < this.content