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..74a01f80614e1 --- /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. */ + id: string + + /** The breadcrumb type. Should be the same as the contribution type `BreadcrumbsContribution#type`. */ + type: symbol + + /** The text that will be rendered as label. */ + label: string + + /** A longer text that will be used as tooltip text. */ + longLabel: string + + /** A CSS class for the icon. */ + 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..d95abfd8d75f5 --- /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`. + */ + type: symbol; + + /** + * The priority of this breadcrumbs contribution. Contributions with lower priority are rendered first. + */ + 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..9196ee671f31d --- /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.onBreadcrumbsChange(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..378245fe1ba7f --- /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 onBreadcrumbsChangeEmitter = 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 onBreadcrumbsChange(): Event { + return this.onBreadcrumbsChangeEmitter.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.onBreadcrumbsChangeEmitter.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 2721a58d77c9b..804d92e67f1ef 100644 --- a/packages/core/src/browser/core-preferences.ts +++ b/packages/core/src/browser/core-preferences.ts @@ -44,6 +44,11 @@ export const corePreferenceSchema: PreferenceSchema = { default: 50, minimum: 0, description: 'Controls the number of recently used commands to keep in history for the command palette. Set to 0 to disable command history.' + }, + 'breadcrumbs.enabled': { + 'type': 'boolean', + 'default': true, + 'description': 'Enable/disable navigation breadcrumbs.' } } }; @@ -52,6 +57,7 @@ export interface CoreConfiguration { 'application.confirmExit': 'never' | 'ifRequired' | 'always'; 'workbench.list.openMode': 'singleClick' | 'doubleClick'; 'workbench.commandPalette.history': number; + 'breadcrumbs.enabled': boolean; } export const CorePreferences = Symbol('CorePreferences'); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 20c91dbc4747d..0deb14419eb56 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -83,6 +83,7 @@ import { ProgressStatusBarItem } from './progress-status-bar-item'; import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorator'; import { ContextMenuContext } from './menu/context-menu-context'; import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings'; +import { BreadcrumbsContribution, BreadcrumbsService } from './breadcrumbs'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -286,4 +287,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(ProgressService).toSelf().inSingletonScope(); bind(ContextMenuContext).toSelf().inSingletonScope(); + + bindContributionProvider(bind, BreadcrumbsContribution); + bind(BreadcrumbsService).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index 584bb4cde2511..2b82fcc6deca5 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 ce0551792645c..f7e51bf879236 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -198,3 +198,4 @@ textarea { @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 d75f02e0aaa60..1fc7e17425d8e 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -17,7 +17,9 @@ 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 { readonly host: HTMLElement; constructor( diff --git a/packages/editor/package.json b/packages/editor/package.json index e422bf2741df2..f4ce0386d595b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -7,7 +7,8 @@ "@theia/languages": "^0.12.0", "@theia/variable-resolver": "^0.12.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 418f2ddb663b3..92ec657c2ba38 100644 --- a/packages/editor/src/browser/editor-frontend-module.ts +++ b/packages/editor/src/browser/editor-frontend-module.ts @@ -33,6 +33,14 @@ import { NavigationLocationSimilarity } from './navigation/navigation-location-s import { EditorVariableContribution } from './editor-variable-contribution'; import { SemanticHighlightingService } from './semantic-highlight/semantic-highlighting-service'; import { EditorQuickOpenService } from './editor-quick-open-service'; +import URI from '@theia/core/lib/common/uri'; +import { + BreadcrumbsRendererFactory, + BreadcrumbsRenderer, + BreadcrumbsURI, + BreadcrumbRenderer, + DefaultBreadcrumbRenderer +} from '@theia/core/lib/browser/breadcrumbs'; export default new ContainerModule(bind => { bindEditorPreferences(bind); @@ -74,4 +82,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 4e07734147435..f915d7191be81 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 { @@ -37,6 +38,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); @@ -45,7 +49,8 @@ export class EditorWidgetFactory implements WidgetFactory { protected async createEditor(uri: URI): Promise { const icon = await this.labelProvider.getIcon(uri); return this.editorProvider(uri).then(textEditor => { - const newEditor = new EditorWidget(textEditor, this.selectionService); + const breadcrumbsRenderer = this.breadcrumbsRendererFactory(uri); + const newEditor = new EditorWidget(textEditor, breadcrumbsRenderer, this.selectionService); newEditor.id = this.id + ':' + uri.toString(); newEditor.title.closable = true; newEditor.title.label = this.labelProvider.getName(uri); diff --git a/packages/editor/src/browser/editor-widget.ts b/packages/editor/src/browser/editor-widget.ts index a126339bfda9f..9629f8fa2a78f 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -18,14 +18,19 @@ import { Disposable, SelectionService } 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.toDispose.push(this.editor); this.toDispose.push(this.editor.onSelectionChanged(() => { if (this.editor.isFocused()) { @@ -39,6 +44,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; } @@ -60,12 +72,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 05f530632abb7..4ce57f0b911bd 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -17,6 +17,7 @@ "jschardet": "1.6.0", "minimatch": "^3.0.4", "mv": "^2.1.1", + "perfect-scrollbar": "^1.3.0", "rimraf": "^2.6.2", "tar-fs": "^1.16.2", "touch": "^3.1.0", 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 618fefa59c3ef..a9513635f87e3 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -30,6 +30,9 @@ import { FileSystemWatcher } from './filesystem-watcher'; import { FileSystemFrontendContribution } from './filesystem-frontend-contribution'; import { FileSystemProxyFactory } from './filesystem-proxy-factory'; import { FileUploadService } from './file-upload-service'; +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); @@ -62,6 +65,12 @@ export default new ContainerModule(bind => { bind(FileSystemFrontendContribution).toSelf().inSingletonScope(); bind(CommandContribution).toService(FileSystemFrontendContribution); bind(FrontendApplicationContribution).toService(FileSystemFrontendContribution); + + 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 f56e53a75ca14..59f94e5c37fd1 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 14fec858971c8..9586672d9ca71 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('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 a3413debf2cfa..cb616b52ccc22 100644 --- a/packages/outline-view/package.json +++ b/packages/outline-view/package.json @@ -3,7 +3,9 @@ "version": "0.12.0", "description": "Theia - Outline View Extension", "dependencies": { - "@theia/core": "^0.12.0" + "@theia/core": "^0.12.0", + "@theia/editor": "^0.12.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 8c54193dd75c1..6255a5e87f2dc 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'; +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 c2c21999a2ea2..5b85354ab3093 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 2300d21aa2ef0..bbc388f8fd8a6 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -44,6 +44,8 @@ import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler'; import { WorkspaceUtils } from './workspace-utils'; import { WorkspaceCompareHandler } from './workspace-compare-handler'; import { DiffService } from './diff-service'; +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); @@ -89,4 +91,6 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un bind(QuickOpenWorkspace).toSelf().inSingletonScope(); bind(WorkspaceUtils).toSelf().inSingletonScope(); + + rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope(); });