diff --git a/CHANGELOG.md b/CHANGELOG.md index fc28aab082361..18a5cb905b259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## v1.18.0 - 9/30/2021 + +- [core, outline-view, file-system, workspace] added breadcrumbs contribution points and renderers to `core` and contributions to other packages. [#9920](https://github.com/eclipse-theia/theia/pull/9920) + +[Breaking Changes:](#breaking_changes_1.18.0) + +- [core] added `BreadcrumbsRendererFactory` to constructor arguments of `DockPanelRenderer` and `ToolbarAwareTabBar`. [#9920](https://github.com/eclipse-theia/theia/pull/9920) + ## v1.17.2 - 9/1/2021 [1.17.2 Milestone](https://github.com/eclipse-theia/theia/milestone/27) diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts index f3e08fe86865a..c5036bb4f8f86 100644 --- a/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts @@ -14,84 +14,88 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { inject, injectable, postConstruct } from '../../../shared/inversify'; +import { Emitter, Event } from '../../common'; import { Disposable, DisposableCollection } from '../../common/disposable'; -import { Breadcrumbs } from './breadcrumbs'; +import { Coordinate } from '../context-menu-renderer'; +import { RendererHost } from '../widgets/react-renderer'; +import { Styles } from './breadcrumbs-constants'; + +export interface BreadcrumbPopupContainerFactory { + (parent: HTMLElement, breadcrumbId: string, position: Coordinate): BreadcrumbPopupContainer; +} +export const BreadcrumbPopupContainerFactory = Symbol('BreadcrumbPopupContainerFactory'); + +export type BreadcrumbID = string; +export const BreadcrumbID = Symbol('BreadcrumbID'); /** * This class creates a popup container at the given position * so that contributions can attach their HTML elements - * as childs of `BreadcrumbPopupContainer#container`. + * as children of `BreadcrumbPopupContainer#container`. * * - `dispose()` is called on blur or on hit on escape */ +@injectable() export class BreadcrumbPopupContainer implements Disposable { + @inject(RendererHost) protected readonly parent: RendererHost; + @inject(BreadcrumbID) public readonly breadcrumbId: BreadcrumbID; + @inject(Coordinate) protected readonly position: Coordinate; - protected toDispose: DisposableCollection = new DisposableCollection(); + protected onDidDisposeEmitter = new Emitter(); + protected toDispose: DisposableCollection = new DisposableCollection(this.onDidDisposeEmitter); + get onDidDispose(): Event { + return this.onDidDisposeEmitter.event; + } + + protected _container: HTMLElement; + get container(): HTMLElement { + return this._container; + } - readonly container: HTMLElement; - public isOpen: boolean; + protected _isOpen: boolean; + get isOpen(): boolean { + return this._isOpen; + } - constructor( - protected readonly parent: HTMLElement, - public readonly breadcrumbId: string, - position: { x: number, y: number } - ) { - this.container = this.createPopupDiv(position); + @postConstruct() + protected init(): void { + this._container = this.createPopupDiv(this.position); document.addEventListener('keyup', this.escFunction); - this.container.focus(); - this.isOpen = true; + this._container.focus(); + this._isOpen = true; } - protected createPopupDiv(position: { x: number, y: number }): HTMLDivElement { + protected createPopupDiv(position: Coordinate): HTMLDivElement { const result = window.document.createElement('div'); - result.className = Breadcrumbs.Styles.BREADCRUMB_POPUP; + result.className = Styles.BREADCRUMB_POPUP; result.style.left = `${position.x}px`; result.style.top = `${position.y}px`; result.tabIndex = 0; - result.onblur = event => this.onBlur(event, this.breadcrumbId); + result.addEventListener('focusout', this.onFocusOut); this.parent.appendChild(result); return result; } - protected onBlur = (event: FocusEvent, breadcrumbId: string) => { - if (event.relatedTarget && event.relatedTarget instanceof HTMLElement) { - // event.relatedTarget is the element that has the focus after this popup looses the focus. - // If a breadcrumb was clicked the following holds the breadcrumb ID of the clicked breadcrumb. - const clickedBreadcrumbId = event.relatedTarget.getAttribute('data-breadcrumb-id'); - if (clickedBreadcrumbId && clickedBreadcrumbId === breadcrumbId) { - // This is a click on the breadcrumb that has openend this popup. - // We do not close this popup here but let the click event of the breadcrumb handle this instead - // because it needs to know that this popup is open to decide if it just closes this popup or - // also open a new popup. - return; - } - if (this.container.contains(event.relatedTarget)) { - // A child element gets focus. Set the focus to the container again. - // Otherwise the popup would not be closed when elements outside the popup get the focus. - // A popup content should not relay on getting a focus. - this.container.focus(); - return; - } + protected onFocusOut = (event: FocusEvent) => { + if (!(event.relatedTarget instanceof Element) || !this._container.contains(event.relatedTarget)) { + this.dispose(); } - this.dispose(); - } + }; protected escFunction = (event: KeyboardEvent) => { if (event.key === 'Escape' || event.key === 'Esc') { this.dispose(); } - } + }; dispose(): void { - this.toDispose.dispose(); - if (this.parent.contains(this.container)) { - this.parent.removeChild(this.container); + if (!this.toDispose.disposed) { + this.onDidDisposeEmitter.fire(); + this.toDispose.dispose(); + this._container.remove(); + this._isOpen = false; + document.removeEventListener('keyup', this.escFunction); } - this.isOpen = false; - document.removeEventListener('keyup', this.escFunction); - } - - addDisposable(disposable: Disposable | undefined): void { - if (disposable) { this.toDispose.push(disposable); } } } diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx b/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx index 6385e8c8f6244..aa863343ebc24 100644 --- a/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx @@ -16,23 +16,22 @@ import * as React from 'react'; import { injectable } from 'inversify'; -import { Breadcrumb } from './breadcrumb'; -import { Breadcrumbs } from './breadcrumbs'; +import { Breadcrumb, Styles } from './breadcrumbs-constants'; export const BreadcrumbRenderer = Symbol('BreadcrumbRenderer'); export interface BreadcrumbRenderer { /** * Renders the given breadcrumb. If `onClick` is given, it is called on breadcrumb click. */ - render(breadcrumb: Breadcrumb, onClick?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode; + render(breadcrumb: Breadcrumb, onMouseDown?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode; } @injectable() export class DefaultBreadcrumbRenderer implements BreadcrumbRenderer { - render(breadcrumb: Breadcrumb, onClick?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode { + render(breadcrumb: Breadcrumb, onMouseDown?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode { return
  • onClick && onClick(breadcrumb, event)} + className={Styles.BREADCRUMB_ITEM + (!onMouseDown ? '' : ' ' + Styles.BREADCRUMB_ITEM_HAS_POPUP)} + onMouseDown={event => onMouseDown && onMouseDown(breadcrumb, event)} tabIndex={0} data-breadcrumb-id={breadcrumb.id} > diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb.ts b/packages/core/src/browser/breadcrumbs/breadcrumb.ts deleted file mode 100644 index 5d549b251b519..0000000000000 --- a/packages/core/src/browser/breadcrumbs/breadcrumb.ts +++ /dev/null @@ -1,34 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2019 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 WITH Classpath-exception-2.0 - ********************************************************************************/ - -/** A single breadcrumb in the breadcrumbs bar. */ -export interface Breadcrumb { - - /** An ID of this breadcrumb that should be unique in the breadcrumbs bar. */ - readonly id: string - - /** The breadcrumb type. Should be the same as the contribution type `BreadcrumbsContribution#type`. */ - readonly type: symbol - - /** The text that will be rendered as label. */ - readonly label: string - - /** A longer text that will be used as tooltip text. */ - readonly longLabel: string - - /** A CSS class for the icon. */ - readonly iconClass?: string -} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-contribution.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts similarity index 52% rename from packages/core/src/browser/breadcrumbs/breadcrumbs-contribution.ts rename to packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts index 959fa63ce77f5..e77495723fbc1 100644 --- a/packages/core/src/browser/breadcrumbs/breadcrumbs-contribution.ts +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts @@ -14,9 +14,39 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { MaybePromise, Event } from '../../common'; +import { Disposable } from '../../../shared/vscode-languageserver-protocol'; import URI from '../../common/uri'; -import { Breadcrumb } from './breadcrumb'; -import { Disposable } from '../../common'; + +export namespace Styles { + export const BREADCRUMBS = 'theia-breadcrumbs'; + export const BREADCRUMB_ITEM = 'theia-breadcrumb-item'; + export const BREADCRUMB_POPUP_OVERLAY_CONTAINER = 'theia-breadcrumbs-popups-overlay'; + export const BREADCRUMB_POPUP = 'theia-breadcrumbs-popup'; + export const BREADCRUMB_ITEM_HAS_POPUP = 'theia-breadcrumb-item-haspopup'; +} + +/** A single breadcrumb in the breadcrumbs bar. */ +export interface Breadcrumb { + + /** An ID of this breadcrumb that should be unique in the breadcrumbs bar. */ + readonly id: string; + + /** The breadcrumb type. Should be the same as the contribution type `BreadcrumbsContribution#type`. */ + readonly type: symbol; + + /** The text that will be rendered as label. */ + readonly label: string; + + /** A longer text that will be used as tooltip text. */ + readonly longLabel: string; + + /** A CSS class for the icon. */ + readonly iconClass?: string; + + /** CSS classes for the container. */ + readonly containerClass?: string; +} export const BreadcrumbsContribution = Symbol('BreadcrumbsContribution'); export interface BreadcrumbsContribution { @@ -27,14 +57,19 @@ export interface BreadcrumbsContribution { readonly type: symbol; /** - * The priority of this breadcrumbs contribution. Contributions with lower priority are rendered first. + * The priority of this breadcrumbs contribution. Contributions are rendered left to right in order of ascending priority. */ readonly priority: number; + /** + * An event emitter that should fire when breadcrumbs change for a given URI. + */ + readonly onDidChangeBreadcrumbs: Event; + /** * Computes breadcrumbs for a given URI. */ - computeBreadcrumbs(uri: URI): Promise; + computeBreadcrumbs(uri: URI): MaybePromise; /** * Attaches the breadcrumb popup content for the given breadcrumb as child to the given parent. diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx b/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx index 198fb59f8f47d..30129fbc95e13 100644 --- a/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx @@ -17,17 +17,20 @@ import * as React from 'react'; import { injectable, inject, postConstruct } from 'inversify'; import { ReactRenderer } from '../widgets'; -import { Breadcrumb } from './breadcrumb'; -import { Breadcrumbs } from './breadcrumbs'; import { BreadcrumbsService } from './breadcrumbs-service'; import { BreadcrumbRenderer } from './breadcrumb-renderer'; import PerfectScrollbar from 'perfect-scrollbar'; import URI from '../../common/uri'; +import { Emitter, Event } from '../../common'; import { BreadcrumbPopupContainer } from './breadcrumb-popup-container'; import { DisposableCollection } from '../../common/disposable'; import { CorePreferences } from '../core-preferences'; +import { Breadcrumb, Styles } from './breadcrumbs-constants'; +import { LabelProvider } from '../label-provider'; -export const BreadcrumbsURI = Symbol('BreadcrumbsURI'); +interface Cancelable { + canceled: boolean; +} @injectable() export class BreadcrumbsRenderer extends ReactRenderer { @@ -41,22 +44,44 @@ export class BreadcrumbsRenderer extends ReactRenderer { @inject(CorePreferences) protected readonly corePreferences: CorePreferences; - private breadcrumbs: Breadcrumb[] = []; + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; - private popup: BreadcrumbPopupContainer | undefined; + protected readonly onDidChangeActiveStateEmitter = new Emitter(); + get onDidChangeActiveState(): Event { + return this.onDidChangeActiveStateEmitter.event; + } - private scrollbar: PerfectScrollbar | undefined; + protected uri: URI | undefined; + protected breadcrumbs: Breadcrumb[] = []; + protected popup: BreadcrumbPopupContainer | undefined; + protected scrollbar: PerfectScrollbar | undefined; + protected toDispose: DisposableCollection = new DisposableCollection(); - private toDispose: DisposableCollection = new DisposableCollection(); + get active(): boolean { + return !!this.breadcrumbs.length; + } + + protected get breadCrumbsContainer(): Element | undefined { + return this.host.firstElementChild ?? undefined; + } - constructor( - @inject(BreadcrumbsURI) readonly uri: URI - ) { super(); } + protected refreshCancellationMarker: Cancelable = { canceled: true }; @postConstruct() - init(): void { - this.toDispose.push(this.breadcrumbsService.onDidChangeBreadcrumbs(uri => { if (this.uri.toString() === uri.toString()) { this.refresh(); } })); - this.toDispose.push(this.corePreferences.onPreferenceChanged(_ => this.refresh())); + protected init(): void { + this.toDispose.push(this.onDidChangeActiveStateEmitter); + this.toDispose.push(this.breadcrumbsService.onDidChangeBreadcrumbs(uri => { + if (this.uri?.isEqual(uri)) { + this.refresh(this.uri); + } + })); + this.toDispose.push(this.corePreferences.onPreferenceChanged(change => { + if (change.preferenceName === 'breadcrumbs.enabled') { + this.refresh(this.uri); + } + })); + this.toDispose.push(this.labelProvider.onDidChange(() => this.refresh(this.uri))); } dispose(): void { @@ -69,38 +94,63 @@ export class BreadcrumbsRenderer extends ReactRenderer { } } - async refresh(): Promise { - if (this.corePreferences['breadcrumbs.enabled']) { - this.breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(this.uri); + async refresh(uri?: URI): Promise { + this.uri = uri; + this.refreshCancellationMarker.canceled = true; + const currentCallCanceled = { canceled: false }; + this.refreshCancellationMarker = currentCallCanceled; + let breadcrumbs: Breadcrumb[]; + if (uri && this.corePreferences['breadcrumbs.enabled']) { + breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(uri); } else { - this.breadcrumbs = []; + breadcrumbs = []; + } + if (currentCallCanceled.canceled) { + return; + } + + const wasActive = this.active; + this.breadcrumbs = breadcrumbs; + const isActive = this.active; + if (wasActive !== isActive) { + this.onDidChangeActiveStateEmitter.fire(isActive); } + + this.update(); + } + + protected update(): void { this.render(); if (!this.scrollbar) { - if (this.host.firstChild) { - this.scrollbar = new PerfectScrollbar(this.host.firstChild as HTMLElement, { - handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], - useBothWheelAxes: true, - scrollXMarginOffset: 4, - suppressScrollY: true - }); - } + this.createScrollbar(); } else { this.scrollbar.update(); } this.scrollToEnd(); } - private scrollToEnd(): void { - if (this.host.firstChild) { - const breadcrumbsHtmlElement = (this.host.firstChild as HTMLElement); - breadcrumbsHtmlElement.scrollLeft = breadcrumbsHtmlElement.scrollWidth; + protected createScrollbar(): void { + const { breadCrumbsContainer } = this; + if (breadCrumbsContainer) { + this.scrollbar = new PerfectScrollbar(breadCrumbsContainer, { + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollXMarginOffset: 4, + suppressScrollY: true + }); + } + } + + protected scrollToEnd(): void { + const { breadCrumbsContainer } = this; + if (breadCrumbsContainer) { + breadCrumbsContainer.scrollLeft = breadCrumbsContainer.scrollWidth; } } protected doRender(): React.ReactNode { - return
      {this.renderBreadcrumbs()}
    ; + return
      {this.renderBreadcrumbs()}
    ; } protected renderBreadcrumbs(): React.ReactNode { @@ -111,81 +161,27 @@ export class BreadcrumbsRenderer extends ReactRenderer { event.stopPropagation(); event.preventDefault(); let openPopup = true; - if (this.popup) { - if (this.popup.isOpen) { - this.popup.dispose(); + if (this.popup?.isOpen) { + this.popup.dispose(); - // There is a popup open. If the popup is the popup that belongs to the currently clicked breadcrumb - // just close the popup. When another breadcrumb was clicked open the new popup immediately. - openPopup = !(this.popup.breadcrumbId === breadcrumb.id); - } + // There is a popup open. If the popup is the popup that belongs to the currently clicked breadcrumb + // just close the popup. If another breadcrumb was clicked, open the new popup immediately. + openPopup = this.popup.breadcrumbId !== breadcrumb.id; + } else { this.popup = undefined; } if (openPopup) { - if (event.nativeEvent.target && event.nativeEvent.target instanceof HTMLElement) { - const breadcrumbsHtmlElement = BreadcrumbsRenderer.findParentBreadcrumbsHtmlElement(event.nativeEvent.target as HTMLElement); - if (breadcrumbsHtmlElement && breadcrumbsHtmlElement.parentElement && breadcrumbsHtmlElement.parentElement.lastElementChild) { - const position: { x: number, y: number } = BreadcrumbsRenderer.determinePopupAnchor(event.nativeEvent) || event.nativeEvent; - this.breadcrumbsService.openPopup(breadcrumb, position).then(popup => { this.popup = popup; }); - } + const { currentTarget } = event; + const breadcrumbElement = currentTarget.closest(`.${Styles.BREADCRUMB_ITEM}`); + if (breadcrumbElement) { + const { left: x, bottom: y } = breadcrumbElement.getBoundingClientRect(); + this.breadcrumbsService.openPopup(breadcrumb, { x, y }).then(popup => { this.popup = popup; }); } } - } -} - -export namespace BreadcrumbsRenderer { - - /** - * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element - * that has the CSS class `Breadcrumbs.Styles.BREADCRUMB_ITEM`. - */ - export function findParentItemHtmlElement(child: HTMLElement): HTMLElement | undefined { - return findParentHtmlElement(child, Breadcrumbs.Styles.BREADCRUMB_ITEM); - } - - /** - * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element - * that has the CSS class `Breadcrumbs.Styles.BREADCRUMBS`. - */ - export function findParentBreadcrumbsHtmlElement(child: HTMLElement): HTMLElement | undefined { - return findParentHtmlElement(child, Breadcrumbs.Styles.BREADCRUMBS); - } - - /** - * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element - * that has the given CSS class. - */ - export function findParentHtmlElement(child: HTMLElement, cssClass: string): HTMLElement | undefined { - if (child.classList.contains(cssClass)) { - return child; - } else { - if (child.parentElement !== null) { - return findParentHtmlElement(child.parentElement, cssClass); - } - } - } - - /** - * Determines the popup anchor for the given mouse event. - * - * It finds the parent HTML element with CSS class `Breadcrumbs.Styles.BREADCRUMB_ITEM` of event's target element - * and return the bottom left corner of this element. - */ - export function determinePopupAnchor(event: MouseEvent): { x: number, y: number } | undefined { - if (event.target === null || !(event.target instanceof HTMLElement)) { - return undefined; - } - const itemHtmlElement = findParentItemHtmlElement(event.target); - if (itemHtmlElement) { - return { - x: itemHtmlElement.getBoundingClientRect().left, - y: itemHtmlElement.getBoundingClientRect().bottom - }; - } - } + }; } export const BreadcrumbsRendererFactory = Symbol('BreadcrumbsRendererFactory'); export interface BreadcrumbsRendererFactory { - (uri: URI): BreadcrumbsRenderer; + (): BreadcrumbsRenderer; } diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts index 63cd18ee39483..d4b527a28437c 100644 --- a/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts @@ -17,10 +17,9 @@ import { inject, injectable, named, postConstruct } from 'inversify'; import { ContributionProvider, Prioritizeable, Emitter, Event } from '../../common'; import URI from '../../common/uri'; -import { Breadcrumb } from './breadcrumb'; -import { BreadcrumbPopupContainer } from './breadcrumb-popup-container'; -import { BreadcrumbsContribution } from './breadcrumbs-contribution'; -import { Breadcrumbs } from './breadcrumbs'; +import { Coordinate } from '../context-menu-renderer'; +import { BreadcrumbPopupContainer, BreadcrumbPopupContainerFactory } from './breadcrumb-popup-container'; +import { BreadcrumbsContribution, Styles, Breadcrumb } from './breadcrumbs-constants'; @injectable() export class BreadcrumbsService { @@ -28,6 +27,10 @@ export class BreadcrumbsService { @inject(ContributionProvider) @named(BreadcrumbsContribution) protected readonly contributions: ContributionProvider; + @inject(BreadcrumbPopupContainerFactory) protected readonly breadcrumbPopupContainerFactory: BreadcrumbPopupContainerFactory; + + protected hasSubscribed = false; + protected popupsOverlayContainer: HTMLDivElement; protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); @@ -39,26 +42,34 @@ export class BreadcrumbsService { protected createOverlayContainer(): void { this.popupsOverlayContainer = window.document.createElement('div'); - this.popupsOverlayContainer.id = Breadcrumbs.Styles.BREADCRUMB_POPUP_OVERLAY_CONTAINER; + this.popupsOverlayContainer.id = Styles.BREADCRUMB_POPUP_OVERLAY_CONTAINER; if (window.document.body) { window.document.body.appendChild(this.popupsOverlayContainer); } } /** - * Subscribe to this event emitter to be notifed when the breadcrumbs have changed. + * Subscribe to this event emitter to be notified when the breadcrumbs have changed. * The URI is the URI of the editor the breadcrumbs have changed for. */ get onDidChangeBreadcrumbs(): Event { + // This lazy subscription is to address problems in inversify's instantiation routine + // related to use of the IconThemeService by different components instantiated by the + // ContributionProvider. + if (!this.hasSubscribed) { + this.subscribeToContributions(); + } return this.onDidChangeBreadcrumbsEmitter.event; } /** - * Notifies that the breadcrumbs for the given URI have changed and should be re-rendered. - * This fires an `onBreadcrumsChange` event. + * Subscribes to the onDidChangeBreadcrumbs events for all contributions. */ - breadcrumbsChanges(uri: URI): void { - this.onDidChangeBreadcrumbsEmitter.fire(uri); + protected subscribeToContributions(): void { + this.hasSubscribed = true; + for (const contribution of this.contributions.getContributions()) { + contribution.onDidChangeBreadcrumbs(uri => this.onDidChangeBreadcrumbsEmitter.fire(uri)); + } } /** @@ -81,11 +92,16 @@ export class BreadcrumbsService { /** * Opens a popup for the given breadcrumb at the given position. */ - async openPopup(breadcrumb: Breadcrumb, position: { x: number, y: number }): Promise { + async openPopup(breadcrumb: Breadcrumb, position: Coordinate): Promise { const contribution = this.contributions.getContributions().find(c => c.type === breadcrumb.type); if (contribution) { - const popup = new BreadcrumbPopupContainer(this.popupsOverlayContainer, breadcrumb.id, position); - popup.addDisposable(await contribution.attachPopupContent(breadcrumb, popup.container)); + const popup = this.breadcrumbPopupContainerFactory(this.popupsOverlayContainer, breadcrumb.id, position); + const popupContent = await contribution.attachPopupContent(breadcrumb, popup.container); + if (popupContent && popup.isOpen) { + popup.onDidDispose(() => popupContent.dispose()); + } else { + popupContent?.dispose(); + } return popup; } } diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs.ts deleted file mode 100644 index afc1c1e84a0b3..0000000000000 --- a/packages/core/src/browser/breadcrumbs/breadcrumbs.ts +++ /dev/null @@ -1,25 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2019 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 WITH Classpath-exception-2.0 - ********************************************************************************/ - -export namespace Breadcrumbs { - export namespace Styles { - export const BREADCRUMBS = 'theia-breadcrumbs'; - export const BREADCRUMB_ITEM = 'theia-breadcrumb-item'; - export const BREADCRUMB_POPUP_OVERLAY_CONTAINER = 'theia-breadcrumbs-popups-overlay'; - export const BREADCRUMB_POPUP = 'theia-breadcrumbs-popup'; - export const BREADCRUMB_ITEM_HAS_POPUP = 'theia-breadcrumb-item-haspopup'; - } -} diff --git a/packages/core/src/browser/breadcrumbs/index.ts b/packages/core/src/browser/breadcrumbs/index.ts index 88a71e8df3d44..876c2a406e7a6 100644 --- a/packages/core/src/browser/breadcrumbs/index.ts +++ b/packages/core/src/browser/breadcrumbs/index.ts @@ -16,8 +16,6 @@ export * from './breadcrumb-popup-container'; export * from './breadcrumb-renderer'; -export * from './breadcrumb'; -export * from './breadcrumbs-contribution'; export * from './breadcrumbs-renderer'; export * from './breadcrumbs-service'; -export * from './breadcrumbs'; +export * from './breadcrumbs-constants'; diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 0afbb5e5f7d2c..54743fe588b7d 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -1921,6 +1921,51 @@ export class CommonFrontendContribution implements FrontendApplicationContributi light: '#c5c5c5', hc: '#c5c5c5' }, description: 'Editor gutter decoration color for commenting ranges.' + }, + { + id: 'breadcrumb.foreground', + defaults: { + dark: Color.transparent('foreground', 0.8), + light: Color.transparent('foreground', 0.8), + hc: Color.transparent('foreground', 0.8), + }, + description: 'Color of breadcrumb item text' + }, + { + id: 'breadcrumb.background', + defaults: { + dark: 'editor.background', + light: 'editor.background', + hc: 'editor.background', + }, + description: 'Color of breadcrumb item background' + }, + { + id: 'breadcrumb.focusForeground', + defaults: { + dark: Color.lighten('foreground', 0.1), + light: Color.darken('foreground', 0.2), + hc: Color.lighten('foreground', 0.1), + }, + description: 'Color of breadcrumb item text when focused' + }, + { + id: 'breadcrumb.activeSelectionForeground', + defaults: { + dark: Color.lighten('foreground', 0.1), + light: Color.darken('foreground', 0.2), + hc: Color.lighten('foreground', 0.1), + }, + description: 'Color of selected breadcrumb item' + }, + { + id: 'breadcrumbPicker.background', + defaults: { + dark: 'editorWidget.background', + light: 'editorWidget.background', + hc: 'editorWidget.background', + }, + description: 'Background color of breadcrumb item picker' } ); } diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index b3f1f265683e3..59c0a43365fc7 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -20,12 +20,20 @@ import { injectable } from 'inversify'; import { MenuPath } from '../common/menu'; import { Disposable, DisposableCollection } from '../common/disposable'; -export type Anchor = MouseEvent | { x: number, y: number }; +export interface Coordinate { x: number; y: number; } +export const Coordinate = Symbol('Coordinate'); -export function toAnchor(anchor: HTMLElement | { x: number, y: number }): Anchor { +export type Anchor = MouseEvent | Coordinate; + +export function toAnchor(anchor: HTMLElement | Coordinate): Anchor { return anchor instanceof HTMLElement ? { x: anchor.offsetLeft, y: anchor.offsetTop } : anchor; } +export function coordinateFromAnchor(anchor: Anchor): Coordinate { + const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor; + return { x, y }; +} + export abstract class ContextMenuAccess implements Disposable { protected readonly toDispose = new DisposableCollection(); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 3b667c1c7698d..e4d3ab0381215 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -50,7 +50,7 @@ import { StatusBar, StatusBarImpl } from './status-bar/status-bar'; import { LabelParser } from './label-parser'; import { LabelProvider, LabelProviderContribution, DefaultUriLabelProviderContribution } from './label-provider'; import { PreferenceService } from './preferences'; -import { ContextMenuRenderer } from './context-menu-renderer'; +import { ContextMenuRenderer, Coordinate } from './context-menu-renderer'; import { ThemeService } from './theming'; import { ConnectionStatusService, FrontendConnectionStatusService, ApplicationConnectionStatusContribution, PingService } from './connection-status-service'; import { DiffUriLabelProviderContribution } from './diff-uris'; @@ -96,17 +96,28 @@ import { keytarServicePath, KeytarService } from '../common/keytar-protocol'; import { CredentialsService, CredentialsServiceImpl } from './credentials-service'; import { ContributionFilterRegistry, ContributionFilterRegistryImpl } from '../common/contribution-filter'; import { QuickCommandFrontendContribution } from './quick-input/quick-command-frontend-contribution'; -import { QuickHelpService } from './quick-input/quick-help-service'; import { QuickPickService, quickPickServicePath } from '../common/quick-pick-service'; import { QuickPickServiceImpl, - QuickInputFrontendContribution + QuickInputFrontendContribution, + QuickAccessContribution, + QuickCommandService, + QuickHelpService } from './quick-input'; -import { QuickAccessContribution } from './quick-input/quick-access'; -import { QuickCommandService } from './quick-input/quick-command-service'; import { SidebarBottomMenuWidget } from './shell/sidebar-bottom-menu-widget'; import { WindowContribution } from './window-contribution'; -import { BreadcrumbsContribution, BreadcrumbsService } from './breadcrumbs'; +import { + BreadcrumbID, + BreadcrumbPopupContainer, + BreadcrumbPopupContainerFactory, + BreadcrumbRenderer, + BreadcrumbsContribution, + BreadcrumbsRenderer, + BreadcrumbsRendererFactory, + BreadcrumbsService, + DefaultBreadcrumbRenderer, +} from './breadcrumbs'; +import { RendererHost } from './widgets'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -151,13 +162,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo return container.get(TabBarToolbar); }); - bind(DockPanelRendererFactory).toFactory(context => () => { - const { container } = context; - const tabBarToolbarRegistry = container.get(TabBarToolbarRegistry); - const tabBarRendererFactory: () => TabBarRenderer = container.get(TabBarRendererFactory); - const tabBarToolbarFactory: () => TabBarToolbar = container.get(TabBarToolbarFactory); - return new DockPanelRenderer(tabBarRendererFactory, tabBarToolbarRegistry, tabBarToolbarFactory); - }); + bind(DockPanelRendererFactory).toFactory(context => () => context.container.get(DockPanelRenderer)); bind(DockPanelRenderer).toSelf(); bind(TabBarRendererFactory).toFactory(context => () => { const contextMenuRenderer = context.container.get(ContextMenuRenderer); @@ -364,4 +369,20 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo } bindContributionProvider(bind, BreadcrumbsContribution); bind(BreadcrumbsService).toSelf().inSingletonScope(); + bind(BreadcrumbsRenderer).toSelf(); + bind(BreadcrumbsRendererFactory).toFactory(ctx => + () => { + const childContainer = ctx.container.createChild(); + childContainer.bind(BreadcrumbRenderer).to(DefaultBreadcrumbRenderer).inSingletonScope(); + return childContainer.get(BreadcrumbsRenderer); + } + ); + bind(BreadcrumbPopupContainer).toSelf(); + bind(BreadcrumbPopupContainerFactory).toFactory(({ container }) => (parent: HTMLElement, breadcrumbId: string, position: Coordinate): BreadcrumbPopupContainer => { + const child = container.createChild(); + child.bind(RendererHost).toConstantValue(parent); + child.bind(BreadcrumbID).toConstantValue(breadcrumbId); + child.bind(Coordinate).toConstantValue(position); + return child.get(BreadcrumbPopupContainer); + }); }); diff --git a/packages/core/src/browser/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index e0d6a38b79149..2b664b75f0238 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -18,7 +18,7 @@ import { inject, injectable } from 'inversify'; import { Menu } from '../widgets'; -import { ContextMenuAccess, ContextMenuRenderer, RenderContextMenuOptions } from '../context-menu-renderer'; +import { ContextMenuAccess, ContextMenuRenderer, coordinateFromAnchor, RenderContextMenuOptions } from '../context-menu-renderer'; import { BrowserMainMenuFactory } from './browser-menu-plugin'; export class BrowserContextMenuAccess extends ContextMenuAccess { @@ -38,7 +38,7 @@ export class BrowserContextMenuRenderer extends ContextMenuRenderer { protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): BrowserContextMenuAccess { const contextMenu = this.menuFactory.createContextMenu(menuPath, args); - const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor!; + const { x, y } = coordinateFromAnchor(anchor); if (onHide) { contextMenu.aboutToClose.connect(() => onHide!()); } diff --git a/packages/core/src/browser/navigatable-types.ts b/packages/core/src/browser/navigatable-types.ts new file mode 100644 index 0000000000000..ae0a381627b21 --- /dev/null +++ b/packages/core/src/browser/navigatable-types.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import URI from '../common/uri'; +import { MaybeArray } from '../common/types'; +import { Widget, BaseWidget } from './widgets'; + +/** + * `Navigatable` provides an access to an URI of an underlying instance of `Resource`. + */ +export interface Navigatable { + /** + * Return an underlying resource URI. + */ + getResourceUri(): URI | undefined; + /** + * Creates a new URI to which this navigatable should moved based on the given target resource URI. + */ + createMoveToUri(resourceUri: URI): URI | undefined; +} + +export namespace Navigatable { + export function is(arg: Object | undefined): arg is Navigatable { + return !!arg && 'getResourceUri' in arg && 'createMoveToUri' in arg; + } +} + +export type NavigatableWidget = BaseWidget & Navigatable; +export namespace NavigatableWidget { + export function is(arg: Object | undefined): arg is NavigatableWidget { + return arg instanceof BaseWidget && Navigatable.is(arg); + } + export function* getAffected( + widgets: Iterable, + context: MaybeArray + ): IterableIterator<[URI, T & NavigatableWidget]> { + const uris = Array.isArray(context) ? context : [context]; + return get(widgets, resourceUri => uris.some(uri => uri.isEqualOrParent(resourceUri))); + } + export function* get( + widgets: Iterable, + filter: (resourceUri: URI) => boolean = () => true + ): IterableIterator<[URI, T & NavigatableWidget]> { + for (const widget of widgets) { + if (NavigatableWidget.is(widget)) { + const resourceUri = widget.getResourceUri(); + if (resourceUri && filter(resourceUri)) { + yield [resourceUri, widget]; + } + } + } + } +} + +export interface NavigatableWidgetOptions { + kind: 'navigatable', + uri: string, + counter?: number, +} +export namespace NavigatableWidgetOptions { + export function is(arg: Object | undefined): arg is NavigatableWidgetOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return !!arg && 'kind' in arg && (arg as any).kind === 'navigatable'; + } +} diff --git a/packages/core/src/browser/navigatable.ts b/packages/core/src/browser/navigatable.ts index 9429dc76ee007..9206549f2b7d3 100644 --- a/packages/core/src/browser/navigatable.ts +++ b/packages/core/src/browser/navigatable.ts @@ -15,68 +15,9 @@ ********************************************************************************/ import URI from '../common/uri'; -import { MaybeArray } from '../common/types'; -import { Widget, BaseWidget } from './widgets'; import { WidgetOpenHandler, WidgetOpenerOptions } from './widget-open-handler'; - -/** - * `Navigatable` provides an access to an URI of an underlying instance of `Resource`. - */ -export interface Navigatable { - /** - * Return an underlying resource URI. - */ - getResourceUri(): URI | undefined; - /** - * Creates a new URI to which this navigatable should moved based on the given target resource URI. - */ - createMoveToUri(resourceUri: URI): URI | undefined; -} - -export namespace Navigatable { - export function is(arg: Object | undefined): arg is Navigatable { - return !!arg && 'getResourceUri' in arg && 'createMoveToUri' in arg; - } -} - -export type NavigatableWidget = BaseWidget & Navigatable; -export namespace NavigatableWidget { - export function is(arg: Object | undefined): arg is NavigatableWidget { - return arg instanceof BaseWidget && Navigatable.is(arg); - } - export function* getAffected( - widgets: Iterable, - context: MaybeArray - ): IterableIterator<[URI, T & NavigatableWidget]> { - const uris = Array.isArray(context) ? context : [context]; - return get(widgets, resourceUri => uris.some(uri => uri.isEqualOrParent(resourceUri))); - } - export function* get( - widgets: Iterable, - filter: (resourceUri: URI) => boolean = () => true - ): IterableIterator<[URI, T & NavigatableWidget]> { - for (const widget of widgets) { - if (NavigatableWidget.is(widget)) { - const resourceUri = widget.getResourceUri(); - if (resourceUri && filter(resourceUri)) { - yield [resourceUri, widget]; - } - } - } - } -} - -export interface NavigatableWidgetOptions { - kind: 'navigatable', - uri: string, - counter?: number, -} -export namespace NavigatableWidgetOptions { - export function is(arg: Object | undefined): arg is NavigatableWidgetOptions { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return !!arg && 'kind' in arg && (arg as any).kind === 'navigatable'; - } -} +import { NavigatableWidget, NavigatableWidgetOptions } from './navigatable-types'; +export * from './navigatable-types'; export abstract class NavigatableWidgetOpenHandler extends WidgetOpenHandler { diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 0732c39415eba..ff0026676d375 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -38,6 +38,7 @@ import { Emitter } from '../../common/event'; import { waitForRevealed, waitForClosed } from '../widgets'; import { CorePreferences } from '../core-preferences'; import { environment } from '../../common'; +import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -80,19 +81,24 @@ export class DockPanelRenderer implements DockLayout.IRenderer { constructor( @inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: () => TabBarRenderer, @inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, - @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: () => TabBarToolbar + @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: () => TabBarToolbar, + @inject(BreadcrumbsRendererFactory) protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory, ) { } createTabBar(): TabBar { const renderer = this.tabBarRendererFactory(); - const tabBar = new ToolbarAwareTabBar(this.tabBarToolbarRegistry, this.tabBarToolbarFactory, { - renderer, - // Scroll bar options - handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], - useBothWheelAxes: true, - scrollXMarginOffset: 4, - suppressScrollY: true - }); + const tabBar = new ToolbarAwareTabBar( + this.tabBarToolbarRegistry, + this.tabBarToolbarFactory, + this.breadcrumbsRendererFactory, + { + renderer, + // Scroll bar options + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollXMarginOffset: 4, + suppressScrollY: true + }); this.tabBarClasses.forEach(c => tabBar.addClass(c)); renderer.tabBar = tabBar; tabBar.disposed.connect(() => renderer.dispose()); diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index d7ee97c36c8c0..65768bbad632a 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -20,7 +20,7 @@ import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/vir import { Disposable, DisposableCollection, MenuPath, notEmpty } from '../../common'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { Signal, Slot } from '@phosphor/signaling'; -import { Message } from '@phosphor/messaging'; +import { Message, MessageLoop } from '@phosphor/messaging'; import { ArrayExt } from '@phosphor/algorithm'; import { ElementExt } from '@phosphor/domutils'; import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar'; @@ -28,6 +28,8 @@ 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'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -576,22 +578,38 @@ export class ScrollableTabBar extends TabBar { * * +-------------------------+-----------------+ * |[TAB_0][TAB_1][TAB_2][TAB| Toolbar | - * +-------------Scrollable--+-None-Scrollable-+ + * +-------------Scrollable--+-Non-Scrollable-+ * */ export class ToolbarAwareTabBar extends ScrollableTabBar { - protected contentContainer: HTMLElement | undefined; + protected contentContainer: HTMLElement; protected toolbar: TabBarToolbar | undefined; + protected breadcrumbsContainer: HTMLElement; + protected readonly breadcrumbsRenderer: BreadcrumbsRenderer; + protected topRow: HTMLElement; constructor( protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, protected readonly tabBarToolbarFactory: () => TabBarToolbar, - protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options) { - + protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory, + protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options, + ) { super(options); + this.breadcrumbsRenderer = this.breadcrumbsRendererFactory(); this.rewireDOM(); 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))); } /** @@ -612,12 +630,22 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { 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; + await this.breadcrumbsRenderer.refresh(uri); + } + protected onAfterAttach(msg: Message): void { if (this.toolbar) { if (this.toolbar.isAttached) { Widget.detach(this.toolbar); } - Widget.attach(this.toolbar, this.node); + Widget.attach(this.toolbar, this.topRow); + if (this.breadcrumbsContainer) { + this.node.appendChild(this.breadcrumbsContainer); + } + this.breadcrumbsRenderer?.refresh(); } super.onAfterAttach(msg); } @@ -660,16 +688,22 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { 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 children with class name 'p-TabBar-content'."); + 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.node.appendChild(this.contentContainer); + this.topRow.appendChild(this.contentContainer); + this.node.appendChild(this.topRow); this.toolbar = this.tabBarToolbarFactory(); + this.breadcrumbsContainer = document.createElement('div'); + this.breadcrumbsContainer.classList.add('theia-tabBar-breadcrumb-row'); + this.breadcrumbsContainer.appendChild(this.breadcrumbsRenderer.host); + this.node.appendChild(this.breadcrumbsContainer); } - } export namespace ToolbarAwareTabBar { diff --git a/packages/core/src/browser/style/breadcrumbs.css b/packages/core/src/browser/style/breadcrumbs.css index 04c13d176e12b..f7c858c6a1141 100644 --- a/packages/core/src/browser/style/breadcrumbs.css +++ b/packages/core/src/browser/style/breadcrumbs.css @@ -14,21 +14,30 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +:root { + --theia-breadcrumbs-height: 22px; +} + .theia-breadcrumbs { + height: var(--theia-breadcrumbs-height); position: relative; user-select: none; display: flex; flex-direction: row; flex-wrap: nowrap; justify-content: flex-start; + align-items: center; outline-style: none; margin: .5rem; list-style-type: none; overflow: hidden; + padding: 0; + margin: 0; + background-color: var(--theia-breadcrumb-background); } .theia-breadcrumbs .ps__thumb-x { - /* Same scrollbar height than in tab bar. */ + /* Same scrollbar height as in tab bar. */ height: var(--theia-private-horizontal-tab-scrollbar-height) !important; } @@ -39,18 +48,36 @@ white-space: nowrap; align-self: center; height: 100%; + color: var(--theia-breadcrumb-foreground); outline: none; - padding: .25rem .3rem .25rem .25rem; + padding: 0 .3rem 0 .25rem; } -.theia-breadcrumbs .theia-breadcrumb-item::before { - font-family: FontAwesome; - font-size: calc(var(--theia-content-font-size) * 0.8); - content: "\F0DA"; +.theia-breadcrumbs .theia-breadcrumb-item:hover { + color: var(--theia-breadcrumb-focusForeground); +} + +.theia-breadcrumbs .theia-breadcrumb-item:not(:last-of-type)::after { + font-family: codicon; + font-size: var(--theia-ui-font-size2); + content: "\eab6"; display: flex; align-items: center; width: .8em; text-align: right; + padding-left: 4px; +} + +.theia-breadcrumbs .theia-breadcrumb-item::before { + width: 16px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.theia-breadcrumbs .theia-breadcrumb-item:first-of-type::before { + content: " "; } .theia-breadcrumb-item-haspopup:hover { @@ -68,7 +95,7 @@ max-height: 200px; z-index: 10000; padding: 0px; - background: var(--theia-menu-color1); + background: var(--theia-breadcrumbPicker-background); font-size: var(--theia-ui-font-size1); color: var(--theia-ui-font-color1); box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2); diff --git a/packages/core/src/browser/style/scrollbars.css b/packages/core/src/browser/style/scrollbars.css index cf731c6c3f2d7..94c79dbef8703 100644 --- a/packages/core/src/browser/style/scrollbars.css +++ b/packages/core/src/browser/style/scrollbars.css @@ -48,12 +48,14 @@ |----------------------------------------------------------------------------*/ #theia-app-shell .ps__rail-x, -#theia-dialog-shell .ps__rail-x { +#theia-dialog-shell .ps__rail-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x { height: var(--theia-scrollbar-rail-width); } #theia-app-shell .ps__rail-x > .ps__thumb-x, -#theia-dialog-shell .ps__rail-x > .ps__thumb-x { +#theia-dialog-shell .ps__rail-x > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__thumb-x { height: var(--theia-scrollbar-width); bottom: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); background: var(--theia-scrollbarSlider-background); @@ -63,7 +65,9 @@ #theia-app-shell .ps__rail-x:hover, #theia-app-shell .ps__rail-x:focus, #theia-dialog-shell .ps__rail-x:hover, -#theia-dialog-shell .ps__rail-x:focus { +#theia-dialog-shell .ps__rail-x:focus, +#theia-breadcrumbs-popups-overlay .ps__rail-x:hover, +#theia-breadcrumbs-popups-overlay .ps__rail-x:focus { height: var(--theia-scrollbar-rail-width); } @@ -72,17 +76,22 @@ #theia-app-shell .ps__rail-x.ps--clicking .ps__thumb-x, #theia-dialog-shell .ps__rail-x:hover > .ps__thumb-x, #theia-dialog-shell .ps__rail-x:focus > .ps__thumb-x, -#theia-dialog-shell .ps__rail-x.ps--clicking .ps__thumb-x { +#theia-dialog-shell .ps__rail-x.ps--clicking .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x:hover > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x:focus > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x.ps--clicking .ps__thumb-x { height: var(--theia-scrollbar-width); } #theia-app-shell .ps__rail-y, -#theia-dialog-shell .ps__rail-y { +#theia-dialog-shell .ps__rail-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y { width: var(--theia-scrollbar-rail-width); } #theia-app-shell .ps__rail-y > .ps__thumb-y, -#theia-dialog-shell .ps__rail-y > .ps__thumb-y { +#theia-dialog-shell .ps__rail-y > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y > .ps__thumb-y { width: var(--theia-scrollbar-width); right: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); background: var(--theia-scrollbarSlider-background); @@ -92,7 +101,9 @@ #theia-app-shell .ps__rail-y:hover, #theia-app-shell .ps__rail-y:focus, #theia-dialog-shell .ps__rail-y:hover, -#theia-dialog-shell .ps__rail-y:focus { +#theia-dialog-shell .ps__rail-y:focus, +#theia-breadcrumbs-popups-overlay .ps__rail-y:hover, +#theia-breadcrumbs-popups-overlay .ps__rail-y:focus { width: var(--theia-scrollbar-rail-width); } @@ -101,27 +112,32 @@ #theia-app-shell .ps__rail-y.ps--clicking .ps__thumb-y, #theia-dialog-shell .ps__rail-y:hover > .ps__thumb-y, #theia-dialog-shell .ps__rail-y:focus > .ps__thumb-y, -#theia-dialog-shell .ps__rail-y.ps--clicking .ps__thumb-y { +#theia-dialog-shell .ps__rail-y.ps--clicking .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y:hover > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y:focus > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y.ps--clicking .ps__thumb-y { right: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); width: var(--theia-scrollbar-width); } #theia-app-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'], -#theia-dialog-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb']{ +#theia-dialog-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'], +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'] { background-color: var(--theia-scrollbarSlider-activeBackground); } #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus, #theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, -#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus -{ +#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:focus { background: var(--theia-scrollbarSlider-hoverBackground); } #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active, -#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active -{ +#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:active { background: var(--theia-scrollbarSlider-activeBackground); } @@ -130,7 +146,10 @@ #theia-app-shell .ps--scrolling-y > [class^='ps__rail'], #theia-dialog-shell .ps:hover > [class^='ps__rail'], #theia-dialog-shell .ps--focus > [class^='ps__rail'], -#theia-dialog-shell .ps--scrolling-y > [class^='ps__rail'] { +#theia-dialog-shell .ps--scrolling-y > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps:hover > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps--focus > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps--scrolling-y > [class^='ps__rail'] { opacity: 1; background: transparent; } diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 112fa4c685405..e369c48720421 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -9,6 +9,7 @@ --theia-private-horizontal-tab-scrollbar-height: 5px; --theia-tabbar-toolbar-z-index: 1001; --theia-toolbar-active-transform-scale: 1.272019649; + --theia-horizontal-toolbar-height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); } /*----------------------------------------------------------------------------- @@ -22,7 +23,7 @@ .p-TabBar[data-orientation='horizontal'] { overflow-x: hidden; overflow-y: hidden; - min-height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); + min-height: var(--theia-horizontal-toolbar-height); } .p-TabBar .p-TabBar-content { @@ -31,7 +32,7 @@ .p-TabBar[data-orientation='horizontal'] .p-TabBar-tab { flex: none; - height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); + height: var(--theia-horizontal-toolbar-height); min-width: 35px; line-height: var(--theia-private-horizontal-tab-height); padding: 0px 8px; @@ -279,23 +280,23 @@ body.theia-editor-highlightModifiedTabs | Perfect scrollbar |----------------------------------------------------------------------------*/ -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x { height: var(--theia-private-horizontal-tab-scrollbar-rail-height); z-index: 1000; } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x > .ps__thumb-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x > .ps__thumb-x { height: var(--theia-private-horizontal-tab-scrollbar-height) !important; bottom: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:hover, -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:focus { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:hover, +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:focus { height: var(--theia-private-horizontal-tab-scrollbar-rail-height) !important; } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:hover > .ps__thumb-x, -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:focus > .ps__thumb-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:hover > .ps__thumb-x, +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:focus > .ps__thumb-x { height: calc(var(--theia-private-horizontal-tab-scrollbar-height) / 2) !important; bottom: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } @@ -387,3 +388,18 @@ body.theia-editor-highlightModifiedTabs .p-TabBar-toolbar .item .cancel { background: var(--theia-icon-close) no-repeat; } + +.theia-tabBar-breadcrumb-row { + min-width: 100%; +} + +.p-TabBar.theia-tabBar-multirow[data-orientation='horizontal'] { + min-height: calc(var(--theia-breadcrumbs-height) + var(--theia-horizontal-toolbar-height)); + flex-direction: column; +} + +.theia-tabBar-tab-row { + display: flex; + flex-flow: row nowrap; + min-width: 100%; +} diff --git a/packages/core/src/browser/widgets/react-renderer.tsx b/packages/core/src/browser/widgets/react-renderer.tsx index f4d829268d0a6..0db1c2595e9b6 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -14,17 +14,19 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; +import { inject, injectable, optional } from 'inversify'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Disposable } from '../../common'; -import { injectable } from 'inversify'; + +export type RendererHost = HTMLElement; +export const RendererHost = Symbol('RendererHost'); @injectable() export class ReactRenderer implements Disposable { readonly host: HTMLElement; constructor( - host?: HTMLElement + @inject(RendererHost) @optional() host?: RendererHost ) { this.host = host || document.createElement('div'); } diff --git a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index 537958969084c..c73b72d4565d2 100644 --- a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts +++ b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts @@ -18,7 +18,7 @@ import * as electron from '../../../shared/electron'; import { inject, injectable } from 'inversify'; -import { ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands } from '../../browser'; +import { ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ContextMenuContext } from '../../browser/menu/context-menu-context'; import { MenuPath, MenuContribution, MenuModelRegistry } from '../../common'; @@ -84,7 +84,7 @@ export class ElectronContextMenuRenderer extends ContextMenuRenderer { protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): ElectronContextMenuAccess { const menu = this.menuFactory.createContextMenu(menuPath, args); - const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor!; + const { x, y } = coordinateFromAnchor(anchor); const zoom = electron.webFrame.getZoomFactor(); // x and y values must be Ints or else there is a conversion error menu.popup({ x: Math.round(x * zoom), y: Math.round(y * zoom) }); diff --git a/packages/editor/package.json b/packages/editor/package.json index 7e8a07c085dcf..e11061b38b2f4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -6,8 +6,7 @@ "@theia/core": "1.17.0", "@theia/variable-resolver": "1.17.0", "@types/base64-arraybuffer": "0.1.0", - "base64-arraybuffer": "^0.1.5", - "perfect-scrollbar": "^1.3.0" + "base64-arraybuffer": "^0.1.5" }, "publishConfig": { "access": "public" diff --git a/packages/editor/src/browser/editor-frontend-module.ts b/packages/editor/src/browser/editor-frontend-module.ts index e16130d59c37b..330b5f8bdb67b 100644 --- a/packages/editor/src/browser/editor-frontend-module.ts +++ b/packages/editor/src/browser/editor-frontend-module.ts @@ -35,14 +35,6 @@ import { NavigationLocationSimilarity } from './navigation/navigation-location-s import { EditorVariableContribution } from './editor-variable-contribution'; import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/quick-access'; import { QuickEditorService } from './quick-editor-service'; -import { - BreadcrumbsRendererFactory, - BreadcrumbsRenderer, - BreadcrumbsURI, - BreadcrumbRenderer, - DefaultBreadcrumbRenderer -} from '@theia/core/lib/browser/breadcrumbs'; -import URI from '@theia/core/lib/common/uri'; export default new ContainerModule(bind => { bindEditorPreferences(bind); @@ -87,14 +79,4 @@ export default new ContainerModule(bind => { bind(ActiveEditorAccess).toSelf().inSingletonScope(); bind(EditorAccess).to(CurrentEditorAccess).inSingletonScope().whenTargetNamed(EditorAccess.CURRENT); bind(EditorAccess).to(ActiveEditorAccess).inSingletonScope().whenTargetNamed(EditorAccess.ACTIVE); - - bind(BreadcrumbsRendererFactory).toFactory(ctx => - (uri: URI) => { - const childContainer = ctx.container.createChild(); - childContainer.bind(BreadcrumbsURI).toConstantValue(uri); - childContainer.bind(BreadcrumbsRenderer).toSelf(); - childContainer.bind(BreadcrumbRenderer).to(DefaultBreadcrumbRenderer).inSingletonScope(); - return childContainer.get(BreadcrumbsRenderer); - } - ); }); diff --git a/packages/editor/src/browser/editor-widget-factory.ts b/packages/editor/src/browser/editor-widget-factory.ts index 985eae6e39b5f..f9e007059ea31 100644 --- a/packages/editor/src/browser/editor-widget-factory.ts +++ b/packages/editor/src/browser/editor-widget-factory.ts @@ -20,7 +20,6 @@ import { SelectionService } from '@theia/core/lib/common'; import { NavigatableWidgetOptions, WidgetFactory, LabelProvider } from '@theia/core/lib/browser'; import { EditorWidget } from './editor-widget'; import { TextEditorProvider } from './editor'; -import { BreadcrumbsRendererFactory } from '@theia/core/lib/browser/breadcrumbs'; @injectable() export class EditorWidgetFactory implements WidgetFactory { @@ -44,9 +43,6 @@ export class EditorWidgetFactory implements WidgetFactory { @inject(SelectionService) protected readonly selectionService: SelectionService; - @inject(BreadcrumbsRendererFactory) - protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory; - createWidget(options: NavigatableWidgetOptions): Promise { const uri = new URI(options.uri); return this.createEditor(uri, options); @@ -71,8 +67,7 @@ export class EditorWidgetFactory implements WidgetFactory { protected async constructEditor(uri: URI): Promise { const textEditor = await this.editorProvider(uri); - const breadcrumbsRenderer = this.breadcrumbsRendererFactory(uri); - return new EditorWidget(textEditor, breadcrumbsRenderer, this.selectionService); + return new EditorWidget(textEditor, this.selectionService); } private setLabels(editor: EditorWidget, uri: URI): void { diff --git a/packages/editor/src/browser/editor-widget.ts b/packages/editor/src/browser/editor-widget.ts index 04ef3af4dd1ad..f875433999f91 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -18,19 +18,14 @@ import { Disposable, SelectionService, Event } from '@theia/core/lib/common'; import { Widget, BaseWidget, Message, Saveable, SaveableSource, Navigatable, StatefulWidget } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { TextEditor } from './editor'; -import { BreadcrumbsRenderer } from '@theia/core/lib/browser/breadcrumbs'; export class EditorWidget extends BaseWidget implements SaveableSource, Navigatable, StatefulWidget { constructor( readonly editor: TextEditor, - readonly breadcrumbsRenderer: BreadcrumbsRenderer, protected readonly selectionService: SelectionService ) { - super(EditorWidget.createParentNode(editor, breadcrumbsRenderer)); - - this.toDispose.push(this.breadcrumbsRenderer); - + super(editor); this.addClass('theia-editor'); this.toDispose.push(this.editor); this.toDispose.push(this.editor.onSelectionChanged(() => this.setSelection())); @@ -48,13 +43,6 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata } } - static createParentNode(editor: TextEditor, breadcrumbsWidget: BreadcrumbsRenderer): Widget.IOptions { - const div = document.createElement('div'); - div.appendChild(breadcrumbsWidget.host); - div.appendChild(editor.node); - return { node: div }; - } - get saveable(): Saveable { return this.editor.document; } @@ -76,14 +64,12 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata super.onAfterAttach(msg); if (this.isVisible) { this.editor.refresh(); - this.breadcrumbsRenderer.refresh(); } } protected onAfterShow(msg: Message): void { super.onAfterShow(msg); this.editor.refresh(); - this.breadcrumbsRenderer.refresh(); } protected onResize(msg: Widget.ResizeMessage): void { diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts index ab76042f7fdcc..4ab4303fdc4c0 100644 --- a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts @@ -14,16 +14,17 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumb'; -import { FilepathBreadcrumbType } from './filepath-breadcrumbs-contribution'; import URI from '@theia/core/lib/common/uri'; +import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-constants'; +import { FilepathBreadcrumbType } from './filepath-breadcrumbs-contribution'; export class FilepathBreadcrumb implements Breadcrumb { constructor( readonly uri: URI, readonly label: string, readonly longLabel: string, - readonly iconClass: string + readonly iconClass: string, + readonly containerClass: string, ) { } get id(): string { diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts index 843caed6cadd1..3c47fd0064cc5 100644 --- a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts @@ -14,8 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Container, interfaces, injectable, inject } from 'inversify'; -import { TreeProps, ContextMenuRenderer, TreeNode, OpenerService, NodeProps } from '@theia/core/lib/browser'; +import { Container, interfaces, injectable, inject } from '@theia/core/shared/inversify'; +import { TreeProps, ContextMenuRenderer, TreeNode, OpenerService, open, NodeProps, defaultTreeProps } from '@theia/core/lib/browser'; import { createFileTreeContainer, FileTreeWidget } from '../'; import { FileTreeModel, FileStatNode } from '../file-tree'; @@ -24,6 +24,7 @@ const BREADCRUMBS_FILETREE_CLASS = 'theia-FilepathBreadcrumbFileTree'; export function createFileTreeBreadcrumbsContainer(parent: interfaces.Container): Container { const child = createFileTreeContainer(parent); child.unbind(FileTreeWidget); + child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, virtualized: false }); child.bind(BreadcrumbsFileTreeWidget).toSelf(); return child; } @@ -57,8 +58,7 @@ export class BreadcrumbsFileTreeWidget extends FileTreeWidget { protected handleClickEvent(node: TreeNode | undefined, event: React.MouseEvent): void { if (FileStatNode.is(node) && !node.fileStat.isDirectory) { - this.openerService.getOpener(node.uri) - .then(opener => opener.open(node.uri)); + open(this.openerService, node.uri, { preview: true }); } else { super.handleClickEvent(node, event); } diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts index aab9dbe35ff0d..7663cd01ddb35 100644 --- a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts @@ -14,31 +14,39 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { BreadcrumbsContribution } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-contribution'; -import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumb'; -import { FilepathBreadcrumb } from './filepath-breadcrumb'; -import { injectable, inject } from 'inversify'; -import { LabelProvider, Widget } from '@theia/core/lib/browser'; -import { FileSystem, FileStat } from '../../common'; +import { Disposable, Emitter, Event } from '@theia/core'; +import { injectable, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; +import { Breadcrumb, BreadcrumbsContribution, CompositeTreeNode, LabelProvider, SelectableTreeNode, Widget } from '@theia/core/lib/browser'; +import { FilepathBreadcrumb } from './filepath-breadcrumb'; import { BreadcrumbsFileTreeWidget } from './filepath-breadcrumbs-container'; import { DirNode } from '../file-tree'; -import { Disposable } from '@theia/core'; +import { FileService } from '../file-service'; +import { FileStat } from '../../common/files'; export const FilepathBreadcrumbType = Symbol('FilepathBreadcrumb'); +export interface FilepathBreadcrumbClassNameFactory { + (location: URI, index: number): string; +} + @injectable() export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution { @inject(LabelProvider) protected readonly labelProvider: LabelProvider; - @inject(FileSystem) - protected readonly fileSystem: FileSystem; + @inject(FileService) + protected readonly fileSystem: FileService; @inject(BreadcrumbsFileTreeWidget) protected readonly breadcrumbsFileTreeWidget: BreadcrumbsFileTreeWidget; + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + get onDidChangeBreadcrumbs(): Event { + return this.onDidChangeBreadcrumbsEmitter.event; + } + readonly type = FilepathBreadcrumbType; readonly priority: number = 100; @@ -46,13 +54,30 @@ export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution if (uri.scheme !== 'file') { return []; } - return (await Promise.all(uri.allLocations.reverse() - .map(async u => new FilepathBreadcrumb( - u, - this.labelProvider.getName(u), - this.labelProvider.getLongName(u), - await this.labelProvider.getIcon(u) + ' file-icon' - )))).filter(b => this.filterBreadcrumbs(uri, b)); + const getContainerClass = this.getContainerClassCreator(uri); + const getIconClass = this.getIconClassCreator(uri); + return uri.allLocations + .map((location, index) => { + const icon = getIconClass(location, index); + const containerClass = getContainerClass(location, index); + return new FilepathBreadcrumb( + location, + this.labelProvider.getName(location), + this.labelProvider.getLongName(location), + icon, + containerClass, + ); + }) + .filter(b => this.filterBreadcrumbs(uri, b)) + .reverse(); + } + + protected getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + return (location, index) => location.isEqual(fileURI) ? 'file' : 'folder'; + } + + protected getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + return (location, index) => location.isEqual(fileURI) ? this.labelProvider.getIcon(location) + ' file-icon' : ''; } protected filterBreadcrumbs(_: URI, breadcrumb: FilepathBreadcrumb): boolean { @@ -63,30 +88,42 @@ export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution if (!FilepathBreadcrumb.is(breadcrumb)) { return undefined; } - const folderFileStat = await this.fileSystem.getFileStat(breadcrumb.uri.parent.toString()); + const folderFileStat = await this.fileSystem.resolve(breadcrumb.uri.parent); if (folderFileStat) { const rootNode = await this.createRootNode(folderFileStat); - await this.breadcrumbsFileTreeWidget.model.navigateTo(rootNode); - Widget.attach(this.breadcrumbsFileTreeWidget, parent); - return { - dispose: () => { - // Clear model otherwise the next time a popup is opened the old model is rendered first - // and is shown for a short time period. - this.breadcrumbsFileTreeWidget.model.root = undefined; - Widget.detach(this.breadcrumbsFileTreeWidget); - } - }; + if (rootNode) { + const { model } = this.breadcrumbsFileTreeWidget; + await model.navigateTo({ ...rootNode, visible: false }); + Widget.attach(this.breadcrumbsFileTreeWidget, parent); + const toDisposeOnTreePopulated = model.onChanged(() => { + if (CompositeTreeNode.is(model.root) && model.root.children.length > 0) { + toDisposeOnTreePopulated.dispose(); + const targetNode = model.getNode(breadcrumb.uri.path.toString()); + if (targetNode && SelectableTreeNode.is(targetNode)) { + model.selectNode(targetNode); + } + this.breadcrumbsFileTreeWidget.activate(); + } + }); + return { + dispose: () => { + // Clear model otherwise the next time a popup is opened the old model is rendered first + // and is shown for a short time period. + toDisposeOnTreePopulated.dispose(); + this.breadcrumbsFileTreeWidget.model.root = undefined; + Widget.detach(this.breadcrumbsFileTreeWidget); + } + }; + } } } protected async createRootNode(folderToOpen: FileStat): Promise { - const folderUri = new URI(folderToOpen.uri); + const folderUri = folderToOpen.resource; const rootUri = folderToOpen.isDirectory ? folderUri : folderUri.parent; - const name = this.labelProvider.getName(rootUri); - const rootStat = await this.fileSystem.getFileStat(rootUri.toString()); + const rootStat = await this.fileSystem.resolve(rootUri); if (rootStat) { - const label = await this.labelProvider.getIcon(rootStat); - return DirNode.createRoot(rootStat, name, label); + return DirNode.createRoot(rootStat); } } } diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index f50947daab644..3e7d70bcb2d4d 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -18,7 +18,7 @@ import '../../src/browser/style/index.css'; import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; import { ResourceResolver, CommandContribution } from '@theia/core/lib/common'; -import { WebSocketConnectionProvider, FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser'; +import { WebSocketConnectionProvider, FrontendApplicationContribution, LabelProviderContribution, BreadcrumbsContribution } from '@theia/core/lib/browser'; import { FileResourceResolver } from './file-resource'; import { bindFileSystemPreferences } from './filesystem-preferences'; import { FileSystemWatcher } from './filesystem-watcher'; @@ -37,7 +37,6 @@ import { RemoteFileServiceContribution } from './remote-file-service-contributio import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler'; import { UTF8 } from '@theia/core/lib/common/encodings'; import { FilepathBreadcrumbsContribution } from './breadcrumbs/filepath-breadcrumbs-contribution'; -import { BreadcrumbsContribution } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-contribution'; import { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container'; export default new ContainerModule(bind => { diff --git a/packages/filesystem/src/browser/style/filepath-breadcrumbs.css b/packages/filesystem/src/browser/style/filepath-breadcrumbs.css index bfa2f6c8052d4..6c15e0ff49ee3 100644 --- a/packages/filesystem/src/browser/style/filepath-breadcrumbs.css +++ b/packages/filesystem/src/browser/style/filepath-breadcrumbs.css @@ -15,5 +15,6 @@ ********************************************************************************/ .theia-FilepathBreadcrumbFileTree { - height: 200px; + height: auto; + max-height: 200px; } diff --git a/packages/monaco/src/browser/monaco-outline-contribution.ts b/packages/monaco/src/browser/monaco-outline-contribution.ts index f6d6fb97f20c8..c8cdbe3b10943 100644 --- a/packages/monaco/src/browser/monaco-outline-contribution.ts +++ b/packages/monaco/src/browser/monaco-outline-contribution.ts @@ -43,7 +43,7 @@ export class MonacoOutlineContribution implements FrontendApplicationContributio onStart(app: FrontendApplication): void { // updateOutline and handleCurrentEditorChanged need to be called even when the outline view widget is closed - // in order to udpate breadcrumbs. + // in order to update breadcrumbs. DocumentSymbolProviderRegistry.onDidChange( debounce(() => this.updateOutline()) ); diff --git a/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx b/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx index 219d91776a463..f25ab1fdaedb4 100644 --- a/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx +++ b/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx @@ -14,24 +14,41 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import { BreadcrumbsContribution } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-contribution'; -import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumb'; -import { injectable, inject, postConstruct } from 'inversify'; -import { LabelProvider, BreadcrumbsService } from '@theia/core/lib/browser'; +import * as React from '@theia/core/shared/react'; +import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; +import { LabelProvider, BreadcrumbsService, Widget, TreeNode, OpenerService, open, SelectableTreeNode, BreadcrumbsContribution, Breadcrumb } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { OutlineViewService } from './outline-view-service'; -import { OutlineSymbolInformationNode } from './outline-view-widget'; -import { EditorManager } from '@theia/editor/lib/browser'; -import { Disposable } from '@theia/core/lib/common'; -import PerfectScrollbar from 'perfect-scrollbar'; +import { OutlineSymbolInformationNode, OutlineViewWidget } from './outline-view-widget'; +import { Disposable, DisposableCollection, Emitter, Event } from '@theia/core/lib/common'; +import { UriSelection } from '@theia/core/lib/common'; export const OutlineBreadcrumbType = Symbol('OutlineBreadcrumb'); +export const BreadcrumbPopupOutlineViewFactory = Symbol('BreadcrumbPopupOutlineViewFactory'); +export const OUTLINE_BREADCRUMB_CONTAINER_CLASS = 'outline-element'; +export interface BreadcrumbPopupOutlineViewFactory { + (): BreadcrumbPopupOutlineView; +} +export class BreadcrumbPopupOutlineView extends OutlineViewWidget { + @inject(OpenerService) protected readonly openerService: OpenerService; + + protected handleClickEvent(node: TreeNode | undefined, event: React.MouseEvent): void { + if (UriSelection.is(node) && OutlineSymbolInformationNode.hasRange(node)) { + open(this.openerService, node.uri, { selection: node.range }); + } else { + super.handleClickEvent(node, event); + } + } + + cloneState(roots: OutlineSymbolInformationNode[]): void { + const nodes = this.reconcileTreeState(roots); + const root = this.getRoot(nodes); + this.model.root = this.inflateFromStorage(this.deflateForStorage(root)); + } +} @injectable() export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { - @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @@ -41,24 +58,34 @@ export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { @inject(BreadcrumbsService) protected readonly breadcrumbsService: BreadcrumbsService; - @inject(EditorManager) - protected readonly editorManager: EditorManager; + @inject(BreadcrumbPopupOutlineViewFactory) + protected readonly outlineFactory: BreadcrumbPopupOutlineViewFactory; + + protected outlineView: BreadcrumbPopupOutlineView; readonly type = OutlineBreadcrumbType; readonly priority: number = 200; - private currentUri: URI | undefined = undefined; - private currentBreadcrumbs: OutlineBreadcrumb[] = []; - private roots: OutlineSymbolInformationNode[] = []; + protected currentUri: URI | undefined = undefined; + protected currentBreadcrumbs: OutlineBreadcrumb[] = []; + protected roots: OutlineSymbolInformationNode[] = []; + + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + get onDidChangeBreadcrumbs(): Event { + return this.onDidChangeBreadcrumbsEmitter.event; + } @postConstruct() init(): void { + this.outlineView = this.outlineFactory(); + this.outlineView.node.style.height = 'auto'; + this.outlineView.node.style.maxHeight = '200px'; this.outlineViewService.onDidChangeOutline(roots => { if (roots.length > 0) { this.roots = roots; const first = roots[0]; - if ('uri' in first) { - this.updateOutlineItems(first['uri'] as URI, this.findSelectedNode(roots)); + if (UriSelection.is(first)) { + this.updateOutlineItems(first.uri, this.findSelectedNode(roots)); } } else { this.currentBreadcrumbs = []; @@ -66,8 +93,8 @@ export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { } }); this.outlineViewService.onDidSelect(node => { - if ('uri' in node) { - this.updateOutlineItems(node['uri'] as URI, node); + if (UriSelection.is(node)) { + this.updateOutlineItems(node.uri, node); } }); } @@ -77,19 +104,39 @@ export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { const outlinePath = this.toOutlinePath(selectedNode); if (outlinePath && selectedNode) { this.currentBreadcrumbs = outlinePath.map((node, index) => - new OutlineBreadcrumb(node, uri, index.toString(), node.name, 'symbol-icon symbol-icon-center ' + node.iconClass) + new OutlineBreadcrumb( + node, + uri, + index.toString(), + this.labelProvider.getName(node), + 'symbol-icon symbol-icon-center ' + node.iconClass, + OUTLINE_BREADCRUMB_CONTAINER_CLASS, + ) ); if (selectedNode.children && selectedNode.children.length > 0) { - this.currentBreadcrumbs.push(new OutlineBreadcrumb(selectedNode.children as OutlineSymbolInformationNode[], - uri, this.currentBreadcrumbs.length.toString(), '…', '')); + this.currentBreadcrumbs.push(new OutlineBreadcrumb( + selectedNode.children as OutlineSymbolInformationNode[], + uri, + this.currentBreadcrumbs.length.toString(), + '…', + '', + OUTLINE_BREADCRUMB_CONTAINER_CLASS, + )); } } else { this.currentBreadcrumbs = []; if (this.roots) { - this.currentBreadcrumbs.push(new OutlineBreadcrumb(this.roots, uri, this.currentBreadcrumbs.length.toString(), '…', '')); + this.currentBreadcrumbs.push(new OutlineBreadcrumb( + this.roots, + uri, + this.currentBreadcrumbs.length.toString(), + '…', + '', + OUTLINE_BREADCRUMB_CONTAINER_CLASS + )); } } - this.breadcrumbsService.breadcrumbsChanges(uri); + this.onDidChangeBreadcrumbsEmitter.fire(uri); } async computeBreadcrumbs(uri: URI): Promise { @@ -103,59 +150,32 @@ export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { if (!OutlineBreadcrumb.is(breadcrumb)) { return undefined; } - const nodes = Array.isArray(breadcrumb.node) ? breadcrumb.node : this.siblings(breadcrumb.node); - const items = nodes.map(node => ({ - label: node.name, - title: node.name, - iconClass: 'symbol-icon symbol-icon-center ' + node.iconClass, - action: () => this.revealInEditor(node) - })); - if (items.length > 0) { - ReactDOM.render({this.renderItems(items)}, parent); - const scrollbar = new PerfectScrollbar(parent, { - handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], - useBothWheelAxes: true, - scrollYMarginOffset: 8, - suppressScrollX: true - }); - return { - dispose: () => { - scrollbar.destroy(); - ReactDOM.unmountComponentAtNode(parent); - - } - }; - } - const noContent = document.createElement('div'); - noContent.style.margin = '.5rem'; - noContent.style.fontStyle = 'italic'; - noContent.innerText = '(no content)'; - parent.appendChild(noContent); - } - - private revealInEditor(node: OutlineSymbolInformationNode): void { - if ('range' in node && this.currentUri) { - this.editorManager.open(this.currentUri, { selection: node['range'] }); + const node = Array.isArray(breadcrumb.node) ? breadcrumb.node[0] : breadcrumb.node; + if (!node.parent) { + return undefined; } - } - - protected renderItems(items: { label: string, title: string, iconClass: string, action: () => void }[]): React.ReactNode { - return
      - {items.map((item, index) =>
    • item.action()}> - {item.label} -
    • )} -
    ; - } - - private siblings(node: OutlineSymbolInformationNode): OutlineSymbolInformationNode[] { - if (!node.parent) { return []; } - return node.parent.children.filter(n => n !== node).map(n => n as OutlineSymbolInformationNode); + const siblings = node.parent.children.filter((child): child is OutlineSymbolInformationNode => OutlineSymbolInformationNode.is(child)); + + const toDisposeOnHide = new DisposableCollection(); + this.outlineView.cloneState(siblings); + this.outlineView.model.selectNode(node); + this.outlineView.model.collapseAll(); + Widget.attach(this.outlineView, parent); + this.outlineView.activate(); + toDisposeOnHide.pushAll([ + this.outlineView.model.onExpansionChanged(expandedNode => SelectableTreeNode.is(expandedNode) && this.outlineView.model.selectNode(expandedNode)), + Disposable.create(() => { + this.outlineView.model.root = undefined; + Widget.detach(this.outlineView); + }), + ]); + return toDisposeOnHide; } /** * Returns the path of the given outline node. */ - private toOutlinePath(node: OutlineSymbolInformationNode | undefined, path: OutlineSymbolInformationNode[] = []): OutlineSymbolInformationNode[] | undefined { + protected toOutlinePath(node: OutlineSymbolInformationNode | undefined, path: OutlineSymbolInformationNode[] = []): OutlineSymbolInformationNode[] | undefined { if (!node) { return undefined; } if (node.id === 'outline-view-root') { return path; } if (node.parent) { @@ -168,7 +188,7 @@ export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { /** * Find the node that is selected. Returns after the first match. */ - private findSelectedNode(roots: OutlineSymbolInformationNode[]): OutlineSymbolInformationNode | undefined { + protected findSelectedNode(roots: OutlineSymbolInformationNode[]): OutlineSymbolInformationNode | undefined { const result = roots.find(node => node.selected); if (result) { return result; @@ -188,7 +208,8 @@ export class OutlineBreadcrumb implements Breadcrumb { readonly uri: URI, readonly index: string, readonly label: string, - readonly iconClass: string + readonly iconClass: string, + readonly containerClass: string, ) { } get id(): string { diff --git a/packages/outline-view/src/browser/outline-view-frontend-module.ts b/packages/outline-view/src/browser/outline-view-frontend-module.ts index b1a7e47b919de..a3534056196dd 100644 --- a/packages/outline-view/src/browser/outline-view-frontend-module.ts +++ b/packages/outline-view/src/browser/outline-view-frontend-module.ts @@ -36,13 +36,20 @@ import '../../src/browser/styles/index.css'; import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { OutlineDecoratorService, OutlineTreeDecorator } from './outline-decorator-service'; import { OutlineViewTreeModel } from './outline-view-tree-model'; -import { OutlineBreadcrumbsContribution } from './outline-breadcrumbs-contribution'; +import { BreadcrumbPopupOutlineView, BreadcrumbPopupOutlineViewFactory, OutlineBreadcrumbsContribution } from './outline-breadcrumbs-contribution'; export default new ContainerModule(bind => { bind(OutlineViewWidgetFactory).toFactory(ctx => () => createOutlineViewWidget(ctx.container) ); + bind(BreadcrumbPopupOutlineViewFactory).toFactory(({ container }) => () => { + const child = createOutlineViewWidgetContainer(container); + child.rebind(OutlineViewWidget).to(BreadcrumbPopupOutlineView); + child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, expandOnlyOnExpansionToggleClick: true, search: false, virtualized: false }); + return child.get(OutlineViewWidget); + }); + bind(OutlineViewService).toSelf().inSingletonScope(); bind(WidgetFactory).toService(OutlineViewService); @@ -54,16 +61,7 @@ export default new ContainerModule(bind => { bind(BreadcrumbsContribution).toService(OutlineBreadcrumbsContribution); }); -/** - * Create an `OutlineViewWidget`. - * - The creation of the `OutlineViewWidget` includes: - * - The creation of the tree widget itself with it's own customized props. - * - The binding of necessary components into the container. - * @param parent the Inversify container. - * - * @returns the `OutlineViewWidget`. - */ -function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidget { +function createOutlineViewWidgetContainer(parent: interfaces.Container): interfaces.Container { const child = createTreeContainer(parent); child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, expandOnlyOnExpansionToggleClick: true, search: true }); @@ -78,6 +76,20 @@ function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidge child.bind(OutlineDecoratorService).toSelf().inSingletonScope(); child.rebind(TreeDecoratorService).toDynamicValue(ctx => ctx.container.get(OutlineDecoratorService)).inSingletonScope(); bindContributionProvider(child, OutlineTreeDecorator); + return child; +} + +/** + * Create an `OutlineViewWidget`. + * - The creation of the `OutlineViewWidget` includes: + * - The creation of the tree widget itself with it's own customized props. + * - The binding of necessary components into the container. + * @param parent the Inversify container. + * + * @returns the `OutlineViewWidget`. + */ +function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidget { + const child = createOutlineViewWidgetContainer(parent); return child.get(OutlineViewWidget); } diff --git a/packages/outline-view/src/browser/outline-view-widget.tsx b/packages/outline-view/src/browser/outline-view-widget.tsx index b2a897b564759..40d4791c02248 100644 --- a/packages/outline-view/src/browser/outline-view-widget.tsx +++ b/packages/outline-view/src/browser/outline-view-widget.tsx @@ -27,9 +27,11 @@ import { } from '@theia/core/lib/browser'; import { OutlineViewTreeModel } from './outline-view-tree-model'; import { Message } from '@theia/core/shared/@phosphor/messaging'; -import { Emitter } from '@theia/core'; +import { Emitter, Mutable, UriSelection } from '@theia/core'; import { CompositeTreeNode } from '@theia/core/lib/browser'; import * as React from '@theia/core/shared/react'; +import { Range } from '@theia/core/shared/vscode-languageserver-types'; +import URI from '@theia/core/lib/common/uri'; /** * Representation of an outline symbol information node. @@ -58,6 +60,10 @@ export namespace OutlineSymbolInformationNode { export function is(node: TreeNode): node is OutlineSymbolInformationNode { return !!node && SelectableTreeNode.is(node) && 'iconClass' in node; } + + export function hasRange(node: unknown): node is { range: Range } { + return typeof node === 'object' && !!node && 'range' in node && Range.is((node as { range: Range }).range); + } } export type OutlineViewWidgetFactory = () => OutlineViewWidget; @@ -91,13 +97,17 @@ export class OutlineViewWidget extends TreeWidget { // Gather the list of available nodes. const nodes = this.reconcileTreeState(roots); // Update the model root node, appending the outline symbol information nodes as children. - this.model.root = { + this.model.root = this.getRoot(nodes); + } + + protected getRoot(children: TreeNode[]): CompositeTreeNode { + return { id: 'outline-view-root', name: 'Outline Root', visible: false, - children: nodes, + children, parent: undefined - } as CompositeTreeNode; + }; } /** @@ -171,4 +181,19 @@ export class OutlineViewWidget extends TreeWidget { return super.renderTree(model); } + protected deflateForStorage(node: TreeNode): object { + const deflated = super.deflateForStorage(node) as { uri: string }; + if (UriSelection.is(node)) { + deflated.uri = node.uri.toString(); + } + return deflated; + } + + protected inflateFromStorage(node: any, parent?: TreeNode): TreeNode { /* eslint-disable-line @typescript-eslint/no-explicit-any */ + const inflated = super.inflateFromStorage(node, parent) as Mutable; + if (node && 'uri' in node && typeof node.uri === 'string') { + inflated.uri = new URI(node.uri); + } + return inflated; + } } diff --git a/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts b/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts index 1cae03025f0a8..8740ae7e074eb 100644 --- a/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts +++ b/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts @@ -36,6 +36,7 @@ import { WorkspaceRootNode } from '@theia/navigator/lib/browser/navigator-tree'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat, FileChangeType } from '@theia/filesystem/lib/common/files'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; export interface PluginIconDefinition { iconPath: string; @@ -107,6 +108,9 @@ export class PluginIconTheme extends PluginIconThemeDefinition implements IconTh @inject(PluginIconThemeDefinition) protected readonly definition: PluginIconThemeDefinition; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; @@ -508,9 +512,15 @@ export class PluginIconTheme extends PluginIconThemeDefinition implements IconTh const name = this.labelProvider.getName(element); const classNames = this.fileNameIcon(name); if (uri) { - const language = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(monaco.Uri.parse(uri)); + const parsedURI = new URI(uri); + const isRoot = this.workspaceService.getWorkspaceRootUri(new URI(uri))?.isEqual(parsedURI); + if (isRoot) { + classNames.unshift(this.rootFolderIcon); + } else { + classNames.unshift(this.fileIcon); + } + const language = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(parsedURI['codeUri']); classNames.push(this.languageIcon(language.languageIdentifier.language)); - classNames.unshift(this.fileIcon); } return classNames; } diff --git a/packages/preferences/src/browser/util/preference-tree-generator.ts b/packages/preferences/src/browser/util/preference-tree-generator.ts index c92b454990908..8c67a0569df28 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.ts +++ b/packages/preferences/src/browser/util/preference-tree-generator.ts @@ -48,6 +48,7 @@ export class PreferenceTreeGenerator { ['extensions', 'Extensions'] ]); protected readonly sectionAssignments = new Map([ + ['breadcrumbs', 'workbench'], ['comments', 'features'], ['debug', 'features'], ['diffEditor', 'editor'], diff --git a/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts index ef9e7acd688db..5903c2f4beb7c 100644 --- a/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts +++ b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts @@ -15,8 +15,8 @@ ********************************************************************************/ import { FilepathBreadcrumb } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumb'; -import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; -import { inject, injectable } from 'inversify'; +import { FilepathBreadcrumbClassNameFactory, FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { WorkspaceService } from './workspace-service'; import URI from '@theia/core/lib/common/uri'; @@ -26,8 +26,31 @@ export class WorkspaceBreadcrumbsContribution extends FilepathBreadcrumbsContrib @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI); + return (location, index) => { + if (location.isEqual(fileURI)) { + return 'file'; + } else if (workspaceRoot?.isEqual(location)) { + return 'root_folder'; + } + return 'folder'; + }; + } + + getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI); + return (location, index) => { + if (location.isEqual(fileURI) || workspaceRoot?.isEqual(location)) { + return this.labelProvider.getIcon(location) + ' file-icon'; + } + return ''; + }; + } + protected filterBreadcrumbs(uri: URI, breadcrumb: FilepathBreadcrumb): boolean { const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri); - return super.filterBreadcrumbs(uri, breadcrumb) && (!workspaceRootUri || !breadcrumb.uri.isEqualOrParent(workspaceRootUri)); + const firstCrumbToHide = this.workspaceService.isMultiRootWorkspaceOpened ? workspaceRootUri?.parent : workspaceRootUri; + return super.filterBreadcrumbs(uri, breadcrumb) && (!firstCrumbToHide || !breadcrumb.uri.isEqualOrParent(firstCrumbToHide)); } } diff --git a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts index 46661f03372af..6c53a5ed1a6a1 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts @@ -100,13 +100,13 @@ describe('WorkspaceUriLabelProviderContribution class', () => { expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon); }); - it('should return folder icon from a folder URI', async () => { + it('should return folder icon from a folder FileStat', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); expect(labelProvider.getIcon(FileStat.dir('file:///home/test'))).eq(labelProvider.defaultFolderIcon); }); - it('should return file icon from a file URI', async () => { + it('should return file icon from a file FileStat', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon); @@ -119,6 +119,11 @@ describe('WorkspaceUriLabelProviderContribution class', () => { expect(labelProvider.getIcon(new URI('file:///home/test'))).eq(ret); expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(ret); }); + + it('should return rootfolder-icon for a URI or file stat that corresponds to a workspace root', () => { + expect(labelProvider.getIcon(new URI('file:///workspace'))).eq('rootfolder-icon'); + expect(labelProvider.getIcon(FileStat.dir('file:///workspace'))).eq('rootfolder-icon'); + }); }); describe('getName()', () => { diff --git a/packages/workspace/src/browser/workspace-uri-contribution.ts b/packages/workspace/src/browser/workspace-uri-contribution.ts index afd07c37ecb3e..4db41501821f1 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.ts @@ -39,6 +39,10 @@ export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProvid } getIcon(element: URI | URIIconReference | FileStat): string { + const uri = this.getUri(element); + if (uri && this.workspaceVariable.getWorkspaceRootUri(uri)?.isEqual(uri)) { + return 'rootfolder-icon'; + } return super.getIcon(this.asURIIconReference(element)); }