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(); });