From d6c3c9236e87ae2a3d1eb97a59012987a4f6c39c Mon Sep 17 00:00:00 2001 From: "Cornelius A. Ludmann" Date: Tue, 12 Nov 2019 11:08:33 +0000 Subject: [PATCH 1/2] Add breadcrumbs bar to editor widget This commit adds a breadcrumbs bar to the editor widget. It shows the path to the current file and outline information as breadcrumbs. A click of breadcrumbs allows to jump to other files or to code sections. Fixes #5475 Signed-off-by: Cornelius A. Ludmann --- .../breadcrumbs/breadcrumb-popup-container.ts | 97 ++++++++ .../breadcrumbs/breadcrumb-renderer.tsx | 42 ++++ .../src/browser/breadcrumbs/breadcrumb.ts | 34 +++ .../breadcrumbs/breadcrumbs-contribution.ts | 44 ++++ .../breadcrumbs/breadcrumbs-renderer.tsx | 191 ++++++++++++++++ .../breadcrumbs/breadcrumbs-service.ts | 92 ++++++++ .../src/browser/breadcrumbs/breadcrumbs.ts | 25 +++ .../core/src/browser/breadcrumbs/index.ts | 23 ++ packages/core/src/browser/core-preferences.ts | 97 ++++---- .../browser/frontend-application-module.ts | 3 + packages/core/src/browser/index.ts | 1 + .../core/src/browser/style/breadcrumbs.css | 104 +++++++++ packages/core/src/browser/style/index.css | 1 + .../src/browser/widgets/react-renderer.tsx | 1 + packages/editor/package.json | 3 +- .../src/browser/editor-frontend-module.ts | 18 ++ .../src/browser/editor-widget-factory.ts | 7 +- packages/editor/src/browser/editor-widget.ts | 16 +- packages/filesystem/package.json | 1 + .../breadcrumbs/filepath-breadcrumb.ts | 42 ++++ .../filepath-breadcrumbs-container.ts | 66 ++++++ .../filepath-breadcrumbs-contribution.ts | 92 ++++++++ .../src/browser/filesystem-frontend-module.ts | 8 + .../browser/style/filepath-breadcrumbs.css | 19 ++ .../filesystem/src/browser/style/index.css | 1 + .../browser/monaco-outline-contribution.ts | 30 +-- packages/outline-view/package.json | 3 +- .../outline-breadcrumbs-contribution.tsx | 210 ++++++++++++++++++ .../browser/outline-view-frontend-module.ts | 7 +- .../src/browser/outline-view-service.ts | 4 +- .../workspace-breadcrumbs-contribution.ts | 33 +++ .../src/browser/workspace-frontend-module.ts | 3 + 32 files changed, 1248 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumb.ts create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumbs-contribution.ts create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts create mode 100644 packages/core/src/browser/breadcrumbs/breadcrumbs.ts create mode 100644 packages/core/src/browser/breadcrumbs/index.ts create mode 100644 packages/core/src/browser/style/breadcrumbs.css create mode 100644 packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts create mode 100644 packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts create mode 100644 packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts create mode 100644 packages/filesystem/src/browser/style/filepath-breadcrumbs.css create mode 100644 packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx create mode 100644 packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts new file mode 100644 index 0000000000000..f3e08fe86865a --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts @@ -0,0 +1,97 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { Disposable, DisposableCollection } from '../../common/disposable'; +import { Breadcrumbs } from './breadcrumbs'; + +/** + * This class creates a popup container at the given position + * so that contributions can attach their HTML elements + * as childs of `BreadcrumbPopupContainer#container`. + * + * - `dispose()` is called on blur or on hit on escape + */ +export class BreadcrumbPopupContainer implements Disposable { + + protected toDispose: DisposableCollection = new DisposableCollection(); + + readonly container: HTMLElement; + public isOpen: boolean; + + constructor( + protected readonly parent: HTMLElement, + public readonly breadcrumbId: string, + position: { x: number, y: number } + ) { + this.container = this.createPopupDiv(position); + document.addEventListener('keyup', this.escFunction); + this.container.focus(); + this.isOpen = true; + } + + protected createPopupDiv(position: { x: number, y: number }): HTMLDivElement { + const result = window.document.createElement('div'); + result.className = Breadcrumbs.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); + 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; + } + } + 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); + } + 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 new file mode 100644 index 0000000000000..6385e8c8f6244 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx @@ -0,0 +1,42 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import * as React from 'react'; +import { injectable } from 'inversify'; +import { Breadcrumb } from './breadcrumb'; +import { Breadcrumbs } from './breadcrumbs'; + +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; +} + +@injectable() +export class DefaultBreadcrumbRenderer implements BreadcrumbRenderer { + render(breadcrumb: Breadcrumb, onClick?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode { + return
  • onClick && onClick(breadcrumb, event)} + tabIndex={0} + data-breadcrumb-id={breadcrumb.id} + > + {breadcrumb.iconClass && } {breadcrumb.label} +
  • ; + } +} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb.ts b/packages/core/src/browser/breadcrumbs/breadcrumb.ts new file mode 100644 index 0000000000000..5d549b251b519 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumb.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * 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-contribution.ts new file mode 100644 index 0000000000000..959fa63ce77f5 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-contribution.ts @@ -0,0 +1,44 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import URI from '../../common/uri'; +import { Breadcrumb } from './breadcrumb'; +import { Disposable } from '../../common'; + +export const BreadcrumbsContribution = Symbol('BreadcrumbsContribution'); +export interface BreadcrumbsContribution { + + /** + * The breadcrumb type. Breadcrumbs returned by `#computeBreadcrumbs(uri)` should have this as `Breadcrumb#type`. + */ + readonly type: symbol; + + /** + * The priority of this breadcrumbs contribution. Contributions with lower priority are rendered first. + */ + readonly priority: number; + + /** + * Computes breadcrumbs for a given URI. + */ + computeBreadcrumbs(uri: URI): Promise; + + /** + * Attaches the breadcrumb popup content for the given breadcrumb as child to the given parent. + * If it returns a Disposable, it is called when the popup closes. + */ + attachPopupContent(breadcrumb: Breadcrumb, parent: HTMLElement): Promise; +} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx b/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx new file mode 100644 index 0000000000000..198fb59f8f47d --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx @@ -0,0 +1,191 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +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 { BreadcrumbPopupContainer } from './breadcrumb-popup-container'; +import { DisposableCollection } from '../../common/disposable'; +import { CorePreferences } from '../core-preferences'; + +export const BreadcrumbsURI = Symbol('BreadcrumbsURI'); + +@injectable() +export class BreadcrumbsRenderer extends ReactRenderer { + + @inject(BreadcrumbsService) + protected readonly breadcrumbsService: BreadcrumbsService; + + @inject(BreadcrumbRenderer) + protected readonly breadcrumbRenderer: BreadcrumbRenderer; + + @inject(CorePreferences) + protected readonly corePreferences: CorePreferences; + + private breadcrumbs: Breadcrumb[] = []; + + private popup: BreadcrumbPopupContainer | undefined; + + private scrollbar: PerfectScrollbar | undefined; + + private toDispose: DisposableCollection = new DisposableCollection(); + + constructor( + @inject(BreadcrumbsURI) readonly uri: URI + ) { super(); } + + @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())); + } + + dispose(): void { + super.dispose(); + this.toDispose.dispose(); + if (this.popup) { this.popup.dispose(); } + if (this.scrollbar) { + this.scrollbar.destroy(); + this.scrollbar = undefined; + } + } + + async refresh(): Promise { + if (this.corePreferences['breadcrumbs.enabled']) { + this.breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(this.uri); + } else { + this.breadcrumbs = []; + } + 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 + }); + } + } 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 doRender(): React.ReactNode { + return
      {this.renderBreadcrumbs()}
    ; + } + + protected renderBreadcrumbs(): React.ReactNode { + return this.breadcrumbs.map(breadcrumb => this.breadcrumbRenderer.render(breadcrumb, this.togglePopup)); + } + + protected togglePopup = (breadcrumb: Breadcrumb, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + let openPopup = true; + if (this.popup) { + 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); + } + 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; }); + } + } + } + } +} + +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; +} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts new file mode 100644 index 0000000000000..63cd18ee39483 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts @@ -0,0 +1,92 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +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'; + +@injectable() +export class BreadcrumbsService { + + @inject(ContributionProvider) @named(BreadcrumbsContribution) + protected readonly contributions: ContributionProvider; + + protected popupsOverlayContainer: HTMLDivElement; + + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + + @postConstruct() + init(): void { + this.createOverlayContainer(); + } + + protected createOverlayContainer(): void { + this.popupsOverlayContainer = window.document.createElement('div'); + this.popupsOverlayContainer.id = Breadcrumbs.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. + * The URI is the URI of the editor the breadcrumbs have changed for. + */ + get onDidChangeBreadcrumbs(): Event { + return this.onDidChangeBreadcrumbsEmitter.event; + } + + /** + * Notifies that the breadcrumbs for the given URI have changed and should be re-rendered. + * This fires an `onBreadcrumsChange` event. + */ + breadcrumbsChanges(uri: URI): void { + this.onDidChangeBreadcrumbsEmitter.fire(uri); + } + + /** + * Returns the breadcrumbs for a given URI, possibly an empty list. + */ + async getBreadcrumbs(uri: URI): Promise { + const result: Breadcrumb[] = []; + for (const contribution of await this.prioritizedContributions()) { + result.push(...await contribution.computeBreadcrumbs(uri)); + } + return result; + } + + protected async prioritizedContributions(): Promise { + const prioritized = await Prioritizeable.prioritizeAll( + this.contributions.getContributions(), contribution => contribution.priority); + return prioritized.map(p => p.value).reverse(); + } + + /** + * Opens a popup for the given breadcrumb at the given position. + */ + async openPopup(breadcrumb: Breadcrumb, position: { x: number, y: number }): 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)); + return popup; + } + } +} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs.ts new file mode 100644 index 0000000000000..afc1c1e84a0b3 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs.ts @@ -0,0 +1,25 @@ +/******************************************************************************** + * 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 new file mode 100644 index 0000000000000..88a71e8df3d44 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/index.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * 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 * 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'; diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts index 8d2b1b6e7e33e..00d17a78b41ed 100644 --- a/packages/core/src/browser/core-preferences.ts +++ b/packages/core/src/browser/core-preferences.ts @@ -23,6 +23,55 @@ import { isOSX } from '../common/os'; export const corePreferenceSchema: PreferenceSchema = { 'type': 'object', properties: { + 'application.confirmExit': { + type: 'string', + enum: [ + 'never', + 'ifRequired', + 'always', + ], + default: 'ifRequired', + description: 'When to confirm before closing the application window.', + }, + 'breadcrumbs.enabled': { + 'type': 'boolean', + 'default': true, + 'description': 'Enable/disable navigation breadcrumbs.', + 'scope': 'application' + }, + 'files.encoding': { + 'type': 'string', + 'enum': Object.keys(SUPPORTED_ENCODINGS), + 'default': 'utf8', + 'description': 'The default character set encoding to use when reading and writing files. This setting can also be configured per language.', + 'scope': 'language-overridable', + 'enumDescriptions': Object.keys(SUPPORTED_ENCODINGS).map(key => SUPPORTED_ENCODINGS[key].labelLong), + 'included': Object.keys(SUPPORTED_ENCODINGS).length > 1 + }, + 'keyboard.dispatch': { + type: 'string', + enum: [ + 'code', + 'keyCode', + ], + default: 'code', + description: 'Whether to interpret keypresses by the `code` of the physical key, or by the `keyCode` provided by the OS.' + }, + 'window.menuBarVisibility': { + type: 'string', + enum: ['classic', 'visible', 'hidden', 'compact'], + markdownEnumDescriptions: [ + 'Menu is displayed at the top of the window and only hidden in full screen mode.', + 'Menu is always visible at the top of the window even in full screen mode.', + 'Menu is always hidden.', + 'Menu is displayed as a compact button in the sidebar.' + ], + default: 'classic', + scope: 'application', + markdownDescription: `Control the visibility of the menu bar. + A setting of 'compact' will move the menu into the sidebar.`, + included: !isOSX + }, 'workbench.list.openMode': { type: 'string', enum: [ @@ -43,16 +92,6 @@ export const corePreferenceSchema: PreferenceSchema = { 'description': 'Controls whether editors showing a file that was opened during the session should close automatically when getting deleted or renamed by some other process. Disabling this will keep the editor open on such an event. Note that deleting from within the application will always close the editor and that dirty files will never close to preserve your data.', 'default': false }, - 'application.confirmExit': { - type: 'string', - enum: [ - 'never', - 'ifRequired', - 'always', - ], - default: 'ifRequired', - description: 'When to confirm before closing the application window.', - }, 'workbench.commandPalette.history': { type: 'number', default: 50, @@ -74,51 +113,21 @@ export const corePreferenceSchema: PreferenceSchema = { default: false, description: 'Controls whether to suppress notification popups.' }, - 'files.encoding': { - 'type': 'string', - 'enum': Object.keys(SUPPORTED_ENCODINGS), - 'default': 'utf8', - 'description': 'The default character set encoding to use when reading and writing files. This setting can also be configured per language.', - 'scope': 'language-overridable', - 'enumDescriptions': Object.keys(SUPPORTED_ENCODINGS).map(key => SUPPORTED_ENCODINGS[key].labelLong), - 'included': Object.keys(SUPPORTED_ENCODINGS).length > 1 - }, 'workbench.tree.renderIndentGuides': { type: 'string', enum: ['onHover', 'none', 'always'], default: 'onHover', description: 'Controls whether the tree should render indent guides.' }, - 'keyboard.dispatch': { - type: 'string', - enum: [ - 'code', - 'keyCode', - ], - default: 'code', - description: 'Whether to interpret keypresses by the `code` of the physical key, or by the `keyCode` provided by the OS.' - }, - 'window.menuBarVisibility': { - type: 'string', - enum: ['classic', 'visible', 'hidden', 'compact'], - markdownEnumDescriptions: [ - 'Menu is displayed at the top of the window and only hidden in full screen mode.', - 'Menu is always visible at the top of the window even in full screen mode.', - 'Menu is always hidden.', - 'Menu is displayed as a compact button in the sidebar.' - ], - default: 'classic', - scope: 'application', - markdownDescription: `Control the visibility of the menu bar. - A setting of 'compact' will move the menu into the sidebar.`, - included: !isOSX - }, } }; export interface CoreConfiguration { 'application.confirmExit': 'never' | 'ifRequired' | 'always'; + 'breadcrumbs.enabled': boolean; + 'files.encoding': string 'keyboard.dispatch': 'code' | 'keyCode'; + 'window.menuBarVisibility': 'classic' | 'visible' | 'hidden' | 'compact'; 'workbench.list.openMode': 'singleClick' | 'doubleClick'; 'workbench.commandPalette.history': number; 'workbench.editor.highlightModifiedTabs': boolean; @@ -126,9 +135,7 @@ export interface CoreConfiguration { 'workbench.colorTheme': string; 'workbench.iconTheme': string | null; 'workbench.silentNotifications': boolean; - 'files.encoding': string 'workbench.tree.renderIndentGuides': 'onHover' | 'none' | 'always'; - 'window.menuBarVisibility': 'classic' | 'visible' | 'hidden' | 'compact'; } export const CorePreferenceContribution = Symbol('CorePreferenceContribution'); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 4a3296369f00d..3b667c1c7698d 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -106,6 +106,7 @@ 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'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -361,4 +362,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo for (const contribution of [CommandContribution, KeybindingContribution, MenuContribution]) { bind(contribution).toService(WindowContribution); } + bindContributionProvider(bind, BreadcrumbsContribution); + bind(BreadcrumbsService).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index b6ef3247daea0..9bc188c6fcef8 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -39,3 +39,4 @@ export * from './navigatable'; export * from './diff-uris'; export * from './core-preferences'; export * from './view-container'; +export * from './breadcrumbs'; diff --git a/packages/core/src/browser/style/breadcrumbs.css b/packages/core/src/browser/style/breadcrumbs.css new file mode 100644 index 0000000000000..04c13d176e12b --- /dev/null +++ b/packages/core/src/browser/style/breadcrumbs.css @@ -0,0 +1,104 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.theia-breadcrumbs { + position: relative; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + outline-style: none; + margin: .5rem; + list-style-type: none; + overflow: hidden; +} + +.theia-breadcrumbs .ps__thumb-x { + /* Same scrollbar height than in tab bar. */ + height: var(--theia-private-horizontal-tab-scrollbar-height) !important; +} + +.theia-breadcrumbs .theia-breadcrumb-item { + display: flex; + align-items: center; + flex: 0 1 auto; + white-space: nowrap; + align-self: center; + height: 100%; + outline: none; + padding: .25rem .3rem .25rem .25rem; +} + +.theia-breadcrumbs .theia-breadcrumb-item::before { + font-family: FontAwesome; + font-size: calc(var(--theia-content-font-size) * 0.8); + content: "\F0DA"; + display: flex; + align-items: center; + width: .8em; + text-align: right; +} + +.theia-breadcrumb-item-haspopup:hover { + background: var(--theia-accent-color3); + cursor: pointer; +} + +#theia-breadcrumbs-popups-overlay { + height: 0px; +} + +.theia-breadcrumbs-popup { + position: fixed; + width: 300px; + max-height: 200px; + z-index: 10000; + padding: 0px; + background: var(--theia-menu-color1); + font-size: var(--theia-ui-font-size1); + color: var(--theia-ui-font-color1); + box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.theia-breadcrumbs-popup:focus { + outline-width: 0; + outline-style: none; +} + +.theia-breadcrumbs-popup ul { + display: flex; + flex-direction: column; + outline-style: none; + list-style-type: none; + padding-inline-start: 0px; + margin: 0 0 0 4px; +} + +.theia-breadcrumbs-popup ul li { + display: flex; + align-items: center; + flex: 0 1 auto; + white-space: nowrap; + cursor: pointer; + outline: none; + padding: .25rem .25rem .25rem .25rem; +} + +.theia-breadcrumbs-popup ul li:hover { + background: var(--theia-accent-color3); +} diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index e4e8f65c61476..005d39a3bb2de 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -248,3 +248,4 @@ button.secondary[disabled], .theia-button.secondary[disabled] { @import './widget.css'; @import './quick-title-bar.css'; @import './progress-bar.css'; +@import './breadcrumbs.css'; diff --git a/packages/core/src/browser/widgets/react-renderer.tsx b/packages/core/src/browser/widgets/react-renderer.tsx index b21e8f6617521..f4d829268d0a6 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -18,6 +18,7 @@ import { injectable } from 'inversify'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Disposable } from '../../common'; +import { injectable } from 'inversify'; @injectable() export class ReactRenderer implements Disposable { diff --git a/packages/editor/package.json b/packages/editor/package.json index e11061b38b2f4..7e8a07c085dcf 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -6,7 +6,8 @@ "@theia/core": "1.17.0", "@theia/variable-resolver": "1.17.0", "@types/base64-arraybuffer": "0.1.0", - "base64-arraybuffer": "^0.1.5" + "base64-arraybuffer": "^0.1.5", + "perfect-scrollbar": "^1.3.0" }, "publishConfig": { "access": "public" diff --git a/packages/editor/src/browser/editor-frontend-module.ts b/packages/editor/src/browser/editor-frontend-module.ts index 330b5f8bdb67b..e16130d59c37b 100644 --- a/packages/editor/src/browser/editor-frontend-module.ts +++ b/packages/editor/src/browser/editor-frontend-module.ts @@ -35,6 +35,14 @@ 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); @@ -79,4 +87,14 @@ 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 f9e007059ea31..985eae6e39b5f 100644 --- a/packages/editor/src/browser/editor-widget-factory.ts +++ b/packages/editor/src/browser/editor-widget-factory.ts @@ -20,6 +20,7 @@ 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 { @@ -43,6 +44,9 @@ 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); @@ -67,7 +71,8 @@ export class EditorWidgetFactory implements WidgetFactory { protected async constructEditor(uri: URI): Promise { const textEditor = await this.editorProvider(uri); - return new EditorWidget(textEditor, this.selectionService); + const breadcrumbsRenderer = this.breadcrumbsRendererFactory(uri); + return new EditorWidget(textEditor, breadcrumbsRenderer, 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 f875433999f91..04ef3af4dd1ad 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -18,14 +18,19 @@ 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(editor); + super(EditorWidget.createParentNode(editor, breadcrumbsRenderer)); + + this.toDispose.push(this.breadcrumbsRenderer); + this.addClass('theia-editor'); this.toDispose.push(this.editor); this.toDispose.push(this.editor.onSelectionChanged(() => this.setSelection())); @@ -43,6 +48,13 @@ 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; } @@ -64,12 +76,14 @@ 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/package.json b/packages/filesystem/package.json index c2682a4167ad2..d901453558956 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -14,6 +14,7 @@ "http-status-codes": "^1.3.0", "minimatch": "^3.0.4", "multer": "^1.4.2", + "perfect-scrollbar": "^1.3.0", "rimraf": "^2.6.2", "tar-fs": "^1.16.2", "trash": "^6.1.1", diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts new file mode 100644 index 0000000000000..ab76042f7fdcc --- /dev/null +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts @@ -0,0 +1,42 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumb'; +import { FilepathBreadcrumbType } from './filepath-breadcrumbs-contribution'; +import URI from '@theia/core/lib/common/uri'; + +export class FilepathBreadcrumb implements Breadcrumb { + constructor( + readonly uri: URI, + readonly label: string, + readonly longLabel: string, + readonly iconClass: string + ) { } + + get id(): string { + return this.type.toString() + '_' + this.uri.toString(); + } + + get type(): symbol { + return FilepathBreadcrumbType; + } +} + +export namespace FilepathBreadcrumb { + export function is(breadcrumb: Breadcrumb): breadcrumb is FilepathBreadcrumb { + return 'uri' in breadcrumb; + } +} diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts new file mode 100644 index 0000000000000..843caed6cadd1 --- /dev/null +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-container.ts @@ -0,0 +1,66 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { Container, interfaces, injectable, inject } from 'inversify'; +import { TreeProps, ContextMenuRenderer, TreeNode, OpenerService, NodeProps } from '@theia/core/lib/browser'; +import { createFileTreeContainer, FileTreeWidget } from '../'; +import { FileTreeModel, FileStatNode } from '../file-tree'; + +const BREADCRUMBS_FILETREE_CLASS = 'theia-FilepathBreadcrumbFileTree'; + +export function createFileTreeBreadcrumbsContainer(parent: interfaces.Container): Container { + const child = createFileTreeContainer(parent); + child.unbind(FileTreeWidget); + child.bind(BreadcrumbsFileTreeWidget).toSelf(); + return child; +} + +export function createFileTreeBreadcrumbsWidget(parent: interfaces.Container): BreadcrumbsFileTreeWidget { + return createFileTreeBreadcrumbsContainer(parent).get(BreadcrumbsFileTreeWidget); +} + +@injectable() +export class BreadcrumbsFileTreeWidget extends FileTreeWidget { + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + constructor( + @inject(TreeProps) readonly props: TreeProps, + @inject(FileTreeModel) readonly model: FileTreeModel, + @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer + ) { + super(props, model, contextMenuRenderer); + this.addClass(BREADCRUMBS_FILETREE_CLASS); + } + + protected createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes { + const elementAttrs = super.createNodeAttributes(node, props); + return { + ...elementAttrs, + draggable: false + }; + } + + 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)); + } 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 new file mode 100644 index 0000000000000..aab9dbe35ff0d --- /dev/null +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts @@ -0,0 +1,92 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +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 URI from '@theia/core/lib/common/uri'; +import { BreadcrumbsFileTreeWidget } from './filepath-breadcrumbs-container'; +import { DirNode } from '../file-tree'; +import { Disposable } from '@theia/core'; + +export const FilepathBreadcrumbType = Symbol('FilepathBreadcrumb'); + +@injectable() +export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution { + + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + @inject(BreadcrumbsFileTreeWidget) + protected readonly breadcrumbsFileTreeWidget: BreadcrumbsFileTreeWidget; + + readonly type = FilepathBreadcrumbType; + readonly priority: number = 100; + + async computeBreadcrumbs(uri: URI): Promise { + 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)); + } + + protected filterBreadcrumbs(_: URI, breadcrumb: FilepathBreadcrumb): boolean { + return !breadcrumb.uri.path.isRoot; + } + + async attachPopupContent(breadcrumb: Breadcrumb, parent: HTMLElement): Promise { + if (!FilepathBreadcrumb.is(breadcrumb)) { + return undefined; + } + const folderFileStat = await this.fileSystem.getFileStat(breadcrumb.uri.parent.toString()); + 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); + } + }; + } + } + + protected async createRootNode(folderToOpen: FileStat): Promise { + const folderUri = new URI(folderToOpen.uri); + const rootUri = folderToOpen.isDirectory ? folderUri : folderUri.parent; + const name = this.labelProvider.getName(rootUri); + const rootStat = await this.fileSystem.getFileStat(rootUri.toString()); + if (rootStat) { + const label = await this.labelProvider.getIcon(rootStat); + return DirNode.createRoot(rootStat, name, label); + } + } +} diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index ff0415fdbe1bf..f50947daab644 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -36,6 +36,9 @@ import { bindContributionProvider } from '@theia/core/lib/common/contribution-pr import { RemoteFileServiceContribution } from './remote-file-service-contribution'; 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 => { bindFileSystemPreferences(bind); @@ -217,6 +220,11 @@ export default new ContainerModule(bind => { bind(FileTreeLabelProvider).toSelf().inSingletonScope(); bind(LabelProviderContribution).toService(FileTreeLabelProvider); + bind(BreadcrumbsFileTreeWidget).toDynamicValue(ctx => + createFileTreeBreadcrumbsWidget(ctx.container) + ); + bind(FilepathBreadcrumbsContribution).toSelf().inSingletonScope(); + bind(BreadcrumbsContribution).toService(FilepathBreadcrumbsContribution); }); export function bindFileResource(bind: interfaces.Bind): void { diff --git a/packages/filesystem/src/browser/style/filepath-breadcrumbs.css b/packages/filesystem/src/browser/style/filepath-breadcrumbs.css new file mode 100644 index 0000000000000..bfa2f6c8052d4 --- /dev/null +++ b/packages/filesystem/src/browser/style/filepath-breadcrumbs.css @@ -0,0 +1,19 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.theia-FilepathBreadcrumbFileTree { + height: 200px; +} diff --git a/packages/filesystem/src/browser/style/index.css b/packages/filesystem/src/browser/style/index.css index c3ccadf6866a3..fdde513b3daf5 100644 --- a/packages/filesystem/src/browser/style/index.css +++ b/packages/filesystem/src/browser/style/index.css @@ -16,6 +16,7 @@ @import './file-dialog.css'; @import './file-icons.css'; +@import './filepath-breadcrumbs.css'; .theia-file-tree-drag-image { position: absolute; diff --git a/packages/monaco/src/browser/monaco-outline-contribution.ts b/packages/monaco/src/browser/monaco-outline-contribution.ts index 83624c9f96ab2..f6d6fb97f20c8 100644 --- a/packages/monaco/src/browser/monaco-outline-contribution.ts +++ b/packages/monaco/src/browser/monaco-outline-contribution.ts @@ -33,7 +33,6 @@ import debounce = require('@theia/core/shared/lodash.debounce'); @injectable() export class MonacoOutlineContribution implements FrontendApplicationContribution { - protected readonly toDisposeOnClose = new DisposableCollection(); protected readonly toDisposeOnEditor = new DisposableCollection(); protected roots: MonacoOutlineSymbolInformationNode[] | undefined; protected canUpdateOutline: boolean = true; @@ -42,20 +41,17 @@ export class MonacoOutlineContribution implements FrontendApplicationContributio @inject(EditorManager) protected readonly editorManager: EditorManager; onStart(app: FrontendApplication): void { - this.outlineViewService.onDidChangeOpenState(async open => { - if (open) { - this.toDisposeOnClose.push(this.toDisposeOnEditor); - this.toDisposeOnClose.push(DocumentSymbolProviderRegistry.onDidChange( - debounce(() => this.updateOutline()) - )); - this.toDisposeOnClose.push(this.editorManager.onCurrentEditorChanged( - debounce(() => this.handleCurrentEditorChanged(), 50) - )); - this.handleCurrentEditorChanged(); - } else { - this.toDisposeOnClose.dispose(); - } - }); + + // updateOutline and handleCurrentEditorChanged need to be called even when the outline view widget is closed + // in order to udpate breadcrumbs. + DocumentSymbolProviderRegistry.onDidChange( + debounce(() => this.updateOutline()) + ); + this.editorManager.onCurrentEditorChanged( + debounce(() => this.handleCurrentEditorChanged(), 50) + ); + this.handleCurrentEditorChanged(); + this.outlineViewService.onDidSelect(async node => { if (MonacoOutlineSymbolInformationNode.is(node) && node.parent) { const options: EditorOpenerOptions = { @@ -89,10 +85,6 @@ export class MonacoOutlineContribution implements FrontendApplicationContributio protected handleCurrentEditorChanged(): void { this.toDisposeOnEditor.dispose(); - if (this.toDisposeOnClose.disposed) { - return; - } - this.toDisposeOnClose.push(this.toDisposeOnEditor); this.toDisposeOnEditor.push(Disposable.create(() => this.roots = undefined)); const editor = this.editorManager.currentEditor; if (editor) { diff --git a/packages/outline-view/package.json b/packages/outline-view/package.json index 4dcfe9e8587cd..e5e6ae026b8cf 100644 --- a/packages/outline-view/package.json +++ b/packages/outline-view/package.json @@ -3,7 +3,8 @@ "version": "1.17.0", "description": "Theia - Outline View Extension", "dependencies": { - "@theia/core": "1.17.0" + "@theia/core": "1.17.0", + "perfect-scrollbar": "^1.3.0" }, "publishConfig": { "access": "public" diff --git a/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx b/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx new file mode 100644 index 0000000000000..219d91776a463 --- /dev/null +++ b/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx @@ -0,0 +1,210 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +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 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'; + +export const OutlineBreadcrumbType = Symbol('OutlineBreadcrumb'); + +@injectable() +export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { + + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + @inject(OutlineViewService) + protected readonly outlineViewService: OutlineViewService; + + @inject(BreadcrumbsService) + protected readonly breadcrumbsService: BreadcrumbsService; + + @inject(EditorManager) + protected readonly editorManager: EditorManager; + + readonly type = OutlineBreadcrumbType; + readonly priority: number = 200; + + private currentUri: URI | undefined = undefined; + private currentBreadcrumbs: OutlineBreadcrumb[] = []; + private roots: OutlineSymbolInformationNode[] = []; + + @postConstruct() + init(): void { + 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)); + } + } else { + this.currentBreadcrumbs = []; + this.roots = []; + } + }); + this.outlineViewService.onDidSelect(node => { + if ('uri' in node) { + this.updateOutlineItems(node['uri'] as URI, node); + } + }); + } + + protected async updateOutlineItems(uri: URI, selectedNode: OutlineSymbolInformationNode | undefined): Promise { + this.currentUri = uri; + 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) + ); + if (selectedNode.children && selectedNode.children.length > 0) { + this.currentBreadcrumbs.push(new OutlineBreadcrumb(selectedNode.children as OutlineSymbolInformationNode[], + uri, this.currentBreadcrumbs.length.toString(), '…', '')); + } + } else { + this.currentBreadcrumbs = []; + if (this.roots) { + this.currentBreadcrumbs.push(new OutlineBreadcrumb(this.roots, uri, this.currentBreadcrumbs.length.toString(), '…', '')); + } + } + this.breadcrumbsService.breadcrumbsChanges(uri); + } + + async computeBreadcrumbs(uri: URI): Promise { + if (this.currentUri && uri.toString() === this.currentUri.toString()) { + return this.currentBreadcrumbs; + } + return []; + } + + async attachPopupContent(breadcrumb: Breadcrumb, parent: HTMLElement): Promise { + 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'] }); + } + } + + 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); + } + + /** + * Returns the path of the given outline node. + */ + private toOutlinePath(node: OutlineSymbolInformationNode | undefined, path: OutlineSymbolInformationNode[] = []): OutlineSymbolInformationNode[] | undefined { + if (!node) { return undefined; } + if (node.id === 'outline-view-root') { return path; } + if (node.parent) { + return this.toOutlinePath(node.parent as OutlineSymbolInformationNode, [node, ...path]); + } else { + return [node, ...path]; + } + } + + /** + * Find the node that is selected. Returns after the first match. + */ + private findSelectedNode(roots: OutlineSymbolInformationNode[]): OutlineSymbolInformationNode | undefined { + const result = roots.find(node => node.selected); + if (result) { + return result; + } + for (const node of roots) { + const result2 = this.findSelectedNode(node.children.map(child => child as OutlineSymbolInformationNode)); + if (result2) { + return result2; + } + } + } +} + +export class OutlineBreadcrumb implements Breadcrumb { + constructor( + readonly node: OutlineSymbolInformationNode | OutlineSymbolInformationNode[], + readonly uri: URI, + readonly index: string, + readonly label: string, + readonly iconClass: string + ) { } + + get id(): string { + return this.type.toString() + '_' + this.uri.toString() + '_' + this.index; + } + + get type(): symbol { + return OutlineBreadcrumbType; + } + + get longLabel(): string { + return this.label; + } +} +export namespace OutlineBreadcrumb { + export function is(breadcrumb: Breadcrumb): breadcrumb is OutlineBreadcrumb { + return 'node' in breadcrumb && 'uri' in breadcrumb; + } +} 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 a110da6fa61bc..b1a7e47b919de 100644 --- a/packages/outline-view/src/browser/outline-view-frontend-module.ts +++ b/packages/outline-view/src/browser/outline-view-frontend-module.ts @@ -27,7 +27,8 @@ import { defaultTreeProps, TreeDecoratorService, TreeModel, - TreeModelImpl + TreeModelImpl, + BreadcrumbsContribution } from '@theia/core/lib/browser'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { OutlineViewWidgetFactory, OutlineViewWidget } from './outline-view-widget'; @@ -35,6 +36,7 @@ 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'; export default new ContainerModule(bind => { bind(OutlineViewWidgetFactory).toFactory(ctx => @@ -47,6 +49,9 @@ export default new ContainerModule(bind => { bindViewContribution(bind, OutlineViewContribution); bind(FrontendApplicationContribution).toService(OutlineViewContribution); bind(TabBarToolbarContribution).toService(OutlineViewContribution); + + bind(OutlineBreadcrumbsContribution).toSelf().inSingletonScope(); + bind(BreadcrumbsContribution).toService(OutlineBreadcrumbsContribution); }); /** diff --git a/packages/outline-view/src/browser/outline-view-service.ts b/packages/outline-view/src/browser/outline-view-service.ts index 9dcd7da62fece..f15b8c46d4d27 100644 --- a/packages/outline-view/src/browser/outline-view-service.ts +++ b/packages/outline-view/src/browser/outline-view-service.ts @@ -61,8 +61,10 @@ export class OutlineViewService implements WidgetFactory { publish(roots: OutlineSymbolInformationNode[]): void { if (this.widget) { this.widget.setOutlineTree(roots); - this.onDidChangeOutlineEmitter.fire(roots); } + // onDidChangeOutline needs to be fired even when the outline view widget is closed + // in order to udpate breadcrumbs. + this.onDidChangeOutlineEmitter.fire(roots); } createWidget(): Promise { diff --git a/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts new file mode 100644 index 0000000000000..ef9e7acd688db --- /dev/null +++ b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +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 { WorkspaceService } from './workspace-service'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class WorkspaceBreadcrumbsContribution extends FilepathBreadcrumbsContribution { + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + protected filterBreadcrumbs(uri: URI, breadcrumb: FilepathBreadcrumb): boolean { + const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri); + return super.filterBreadcrumbs(uri, breadcrumb) && (!workspaceRootUri || !breadcrumb.uri.isEqualOrParent(workspaceRootUri)); + } +} diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 35a6dbb74b61a..9cb94ab93768a 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -46,6 +46,8 @@ import { WorkspaceCompareHandler } from './workspace-compare-handler'; import { DiffService } from './diff-service'; import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store'; import { WorkspaceSchemaUpdater } from './workspace-schema-updater'; +import { WorkspaceBreadcrumbsContribution } from './workspace-breadcrumbs-contribution'; +import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { bindWorkspacePreferences(bind); @@ -96,4 +98,5 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un bind(WorkspaceSchemaUpdater).toSelf().inSingletonScope(); bind(JsonSchemaContribution).toService(WorkspaceSchemaUpdater); + rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope(); }); From 81ddeee3c4637e32a8533d9482c744a020d0690a Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 16 Aug 2021 22:40:10 +0200 Subject: [PATCH 2/2] Add breadcrumbs to tabbar Signed-off-by: Colin Grant --- CHANGELOG.md | 8 + .../breadcrumbs/breadcrumb-popup-container.ts | 98 ++++----- .../breadcrumbs/breadcrumb-renderer.tsx | 11 +- .../src/browser/breadcrumbs/breadcrumb.ts | 34 ---- ...ntribution.ts => breadcrumbs-constants.ts} | 43 +++- .../breadcrumbs/breadcrumbs-renderer.tsx | 190 +++++++++--------- .../breadcrumbs/breadcrumbs-service.ts | 42 ++-- .../src/browser/breadcrumbs/breadcrumbs.ts | 25 --- .../core/src/browser/breadcrumbs/index.ts | 4 +- .../browser/common-frontend-contribution.ts | 45 +++++ .../core/src/browser/context-menu-renderer.ts | 12 +- .../browser/frontend-application-module.ts | 47 +++-- .../menu/browser-context-menu-renderer.ts | 4 +- .../core/src/browser/navigatable-types.ts | 78 +++++++ packages/core/src/browser/navigatable.ts | 63 +----- .../src/browser/shell/application-shell.ts | 24 ++- packages/core/src/browser/shell/tab-bars.ts | 52 ++++- .../core/src/browser/style/breadcrumbs.css | 41 +++- .../core/src/browser/style/scrollbars.css | 47 +++-- packages/core/src/browser/style/tabs.css | 32 ++- .../src/browser/widgets/react-renderer.tsx | 8 +- .../menu/electron-context-menu-renderer.ts | 4 +- packages/editor/package.json | 3 +- .../src/browser/editor-frontend-module.ts | 18 -- .../src/browser/editor-widget-factory.ts | 7 +- packages/editor/src/browser/editor-widget.ts | 16 +- .../breadcrumbs/filepath-breadcrumb.ts | 7 +- .../filepath-breadcrumbs-container.ts | 8 +- .../filepath-breadcrumbs-contribution.ts | 101 +++++++--- .../src/browser/filesystem-frontend-module.ts | 3 +- .../browser/style/filepath-breadcrumbs.css | 3 +- .../browser/monaco-outline-contribution.ts | 2 +- .../outline-breadcrumbs-contribution.tsx | 169 +++++++++------- .../browser/outline-view-frontend-module.ts | 34 +++- .../src/browser/outline-view-widget.tsx | 33 ++- .../main/browser/plugin-icon-theme-service.ts | 14 +- .../browser/util/preference-tree-generator.ts | 1 + .../workspace-breadcrumbs-contribution.ts | 29 ++- .../workspace-uri-contribution.spec.ts | 9 +- .../src/browser/workspace-uri-contribution.ts | 4 + 40 files changed, 834 insertions(+), 539 deletions(-) delete mode 100644 packages/core/src/browser/breadcrumbs/breadcrumb.ts rename packages/core/src/browser/breadcrumbs/{breadcrumbs-contribution.ts => breadcrumbs-constants.ts} (52%) delete mode 100644 packages/core/src/browser/breadcrumbs/breadcrumbs.ts create mode 100644 packages/core/src/browser/navigatable-types.ts 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)); }