diff --git a/CHANGELOG.md b/CHANGELOG.md index fc28aab082361..18a5cb905b259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## v1.18.0 - 9/30/2021 + +- [core, outline-view, file-system, workspace] added breadcrumbs contribution points and renderers to `core` and contributions to other packages. [#9920](https://github.com/eclipse-theia/theia/pull/9920) + +[Breaking Changes:](#breaking_changes_1.18.0) + +- [core] added `BreadcrumbsRendererFactory` to constructor arguments of `DockPanelRenderer` and `ToolbarAwareTabBar`. [#9920](https://github.com/eclipse-theia/theia/pull/9920) + ## v1.17.2 - 9/1/2021 [1.17.2 Milestone](https://github.com/eclipse-theia/theia/milestone/27) diff --git a/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts new file mode 100644 index 0000000000000..c5036bb4f8f86 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts @@ -0,0 +1,101 @@ +/******************************************************************************** + * 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, postConstruct } from '../../../shared/inversify'; +import { Emitter, Event } from '../../common'; +import { Disposable, DisposableCollection } from '../../common/disposable'; +import { Coordinate } from '../context-menu-renderer'; +import { RendererHost } from '../widgets/react-renderer'; +import { Styles } from './breadcrumbs-constants'; + +export interface BreadcrumbPopupContainerFactory { + (parent: HTMLElement, breadcrumbId: string, position: Coordinate): BreadcrumbPopupContainer; +} +export const BreadcrumbPopupContainerFactory = Symbol('BreadcrumbPopupContainerFactory'); + +export type BreadcrumbID = string; +export const BreadcrumbID = Symbol('BreadcrumbID'); + +/** + * This class creates a popup container at the given position + * so that contributions can attach their HTML elements + * as children of `BreadcrumbPopupContainer#container`. + * + * - `dispose()` is called on blur or on hit on escape + */ +@injectable() +export class BreadcrumbPopupContainer implements Disposable { + @inject(RendererHost) protected readonly parent: RendererHost; + @inject(BreadcrumbID) public readonly breadcrumbId: BreadcrumbID; + @inject(Coordinate) protected readonly position: Coordinate; + + protected onDidDisposeEmitter = new Emitter(); + protected toDispose: DisposableCollection = new DisposableCollection(this.onDidDisposeEmitter); + get onDidDispose(): Event { + return this.onDidDisposeEmitter.event; + } + + protected _container: HTMLElement; + get container(): HTMLElement { + return this._container; + } + + protected _isOpen: boolean; + get isOpen(): boolean { + return this._isOpen; + } + + @postConstruct() + protected init(): void { + this._container = this.createPopupDiv(this.position); + document.addEventListener('keyup', this.escFunction); + this._container.focus(); + this._isOpen = true; + } + + protected createPopupDiv(position: Coordinate): HTMLDivElement { + const result = window.document.createElement('div'); + result.className = Styles.BREADCRUMB_POPUP; + result.style.left = `${position.x}px`; + result.style.top = `${position.y}px`; + result.tabIndex = 0; + result.addEventListener('focusout', this.onFocusOut); + this.parent.appendChild(result); + return result; + } + + protected onFocusOut = (event: FocusEvent) => { + if (!(event.relatedTarget instanceof Element) || !this._container.contains(event.relatedTarget)) { + this.dispose(); + } + }; + + protected escFunction = (event: KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Esc') { + this.dispose(); + } + }; + + dispose(): void { + if (!this.toDispose.disposed) { + this.onDidDisposeEmitter.fire(); + this.toDispose.dispose(); + this._container.remove(); + this._isOpen = false; + document.removeEventListener('keyup', this.escFunction); + } + } +} 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..aa863343ebc24 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumb-renderer.tsx @@ -0,0 +1,41 @@ +/******************************************************************************** + * 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, Styles } from './breadcrumbs-constants'; + +export const BreadcrumbRenderer = Symbol('BreadcrumbRenderer'); +export interface BreadcrumbRenderer { + /** + * Renders the given breadcrumb. If `onClick` is given, it is called on breadcrumb click. + */ + render(breadcrumb: Breadcrumb, onMouseDown?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode; +} + +@injectable() +export class DefaultBreadcrumbRenderer implements BreadcrumbRenderer { + render(breadcrumb: Breadcrumb, onMouseDown?: (breadcrumb: Breadcrumb, event: React.MouseEvent) => void): React.ReactNode { + return
  • onMouseDown && onMouseDown(breadcrumb, event)} + tabIndex={0} + data-breadcrumb-id={breadcrumb.id} + > + {breadcrumb.iconClass && } {breadcrumb.label} +
  • ; + } +} diff --git a/packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts b/packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts new file mode 100644 index 0000000000000..e77495723fbc1 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-constants.ts @@ -0,0 +1,79 @@ +/******************************************************************************** + * 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 { MaybePromise, Event } from '../../common'; +import { Disposable } from '../../../shared/vscode-languageserver-protocol'; +import URI from '../../common/uri'; + +export namespace Styles { + export const BREADCRUMBS = 'theia-breadcrumbs'; + export const BREADCRUMB_ITEM = 'theia-breadcrumb-item'; + export const BREADCRUMB_POPUP_OVERLAY_CONTAINER = 'theia-breadcrumbs-popups-overlay'; + export const BREADCRUMB_POPUP = 'theia-breadcrumbs-popup'; + export const BREADCRUMB_ITEM_HAS_POPUP = 'theia-breadcrumb-item-haspopup'; +} + +/** A single breadcrumb in the breadcrumbs bar. */ +export interface Breadcrumb { + + /** An ID of this breadcrumb that should be unique in the breadcrumbs bar. */ + readonly id: string; + + /** The breadcrumb type. Should be the same as the contribution type `BreadcrumbsContribution#type`. */ + readonly type: symbol; + + /** The text that will be rendered as label. */ + readonly label: string; + + /** A longer text that will be used as tooltip text. */ + readonly longLabel: string; + + /** A CSS class for the icon. */ + readonly iconClass?: string; + + /** CSS classes for the container. */ + readonly containerClass?: string; +} + +export const BreadcrumbsContribution = Symbol('BreadcrumbsContribution'); +export interface BreadcrumbsContribution { + + /** + * The breadcrumb type. Breadcrumbs returned by `#computeBreadcrumbs(uri)` should have this as `Breadcrumb#type`. + */ + readonly type: symbol; + + /** + * The priority of this breadcrumbs contribution. Contributions are rendered left to right in order of ascending priority. + */ + readonly priority: number; + + /** + * An event emitter that should fire when breadcrumbs change for a given URI. + */ + readonly onDidChangeBreadcrumbs: Event; + + /** + * Computes breadcrumbs for a given URI. + */ + computeBreadcrumbs(uri: URI): MaybePromise; + + /** + * 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..30129fbc95e13 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx @@ -0,0 +1,187 @@ +/******************************************************************************** + * 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 { BreadcrumbsService } from './breadcrumbs-service'; +import { BreadcrumbRenderer } from './breadcrumb-renderer'; +import PerfectScrollbar from 'perfect-scrollbar'; +import URI from '../../common/uri'; +import { Emitter, Event } from '../../common'; +import { BreadcrumbPopupContainer } from './breadcrumb-popup-container'; +import { DisposableCollection } from '../../common/disposable'; +import { CorePreferences } from '../core-preferences'; +import { Breadcrumb, Styles } from './breadcrumbs-constants'; +import { LabelProvider } from '../label-provider'; + +interface Cancelable { + canceled: boolean; +} + +@injectable() +export class BreadcrumbsRenderer extends ReactRenderer { + + @inject(BreadcrumbsService) + protected readonly breadcrumbsService: BreadcrumbsService; + + @inject(BreadcrumbRenderer) + protected readonly breadcrumbRenderer: BreadcrumbRenderer; + + @inject(CorePreferences) + protected readonly corePreferences: CorePreferences; + + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + protected readonly onDidChangeActiveStateEmitter = new Emitter(); + get onDidChangeActiveState(): Event { + return this.onDidChangeActiveStateEmitter.event; + } + + protected uri: URI | undefined; + protected breadcrumbs: Breadcrumb[] = []; + protected popup: BreadcrumbPopupContainer | undefined; + protected scrollbar: PerfectScrollbar | undefined; + protected toDispose: DisposableCollection = new DisposableCollection(); + + get active(): boolean { + return !!this.breadcrumbs.length; + } + + protected get breadCrumbsContainer(): Element | undefined { + return this.host.firstElementChild ?? undefined; + } + + protected refreshCancellationMarker: Cancelable = { canceled: true }; + + @postConstruct() + protected init(): void { + this.toDispose.push(this.onDidChangeActiveStateEmitter); + this.toDispose.push(this.breadcrumbsService.onDidChangeBreadcrumbs(uri => { + if (this.uri?.isEqual(uri)) { + this.refresh(this.uri); + } + })); + this.toDispose.push(this.corePreferences.onPreferenceChanged(change => { + if (change.preferenceName === 'breadcrumbs.enabled') { + this.refresh(this.uri); + } + })); + this.toDispose.push(this.labelProvider.onDidChange(() => this.refresh(this.uri))); + } + + dispose(): void { + super.dispose(); + this.toDispose.dispose(); + if (this.popup) { this.popup.dispose(); } + if (this.scrollbar) { + this.scrollbar.destroy(); + this.scrollbar = undefined; + } + } + + async refresh(uri?: URI): Promise { + this.uri = uri; + this.refreshCancellationMarker.canceled = true; + const currentCallCanceled = { canceled: false }; + this.refreshCancellationMarker = currentCallCanceled; + let breadcrumbs: Breadcrumb[]; + if (uri && this.corePreferences['breadcrumbs.enabled']) { + breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(uri); + } else { + breadcrumbs = []; + } + if (currentCallCanceled.canceled) { + return; + } + + const wasActive = this.active; + this.breadcrumbs = breadcrumbs; + const isActive = this.active; + if (wasActive !== isActive) { + this.onDidChangeActiveStateEmitter.fire(isActive); + } + + this.update(); + } + + protected update(): void { + this.render(); + + if (!this.scrollbar) { + this.createScrollbar(); + } else { + this.scrollbar.update(); + } + this.scrollToEnd(); + } + + protected createScrollbar(): void { + const { breadCrumbsContainer } = this; + if (breadCrumbsContainer) { + this.scrollbar = new PerfectScrollbar(breadCrumbsContainer, { + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollXMarginOffset: 4, + suppressScrollY: true + }); + } + } + + protected scrollToEnd(): void { + const { breadCrumbsContainer } = this; + if (breadCrumbsContainer) { + breadCrumbsContainer.scrollLeft = breadCrumbsContainer.scrollWidth; + } + } + + protected doRender(): React.ReactNode { + return
      {this.renderBreadcrumbs()}
    ; + } + + 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?.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. If another breadcrumb was clicked, open the new popup immediately. + openPopup = this.popup.breadcrumbId !== breadcrumb.id; + } else { + this.popup = undefined; + } + if (openPopup) { + const { currentTarget } = event; + const breadcrumbElement = currentTarget.closest(`.${Styles.BREADCRUMB_ITEM}`); + if (breadcrumbElement) { + const { left: x, bottom: y } = breadcrumbElement.getBoundingClientRect(); + this.breadcrumbsService.openPopup(breadcrumb, { x, y }).then(popup => { this.popup = popup; }); + } + } + }; +} + +export const BreadcrumbsRendererFactory = Symbol('BreadcrumbsRendererFactory'); +export interface BreadcrumbsRendererFactory { + (): 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..d4b527a28437c --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/breadcrumbs-service.ts @@ -0,0 +1,108 @@ +/******************************************************************************** + * 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 { Coordinate } from '../context-menu-renderer'; +import { BreadcrumbPopupContainer, BreadcrumbPopupContainerFactory } from './breadcrumb-popup-container'; +import { BreadcrumbsContribution, Styles, Breadcrumb } from './breadcrumbs-constants'; + +@injectable() +export class BreadcrumbsService { + + @inject(ContributionProvider) @named(BreadcrumbsContribution) + protected readonly contributions: ContributionProvider; + + @inject(BreadcrumbPopupContainerFactory) protected readonly breadcrumbPopupContainerFactory: BreadcrumbPopupContainerFactory; + + protected hasSubscribed = false; + + protected popupsOverlayContainer: HTMLDivElement; + + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + + @postConstruct() + init(): void { + this.createOverlayContainer(); + } + + protected createOverlayContainer(): void { + this.popupsOverlayContainer = window.document.createElement('div'); + this.popupsOverlayContainer.id = Styles.BREADCRUMB_POPUP_OVERLAY_CONTAINER; + if (window.document.body) { + window.document.body.appendChild(this.popupsOverlayContainer); + } + } + + /** + * Subscribe to this event emitter to be notified when the breadcrumbs have changed. + * The URI is the URI of the editor the breadcrumbs have changed for. + */ + get onDidChangeBreadcrumbs(): Event { + // This lazy subscription is to address problems in inversify's instantiation routine + // related to use of the IconThemeService by different components instantiated by the + // ContributionProvider. + if (!this.hasSubscribed) { + this.subscribeToContributions(); + } + return this.onDidChangeBreadcrumbsEmitter.event; + } + + /** + * Subscribes to the onDidChangeBreadcrumbs events for all contributions. + */ + protected subscribeToContributions(): void { + this.hasSubscribed = true; + for (const contribution of this.contributions.getContributions()) { + contribution.onDidChangeBreadcrumbs(uri => 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: Coordinate): Promise { + const contribution = this.contributions.getContributions().find(c => c.type === breadcrumb.type); + if (contribution) { + const popup = this.breadcrumbPopupContainerFactory(this.popupsOverlayContainer, breadcrumb.id, position); + const popupContent = await contribution.attachPopupContent(breadcrumb, popup.container); + if (popupContent && popup.isOpen) { + popup.onDidDispose(() => popupContent.dispose()); + } else { + popupContent?.dispose(); + } + return popup; + } + } +} diff --git a/packages/core/src/browser/breadcrumbs/index.ts b/packages/core/src/browser/breadcrumbs/index.ts new file mode 100644 index 0000000000000..876c2a406e7a6 --- /dev/null +++ b/packages/core/src/browser/breadcrumbs/index.ts @@ -0,0 +1,21 @@ +/******************************************************************************** + * 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 './breadcrumbs-renderer'; +export * from './breadcrumbs-service'; +export * from './breadcrumbs-constants'; diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 0afbb5e5f7d2c..54743fe588b7d 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -1921,6 +1921,51 @@ export class CommonFrontendContribution implements FrontendApplicationContributi light: '#c5c5c5', hc: '#c5c5c5' }, description: 'Editor gutter decoration color for commenting ranges.' + }, + { + id: 'breadcrumb.foreground', + defaults: { + dark: Color.transparent('foreground', 0.8), + light: Color.transparent('foreground', 0.8), + hc: Color.transparent('foreground', 0.8), + }, + description: 'Color of breadcrumb item text' + }, + { + id: 'breadcrumb.background', + defaults: { + dark: 'editor.background', + light: 'editor.background', + hc: 'editor.background', + }, + description: 'Color of breadcrumb item background' + }, + { + id: 'breadcrumb.focusForeground', + defaults: { + dark: Color.lighten('foreground', 0.1), + light: Color.darken('foreground', 0.2), + hc: Color.lighten('foreground', 0.1), + }, + description: 'Color of breadcrumb item text when focused' + }, + { + id: 'breadcrumb.activeSelectionForeground', + defaults: { + dark: Color.lighten('foreground', 0.1), + light: Color.darken('foreground', 0.2), + hc: Color.lighten('foreground', 0.1), + }, + description: 'Color of selected breadcrumb item' + }, + { + id: 'breadcrumbPicker.background', + defaults: { + dark: 'editorWidget.background', + light: 'editorWidget.background', + hc: 'editorWidget.background', + }, + description: 'Background color of breadcrumb item picker' } ); } diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index b3f1f265683e3..59c0a43365fc7 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -20,12 +20,20 @@ import { injectable } from 'inversify'; import { MenuPath } from '../common/menu'; import { Disposable, DisposableCollection } from '../common/disposable'; -export type Anchor = MouseEvent | { x: number, y: number }; +export interface Coordinate { x: number; y: number; } +export const Coordinate = Symbol('Coordinate'); -export function toAnchor(anchor: HTMLElement | { x: number, y: number }): Anchor { +export type Anchor = MouseEvent | Coordinate; + +export function toAnchor(anchor: HTMLElement | Coordinate): Anchor { return anchor instanceof HTMLElement ? { x: anchor.offsetLeft, y: anchor.offsetTop } : anchor; } +export function coordinateFromAnchor(anchor: Anchor): Coordinate { + const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor; + return { x, y }; +} + export abstract class ContextMenuAccess implements Disposable { protected readonly toDispose = new DisposableCollection(); diff --git a/packages/core/src/browser/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..e4d3ab0381215 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -50,7 +50,7 @@ import { StatusBar, StatusBarImpl } from './status-bar/status-bar'; import { LabelParser } from './label-parser'; import { LabelProvider, LabelProviderContribution, DefaultUriLabelProviderContribution } from './label-provider'; import { PreferenceService } from './preferences'; -import { ContextMenuRenderer } from './context-menu-renderer'; +import { ContextMenuRenderer, Coordinate } from './context-menu-renderer'; import { ThemeService } from './theming'; import { ConnectionStatusService, FrontendConnectionStatusService, ApplicationConnectionStatusContribution, PingService } from './connection-status-service'; import { DiffUriLabelProviderContribution } from './diff-uris'; @@ -96,16 +96,28 @@ import { keytarServicePath, KeytarService } from '../common/keytar-protocol'; import { CredentialsService, CredentialsServiceImpl } from './credentials-service'; import { ContributionFilterRegistry, ContributionFilterRegistryImpl } from '../common/contribution-filter'; import { QuickCommandFrontendContribution } from './quick-input/quick-command-frontend-contribution'; -import { QuickHelpService } from './quick-input/quick-help-service'; import { QuickPickService, quickPickServicePath } from '../common/quick-pick-service'; import { QuickPickServiceImpl, - QuickInputFrontendContribution + QuickInputFrontendContribution, + QuickAccessContribution, + QuickCommandService, + QuickHelpService } from './quick-input'; -import { QuickAccessContribution } from './quick-input/quick-access'; -import { QuickCommandService } from './quick-input/quick-command-service'; import { SidebarBottomMenuWidget } from './shell/sidebar-bottom-menu-widget'; import { WindowContribution } from './window-contribution'; +import { + BreadcrumbID, + BreadcrumbPopupContainer, + BreadcrumbPopupContainerFactory, + BreadcrumbRenderer, + BreadcrumbsContribution, + BreadcrumbsRenderer, + BreadcrumbsRendererFactory, + BreadcrumbsService, + DefaultBreadcrumbRenderer, +} from './breadcrumbs'; +import { RendererHost } from './widgets'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -150,13 +162,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo return container.get(TabBarToolbar); }); - bind(DockPanelRendererFactory).toFactory(context => () => { - const { container } = context; - const tabBarToolbarRegistry = container.get(TabBarToolbarRegistry); - const tabBarRendererFactory: () => TabBarRenderer = container.get(TabBarRendererFactory); - const tabBarToolbarFactory: () => TabBarToolbar = container.get(TabBarToolbarFactory); - return new DockPanelRenderer(tabBarRendererFactory, tabBarToolbarRegistry, tabBarToolbarFactory); - }); + bind(DockPanelRendererFactory).toFactory(context => () => context.container.get(DockPanelRenderer)); bind(DockPanelRenderer).toSelf(); bind(TabBarRendererFactory).toFactory(context => () => { const contextMenuRenderer = context.container.get(ContextMenuRenderer); @@ -361,4 +367,22 @@ 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(); + bind(BreadcrumbsRenderer).toSelf(); + bind(BreadcrumbsRendererFactory).toFactory(ctx => + () => { + const childContainer = ctx.container.createChild(); + childContainer.bind(BreadcrumbRenderer).to(DefaultBreadcrumbRenderer).inSingletonScope(); + return childContainer.get(BreadcrumbsRenderer); + } + ); + bind(BreadcrumbPopupContainer).toSelf(); + bind(BreadcrumbPopupContainerFactory).toFactory(({ container }) => (parent: HTMLElement, breadcrumbId: string, position: Coordinate): BreadcrumbPopupContainer => { + const child = container.createChild(); + child.bind(RendererHost).toConstantValue(parent); + child.bind(BreadcrumbID).toConstantValue(breadcrumbId); + child.bind(Coordinate).toConstantValue(position); + return child.get(BreadcrumbPopupContainer); + }); }); diff --git a/packages/core/src/browser/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/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index e0d6a38b79149..2b664b75f0238 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -18,7 +18,7 @@ import { inject, injectable } from 'inversify'; import { Menu } from '../widgets'; -import { ContextMenuAccess, ContextMenuRenderer, RenderContextMenuOptions } from '../context-menu-renderer'; +import { ContextMenuAccess, ContextMenuRenderer, coordinateFromAnchor, RenderContextMenuOptions } from '../context-menu-renderer'; import { BrowserMainMenuFactory } from './browser-menu-plugin'; export class BrowserContextMenuAccess extends ContextMenuAccess { @@ -38,7 +38,7 @@ export class BrowserContextMenuRenderer extends ContextMenuRenderer { protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): BrowserContextMenuAccess { const contextMenu = this.menuFactory.createContextMenu(menuPath, args); - const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor!; + const { x, y } = coordinateFromAnchor(anchor); if (onHide) { contextMenu.aboutToClose.connect(() => onHide!()); } diff --git a/packages/core/src/browser/navigatable-types.ts b/packages/core/src/browser/navigatable-types.ts new file mode 100644 index 0000000000000..ae0a381627b21 --- /dev/null +++ b/packages/core/src/browser/navigatable-types.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import URI from '../common/uri'; +import { MaybeArray } from '../common/types'; +import { Widget, BaseWidget } from './widgets'; + +/** + * `Navigatable` provides an access to an URI of an underlying instance of `Resource`. + */ +export interface Navigatable { + /** + * Return an underlying resource URI. + */ + getResourceUri(): URI | undefined; + /** + * Creates a new URI to which this navigatable should moved based on the given target resource URI. + */ + createMoveToUri(resourceUri: URI): URI | undefined; +} + +export namespace Navigatable { + export function is(arg: Object | undefined): arg is Navigatable { + return !!arg && 'getResourceUri' in arg && 'createMoveToUri' in arg; + } +} + +export type NavigatableWidget = BaseWidget & Navigatable; +export namespace NavigatableWidget { + export function is(arg: Object | undefined): arg is NavigatableWidget { + return arg instanceof BaseWidget && Navigatable.is(arg); + } + export function* getAffected( + widgets: Iterable, + context: MaybeArray + ): IterableIterator<[URI, T & NavigatableWidget]> { + const uris = Array.isArray(context) ? context : [context]; + return get(widgets, resourceUri => uris.some(uri => uri.isEqualOrParent(resourceUri))); + } + export function* get( + widgets: Iterable, + filter: (resourceUri: URI) => boolean = () => true + ): IterableIterator<[URI, T & NavigatableWidget]> { + for (const widget of widgets) { + if (NavigatableWidget.is(widget)) { + const resourceUri = widget.getResourceUri(); + if (resourceUri && filter(resourceUri)) { + yield [resourceUri, widget]; + } + } + } + } +} + +export interface NavigatableWidgetOptions { + kind: 'navigatable', + uri: string, + counter?: number, +} +export namespace NavigatableWidgetOptions { + export function is(arg: Object | undefined): arg is NavigatableWidgetOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return !!arg && 'kind' in arg && (arg as any).kind === 'navigatable'; + } +} diff --git a/packages/core/src/browser/navigatable.ts b/packages/core/src/browser/navigatable.ts index 9429dc76ee007..9206549f2b7d3 100644 --- a/packages/core/src/browser/navigatable.ts +++ b/packages/core/src/browser/navigatable.ts @@ -15,68 +15,9 @@ ********************************************************************************/ import URI from '../common/uri'; -import { MaybeArray } from '../common/types'; -import { Widget, BaseWidget } from './widgets'; import { WidgetOpenHandler, WidgetOpenerOptions } from './widget-open-handler'; - -/** - * `Navigatable` provides an access to an URI of an underlying instance of `Resource`. - */ -export interface Navigatable { - /** - * Return an underlying resource URI. - */ - getResourceUri(): URI | undefined; - /** - * Creates a new URI to which this navigatable should moved based on the given target resource URI. - */ - createMoveToUri(resourceUri: URI): URI | undefined; -} - -export namespace Navigatable { - export function is(arg: Object | undefined): arg is Navigatable { - return !!arg && 'getResourceUri' in arg && 'createMoveToUri' in arg; - } -} - -export type NavigatableWidget = BaseWidget & Navigatable; -export namespace NavigatableWidget { - export function is(arg: Object | undefined): arg is NavigatableWidget { - return arg instanceof BaseWidget && Navigatable.is(arg); - } - export function* getAffected( - widgets: Iterable, - context: MaybeArray - ): IterableIterator<[URI, T & NavigatableWidget]> { - const uris = Array.isArray(context) ? context : [context]; - return get(widgets, resourceUri => uris.some(uri => uri.isEqualOrParent(resourceUri))); - } - export function* get( - widgets: Iterable, - filter: (resourceUri: URI) => boolean = () => true - ): IterableIterator<[URI, T & NavigatableWidget]> { - for (const widget of widgets) { - if (NavigatableWidget.is(widget)) { - const resourceUri = widget.getResourceUri(); - if (resourceUri && filter(resourceUri)) { - yield [resourceUri, widget]; - } - } - } - } -} - -export interface NavigatableWidgetOptions { - kind: 'navigatable', - uri: string, - counter?: number, -} -export namespace NavigatableWidgetOptions { - export function is(arg: Object | undefined): arg is NavigatableWidgetOptions { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return !!arg && 'kind' in arg && (arg as any).kind === 'navigatable'; - } -} +import { NavigatableWidget, NavigatableWidgetOptions } from './navigatable-types'; +export * from './navigatable-types'; export abstract class NavigatableWidgetOpenHandler extends WidgetOpenHandler { diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 0732c39415eba..ff0026676d375 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -38,6 +38,7 @@ import { Emitter } from '../../common/event'; import { waitForRevealed, waitForClosed } from '../widgets'; import { CorePreferences } from '../core-preferences'; import { environment } from '../../common'; +import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -80,19 +81,24 @@ export class DockPanelRenderer implements DockLayout.IRenderer { constructor( @inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: () => TabBarRenderer, @inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, - @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: () => TabBarToolbar + @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: () => TabBarToolbar, + @inject(BreadcrumbsRendererFactory) protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory, ) { } createTabBar(): TabBar { const renderer = this.tabBarRendererFactory(); - const tabBar = new ToolbarAwareTabBar(this.tabBarToolbarRegistry, this.tabBarToolbarFactory, { - renderer, - // Scroll bar options - handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], - useBothWheelAxes: true, - scrollXMarginOffset: 4, - suppressScrollY: true - }); + const tabBar = new ToolbarAwareTabBar( + this.tabBarToolbarRegistry, + this.tabBarToolbarFactory, + this.breadcrumbsRendererFactory, + { + renderer, + // Scroll bar options + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollXMarginOffset: 4, + suppressScrollY: true + }); this.tabBarClasses.forEach(c => tabBar.addClass(c)); renderer.tabBar = tabBar; tabBar.disposed.connect(() => renderer.dispose()); diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index d7ee97c36c8c0..65768bbad632a 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -20,7 +20,7 @@ import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/vir import { Disposable, DisposableCollection, MenuPath, notEmpty } from '../../common'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { Signal, Slot } from '@phosphor/signaling'; -import { Message } from '@phosphor/messaging'; +import { Message, MessageLoop } from '@phosphor/messaging'; import { ArrayExt } from '@phosphor/algorithm'; import { ElementExt } from '@phosphor/domutils'; import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar'; @@ -28,6 +28,8 @@ import { TheiaDockPanel, MAIN_AREA_ID, BOTTOM_AREA_ID } from './theia-dock-panel import { WidgetDecoration } from '../widget-decoration'; import { TabBarDecoratorService } from './tab-bar-decorator'; import { IconThemeService } from '../icon-theme-service'; +import { BreadcrumbsRenderer, BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; +import { NavigatableWidget } from '../navigatable-types'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -576,22 +578,38 @@ export class ScrollableTabBar extends TabBar { * * +-------------------------+-----------------+ * |[TAB_0][TAB_1][TAB_2][TAB| Toolbar | - * +-------------Scrollable--+-None-Scrollable-+ + * +-------------Scrollable--+-Non-Scrollable-+ * */ export class ToolbarAwareTabBar extends ScrollableTabBar { - protected contentContainer: HTMLElement | undefined; + protected contentContainer: HTMLElement; protected toolbar: TabBarToolbar | undefined; + protected breadcrumbsContainer: HTMLElement; + protected readonly breadcrumbsRenderer: BreadcrumbsRenderer; + protected topRow: HTMLElement; constructor( protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, protected readonly tabBarToolbarFactory: () => TabBarToolbar, - protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options) { - + protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory, + protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options, + ) { super(options); + this.breadcrumbsRenderer = this.breadcrumbsRendererFactory(); this.rewireDOM(); this.toDispose.push(this.tabBarToolbarRegistry.onDidChange(() => this.update())); + this.toDispose.push(this.breadcrumbsRenderer); + this.toDispose.push(this.breadcrumbsRenderer.onDidChangeActiveState(active => { + this.node.classList.toggle('theia-tabBar-multirow', active); + if (this.parent) { + MessageLoop.sendMessage(this.parent, new Message('fit-request')); + } + })); + this.node.classList.toggle('theia-tabBar-multirow', this.breadcrumbsRenderer.active); + const handler = () => this.updateBreadcrumbs(); + this.currentChanged.connect(handler); + this.toDispose.push(Disposable.create(() => this.currentChanged.disconnect(handler))); } /** @@ -612,12 +630,22 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { return this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER)[0] as HTMLElement; } + protected async updateBreadcrumbs(): Promise { + const current = this.currentTitle?.owner; + const uri = NavigatableWidget.is(current) ? current.getResourceUri() : undefined; + await this.breadcrumbsRenderer.refresh(uri); + } + protected onAfterAttach(msg: Message): void { if (this.toolbar) { if (this.toolbar.isAttached) { Widget.detach(this.toolbar); } - Widget.attach(this.toolbar, this.node); + Widget.attach(this.toolbar, this.topRow); + if (this.breadcrumbsContainer) { + this.node.appendChild(this.breadcrumbsContainer); + } + this.breadcrumbsRenderer?.refresh(); } super.onAfterAttach(msg); } @@ -660,16 +688,22 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { protected rewireDOM(): void { const contentNode = this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT)[0]; if (!contentNode) { - throw new Error("'this.node' does not have the content as a direct children with class name 'p-TabBar-content'."); + throw new Error("'this.node' does not have the content as a direct child with class name 'p-TabBar-content'."); } this.node.removeChild(contentNode); + this.topRow = document.createElement('div'); + this.topRow.classList.add('theia-tabBar-tab-row'); this.contentContainer = document.createElement('div'); this.contentContainer.classList.add(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER); this.contentContainer.appendChild(contentNode); - this.node.appendChild(this.contentContainer); + this.topRow.appendChild(this.contentContainer); + this.node.appendChild(this.topRow); this.toolbar = this.tabBarToolbarFactory(); + this.breadcrumbsContainer = document.createElement('div'); + this.breadcrumbsContainer.classList.add('theia-tabBar-breadcrumb-row'); + this.breadcrumbsContainer.appendChild(this.breadcrumbsRenderer.host); + this.node.appendChild(this.breadcrumbsContainer); } - } export namespace ToolbarAwareTabBar { diff --git a/packages/core/src/browser/style/breadcrumbs.css b/packages/core/src/browser/style/breadcrumbs.css new file mode 100644 index 0000000000000..f7c858c6a1141 --- /dev/null +++ b/packages/core/src/browser/style/breadcrumbs.css @@ -0,0 +1,131 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +:root { + --theia-breadcrumbs-height: 22px; +} + +.theia-breadcrumbs { + height: var(--theia-breadcrumbs-height); + position: relative; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; + outline-style: none; + margin: .5rem; + list-style-type: none; + overflow: hidden; + padding: 0; + margin: 0; + background-color: var(--theia-breadcrumb-background); +} + +.theia-breadcrumbs .ps__thumb-x { + /* Same scrollbar height as 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%; + color: var(--theia-breadcrumb-foreground); + outline: none; + padding: 0 .3rem 0 .25rem; +} + +.theia-breadcrumbs .theia-breadcrumb-item:hover { + color: var(--theia-breadcrumb-focusForeground); +} + +.theia-breadcrumbs .theia-breadcrumb-item:not(:last-of-type)::after { + font-family: codicon; + font-size: var(--theia-ui-font-size2); + content: "\eab6"; + display: flex; + align-items: center; + width: .8em; + text-align: right; + padding-left: 4px; +} + +.theia-breadcrumbs .theia-breadcrumb-item::before { + width: 16px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.theia-breadcrumbs .theia-breadcrumb-item:first-of-type::before { + content: " "; +} + +.theia-breadcrumb-item-haspopup:hover { + 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-breadcrumbPicker-background); + font-size: var(--theia-ui-font-size1); + color: var(--theia-ui-font-color1); + box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2); + 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/style/scrollbars.css b/packages/core/src/browser/style/scrollbars.css index cf731c6c3f2d7..94c79dbef8703 100644 --- a/packages/core/src/browser/style/scrollbars.css +++ b/packages/core/src/browser/style/scrollbars.css @@ -48,12 +48,14 @@ |----------------------------------------------------------------------------*/ #theia-app-shell .ps__rail-x, -#theia-dialog-shell .ps__rail-x { +#theia-dialog-shell .ps__rail-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x { height: var(--theia-scrollbar-rail-width); } #theia-app-shell .ps__rail-x > .ps__thumb-x, -#theia-dialog-shell .ps__rail-x > .ps__thumb-x { +#theia-dialog-shell .ps__rail-x > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__thumb-x { height: var(--theia-scrollbar-width); bottom: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); background: var(--theia-scrollbarSlider-background); @@ -63,7 +65,9 @@ #theia-app-shell .ps__rail-x:hover, #theia-app-shell .ps__rail-x:focus, #theia-dialog-shell .ps__rail-x:hover, -#theia-dialog-shell .ps__rail-x:focus { +#theia-dialog-shell .ps__rail-x:focus, +#theia-breadcrumbs-popups-overlay .ps__rail-x:hover, +#theia-breadcrumbs-popups-overlay .ps__rail-x:focus { height: var(--theia-scrollbar-rail-width); } @@ -72,17 +76,22 @@ #theia-app-shell .ps__rail-x.ps--clicking .ps__thumb-x, #theia-dialog-shell .ps__rail-x:hover > .ps__thumb-x, #theia-dialog-shell .ps__rail-x:focus > .ps__thumb-x, -#theia-dialog-shell .ps__rail-x.ps--clicking .ps__thumb-x { +#theia-dialog-shell .ps__rail-x.ps--clicking .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x:hover > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x:focus > .ps__thumb-x, +#theia-breadcrumbs-popups-overlay .ps__rail-x.ps--clicking .ps__thumb-x { height: var(--theia-scrollbar-width); } #theia-app-shell .ps__rail-y, -#theia-dialog-shell .ps__rail-y { +#theia-dialog-shell .ps__rail-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y { width: var(--theia-scrollbar-rail-width); } #theia-app-shell .ps__rail-y > .ps__thumb-y, -#theia-dialog-shell .ps__rail-y > .ps__thumb-y { +#theia-dialog-shell .ps__rail-y > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y > .ps__thumb-y { width: var(--theia-scrollbar-width); right: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); background: var(--theia-scrollbarSlider-background); @@ -92,7 +101,9 @@ #theia-app-shell .ps__rail-y:hover, #theia-app-shell .ps__rail-y:focus, #theia-dialog-shell .ps__rail-y:hover, -#theia-dialog-shell .ps__rail-y:focus { +#theia-dialog-shell .ps__rail-y:focus, +#theia-breadcrumbs-popups-overlay .ps__rail-y:hover, +#theia-breadcrumbs-popups-overlay .ps__rail-y:focus { width: var(--theia-scrollbar-rail-width); } @@ -101,27 +112,32 @@ #theia-app-shell .ps__rail-y.ps--clicking .ps__thumb-y, #theia-dialog-shell .ps__rail-y:hover > .ps__thumb-y, #theia-dialog-shell .ps__rail-y:focus > .ps__thumb-y, -#theia-dialog-shell .ps__rail-y.ps--clicking .ps__thumb-y { +#theia-dialog-shell .ps__rail-y.ps--clicking .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y:hover > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y:focus > .ps__thumb-y, +#theia-breadcrumbs-popups-overlay .ps__rail-y.ps--clicking .ps__thumb-y { right: calc((var(--theia-scrollbar-rail-width) - var(--theia-scrollbar-width)) / 2); width: var(--theia-scrollbar-width); } #theia-app-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'], -#theia-dialog-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb']{ +#theia-dialog-shell .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'], +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'].ps--clicking > [class^='ps__thumb'] { background-color: var(--theia-scrollbarSlider-activeBackground); } #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus, #theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, -#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus -{ +#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:focus, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:hover, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:focus { background: var(--theia-scrollbarSlider-hoverBackground); } #theia-app-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active, -#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active -{ +#theia-dialog-shell .ps [class^='ps__rail'] > [class^='ps__thumb']:active, +#theia-breadcrumbs-popups-overlay .ps [class^='ps__rail'] > [class^='ps__thumb']:active { background: var(--theia-scrollbarSlider-activeBackground); } @@ -130,7 +146,10 @@ #theia-app-shell .ps--scrolling-y > [class^='ps__rail'], #theia-dialog-shell .ps:hover > [class^='ps__rail'], #theia-dialog-shell .ps--focus > [class^='ps__rail'], -#theia-dialog-shell .ps--scrolling-y > [class^='ps__rail'] { +#theia-dialog-shell .ps--scrolling-y > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps:hover > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps--focus > [class^='ps__rail'], +#theia-breadcrumbs-popups-overlay .ps--scrolling-y > [class^='ps__rail'] { opacity: 1; background: transparent; } diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 112fa4c685405..e369c48720421 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -9,6 +9,7 @@ --theia-private-horizontal-tab-scrollbar-height: 5px; --theia-tabbar-toolbar-z-index: 1001; --theia-toolbar-active-transform-scale: 1.272019649; + --theia-horizontal-toolbar-height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); } /*----------------------------------------------------------------------------- @@ -22,7 +23,7 @@ .p-TabBar[data-orientation='horizontal'] { overflow-x: hidden; overflow-y: hidden; - min-height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); + min-height: var(--theia-horizontal-toolbar-height); } .p-TabBar .p-TabBar-content { @@ -31,7 +32,7 @@ .p-TabBar[data-orientation='horizontal'] .p-TabBar-tab { flex: none; - height: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2); + height: var(--theia-horizontal-toolbar-height); min-width: 35px; line-height: var(--theia-private-horizontal-tab-height); padding: 0px 8px; @@ -279,23 +280,23 @@ body.theia-editor-highlightModifiedTabs | Perfect scrollbar |----------------------------------------------------------------------------*/ -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x { height: var(--theia-private-horizontal-tab-scrollbar-rail-height); z-index: 1000; } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x > .ps__thumb-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x > .ps__thumb-x { height: var(--theia-private-horizontal-tab-scrollbar-height) !important; bottom: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:hover, -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:focus { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:hover, +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:focus { height: var(--theia-private-horizontal-tab-scrollbar-rail-height) !important; } -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:hover > .ps__thumb-x, -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content-container > .ps__rail-x:focus > .ps__thumb-x { +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:hover > .ps__thumb-x, +.p-TabBar[data-orientation='horizontal'] .p-TabBar-content-container > .ps__rail-x:focus > .ps__thumb-x { height: calc(var(--theia-private-horizontal-tab-scrollbar-height) / 2) !important; bottom: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } @@ -387,3 +388,18 @@ body.theia-editor-highlightModifiedTabs .p-TabBar-toolbar .item .cancel { background: var(--theia-icon-close) no-repeat; } + +.theia-tabBar-breadcrumb-row { + min-width: 100%; +} + +.p-TabBar.theia-tabBar-multirow[data-orientation='horizontal'] { + min-height: calc(var(--theia-breadcrumbs-height) + var(--theia-horizontal-toolbar-height)); + flex-direction: column; +} + +.theia-tabBar-tab-row { + display: flex; + flex-flow: row nowrap; + min-width: 100%; +} diff --git a/packages/core/src/browser/widgets/react-renderer.tsx b/packages/core/src/browser/widgets/react-renderer.tsx index b21e8f6617521..0db1c2595e9b6 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -14,16 +14,19 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; +import { inject, injectable, optional } from 'inversify'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Disposable } from '../../common'; +export type RendererHost = HTMLElement; +export const RendererHost = Symbol('RendererHost'); + @injectable() export class ReactRenderer implements Disposable { readonly host: HTMLElement; constructor( - host?: HTMLElement + @inject(RendererHost) @optional() host?: RendererHost ) { this.host = host || document.createElement('div'); } diff --git a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index 537958969084c..c73b72d4565d2 100644 --- a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts +++ b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts @@ -18,7 +18,7 @@ import * as electron from '../../../shared/electron'; import { inject, injectable } from 'inversify'; -import { ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands } from '../../browser'; +import { ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ContextMenuContext } from '../../browser/menu/context-menu-context'; import { MenuPath, MenuContribution, MenuModelRegistry } from '../../common'; @@ -84,7 +84,7 @@ export class ElectronContextMenuRenderer extends ContextMenuRenderer { protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): ElectronContextMenuAccess { const menu = this.menuFactory.createContextMenu(menuPath, args); - const { x, y } = anchor instanceof MouseEvent ? { x: anchor.clientX, y: anchor.clientY } : anchor!; + const { x, y } = coordinateFromAnchor(anchor); const zoom = electron.webFrame.getZoomFactor(); // x and y values must be Ints or else there is a conversion error menu.popup({ x: Math.round(x * zoom), y: Math.round(y * zoom) }); diff --git a/packages/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..4ab4303fdc4c0 --- /dev/null +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumb.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * 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 '@theia/core/lib/common/uri'; +import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-constants'; +import { FilepathBreadcrumbType } from './filepath-breadcrumbs-contribution'; + +export class FilepathBreadcrumb implements Breadcrumb { + constructor( + readonly uri: URI, + readonly label: string, + readonly longLabel: string, + readonly iconClass: string, + readonly containerClass: 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..3c47fd0064cc5 --- /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 '@theia/core/shared/inversify'; +import { TreeProps, ContextMenuRenderer, TreeNode, OpenerService, open, NodeProps, defaultTreeProps } from '@theia/core/lib/browser'; +import { createFileTreeContainer, FileTreeWidget } from '../'; +import { FileTreeModel, FileStatNode } from '../file-tree'; + +const BREADCRUMBS_FILETREE_CLASS = 'theia-FilepathBreadcrumbFileTree'; + +export function createFileTreeBreadcrumbsContainer(parent: interfaces.Container): Container { + const child = createFileTreeContainer(parent); + child.unbind(FileTreeWidget); + child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, virtualized: false }); + child.bind(BreadcrumbsFileTreeWidget).toSelf(); + return child; +} + +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) { + open(this.openerService, node.uri, { preview: true }); + } else { + super.handleClickEvent(node, event); + } + } +} diff --git a/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts new file mode 100644 index 0000000000000..7663cd01ddb35 --- /dev/null +++ b/packages/filesystem/src/browser/breadcrumbs/filepath-breadcrumbs-contribution.ts @@ -0,0 +1,129 @@ +/******************************************************************************** + * 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, Emitter, Event } from '@theia/core'; +import { injectable, inject } from '@theia/core/shared/inversify'; +import URI from '@theia/core/lib/common/uri'; +import { Breadcrumb, BreadcrumbsContribution, CompositeTreeNode, LabelProvider, SelectableTreeNode, Widget } from '@theia/core/lib/browser'; +import { FilepathBreadcrumb } from './filepath-breadcrumb'; +import { BreadcrumbsFileTreeWidget } from './filepath-breadcrumbs-container'; +import { DirNode } from '../file-tree'; +import { FileService } from '../file-service'; +import { FileStat } from '../../common/files'; + +export const FilepathBreadcrumbType = Symbol('FilepathBreadcrumb'); + +export interface FilepathBreadcrumbClassNameFactory { + (location: URI, index: number): string; +} + +@injectable() +export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution { + + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + @inject(FileService) + protected readonly fileSystem: FileService; + + @inject(BreadcrumbsFileTreeWidget) + protected readonly breadcrumbsFileTreeWidget: BreadcrumbsFileTreeWidget; + + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + get onDidChangeBreadcrumbs(): Event { + return this.onDidChangeBreadcrumbsEmitter.event; + } + + readonly type = FilepathBreadcrumbType; + readonly priority: number = 100; + + async computeBreadcrumbs(uri: URI): Promise { + if (uri.scheme !== 'file') { + return []; + } + const getContainerClass = this.getContainerClassCreator(uri); + const getIconClass = this.getIconClassCreator(uri); + return uri.allLocations + .map((location, index) => { + const icon = getIconClass(location, index); + const containerClass = getContainerClass(location, index); + return new FilepathBreadcrumb( + location, + this.labelProvider.getName(location), + this.labelProvider.getLongName(location), + icon, + containerClass, + ); + }) + .filter(b => this.filterBreadcrumbs(uri, b)) + .reverse(); + } + + protected getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + return (location, index) => location.isEqual(fileURI) ? 'file' : 'folder'; + } + + protected getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + return (location, index) => location.isEqual(fileURI) ? this.labelProvider.getIcon(location) + ' file-icon' : ''; + } + + protected filterBreadcrumbs(_: URI, breadcrumb: FilepathBreadcrumb): boolean { + return !breadcrumb.uri.path.isRoot; + } + + async attachPopupContent(breadcrumb: Breadcrumb, parent: HTMLElement): Promise { + if (!FilepathBreadcrumb.is(breadcrumb)) { + return undefined; + } + const folderFileStat = await this.fileSystem.resolve(breadcrumb.uri.parent); + if (folderFileStat) { + const rootNode = await this.createRootNode(folderFileStat); + if (rootNode) { + const { model } = this.breadcrumbsFileTreeWidget; + await model.navigateTo({ ...rootNode, visible: false }); + Widget.attach(this.breadcrumbsFileTreeWidget, parent); + const toDisposeOnTreePopulated = model.onChanged(() => { + if (CompositeTreeNode.is(model.root) && model.root.children.length > 0) { + toDisposeOnTreePopulated.dispose(); + const targetNode = model.getNode(breadcrumb.uri.path.toString()); + if (targetNode && SelectableTreeNode.is(targetNode)) { + model.selectNode(targetNode); + } + this.breadcrumbsFileTreeWidget.activate(); + } + }); + return { + dispose: () => { + // Clear model otherwise the next time a popup is opened the old model is rendered first + // and is shown for a short time period. + toDisposeOnTreePopulated.dispose(); + this.breadcrumbsFileTreeWidget.model.root = undefined; + Widget.detach(this.breadcrumbsFileTreeWidget); + } + }; + } + } + } + + protected async createRootNode(folderToOpen: FileStat): Promise { + const folderUri = folderToOpen.resource; + const rootUri = folderToOpen.isDirectory ? folderUri : folderUri.parent; + const rootStat = await this.fileSystem.resolve(rootUri); + if (rootStat) { + return DirNode.createRoot(rootStat); + } + } +} diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index ff0415fdbe1bf..3e7d70bcb2d4d 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -18,7 +18,7 @@ import '../../src/browser/style/index.css'; import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; import { ResourceResolver, CommandContribution } from '@theia/core/lib/common'; -import { WebSocketConnectionProvider, FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser'; +import { WebSocketConnectionProvider, FrontendApplicationContribution, LabelProviderContribution, BreadcrumbsContribution } from '@theia/core/lib/browser'; import { FileResourceResolver } from './file-resource'; import { bindFileSystemPreferences } from './filesystem-preferences'; import { FileSystemWatcher } from './filesystem-watcher'; @@ -36,6 +36,8 @@ 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 { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container'; export default new ContainerModule(bind => { bindFileSystemPreferences(bind); @@ -217,6 +219,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..6c15e0ff49ee3 --- /dev/null +++ b/packages/filesystem/src/browser/style/filepath-breadcrumbs.css @@ -0,0 +1,20 @@ +/******************************************************************************** + * 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: auto; + max-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..c8cdbe3b10943 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 update 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..f25ab1fdaedb4 --- /dev/null +++ b/packages/outline-view/src/browser/outline-breadcrumbs-contribution.tsx @@ -0,0 +1,231 @@ +/******************************************************************************** + * 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 '@theia/core/shared/react'; +import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; +import { LabelProvider, BreadcrumbsService, Widget, TreeNode, OpenerService, open, SelectableTreeNode, BreadcrumbsContribution, Breadcrumb } from '@theia/core/lib/browser'; +import URI from '@theia/core/lib/common/uri'; +import { OutlineViewService } from './outline-view-service'; +import { OutlineSymbolInformationNode, OutlineViewWidget } from './outline-view-widget'; +import { Disposable, DisposableCollection, Emitter, Event } from '@theia/core/lib/common'; +import { UriSelection } from '@theia/core/lib/common'; + +export const OutlineBreadcrumbType = Symbol('OutlineBreadcrumb'); +export const BreadcrumbPopupOutlineViewFactory = Symbol('BreadcrumbPopupOutlineViewFactory'); +export const OUTLINE_BREADCRUMB_CONTAINER_CLASS = 'outline-element'; +export interface BreadcrumbPopupOutlineViewFactory { + (): BreadcrumbPopupOutlineView; +} +export class BreadcrumbPopupOutlineView extends OutlineViewWidget { + @inject(OpenerService) protected readonly openerService: OpenerService; + + protected handleClickEvent(node: TreeNode | undefined, event: React.MouseEvent): void { + if (UriSelection.is(node) && OutlineSymbolInformationNode.hasRange(node)) { + open(this.openerService, node.uri, { selection: node.range }); + } else { + super.handleClickEvent(node, event); + } + } + + cloneState(roots: OutlineSymbolInformationNode[]): void { + const nodes = this.reconcileTreeState(roots); + const root = this.getRoot(nodes); + this.model.root = this.inflateFromStorage(this.deflateForStorage(root)); + } +} + +@injectable() +export class OutlineBreadcrumbsContribution implements BreadcrumbsContribution { + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + @inject(OutlineViewService) + protected readonly outlineViewService: OutlineViewService; + + @inject(BreadcrumbsService) + protected readonly breadcrumbsService: BreadcrumbsService; + + @inject(BreadcrumbPopupOutlineViewFactory) + protected readonly outlineFactory: BreadcrumbPopupOutlineViewFactory; + + protected outlineView: BreadcrumbPopupOutlineView; + + readonly type = OutlineBreadcrumbType; + readonly priority: number = 200; + + protected currentUri: URI | undefined = undefined; + protected currentBreadcrumbs: OutlineBreadcrumb[] = []; + protected roots: OutlineSymbolInformationNode[] = []; + + protected readonly onDidChangeBreadcrumbsEmitter = new Emitter(); + get onDidChangeBreadcrumbs(): Event { + return this.onDidChangeBreadcrumbsEmitter.event; + } + + @postConstruct() + init(): void { + this.outlineView = this.outlineFactory(); + this.outlineView.node.style.height = 'auto'; + this.outlineView.node.style.maxHeight = '200px'; + this.outlineViewService.onDidChangeOutline(roots => { + if (roots.length > 0) { + this.roots = roots; + const first = roots[0]; + if (UriSelection.is(first)) { + this.updateOutlineItems(first.uri, this.findSelectedNode(roots)); + } + } else { + this.currentBreadcrumbs = []; + this.roots = []; + } + }); + this.outlineViewService.onDidSelect(node => { + if (UriSelection.is(node)) { + this.updateOutlineItems(node.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(), + this.labelProvider.getName(node), + 'symbol-icon symbol-icon-center ' + node.iconClass, + OUTLINE_BREADCRUMB_CONTAINER_CLASS, + ) + ); + if (selectedNode.children && selectedNode.children.length > 0) { + this.currentBreadcrumbs.push(new OutlineBreadcrumb( + selectedNode.children as OutlineSymbolInformationNode[], + uri, + this.currentBreadcrumbs.length.toString(), + '…', + '', + OUTLINE_BREADCRUMB_CONTAINER_CLASS, + )); + } + } else { + this.currentBreadcrumbs = []; + if (this.roots) { + this.currentBreadcrumbs.push(new OutlineBreadcrumb( + this.roots, + uri, + this.currentBreadcrumbs.length.toString(), + '…', + '', + OUTLINE_BREADCRUMB_CONTAINER_CLASS + )); + } + } + this.onDidChangeBreadcrumbsEmitter.fire(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 node = Array.isArray(breadcrumb.node) ? breadcrumb.node[0] : breadcrumb.node; + if (!node.parent) { + return undefined; + } + const siblings = node.parent.children.filter((child): child is OutlineSymbolInformationNode => OutlineSymbolInformationNode.is(child)); + + const toDisposeOnHide = new DisposableCollection(); + this.outlineView.cloneState(siblings); + this.outlineView.model.selectNode(node); + this.outlineView.model.collapseAll(); + Widget.attach(this.outlineView, parent); + this.outlineView.activate(); + toDisposeOnHide.pushAll([ + this.outlineView.model.onExpansionChanged(expandedNode => SelectableTreeNode.is(expandedNode) && this.outlineView.model.selectNode(expandedNode)), + Disposable.create(() => { + this.outlineView.model.root = undefined; + Widget.detach(this.outlineView); + }), + ]); + return toDisposeOnHide; + } + + /** + * Returns the path of the given outline node. + */ + protected 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. + */ + protected 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, + readonly containerClass: 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..a3534056196dd 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,30 +36,32 @@ 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 { BreadcrumbPopupOutlineView, BreadcrumbPopupOutlineViewFactory, OutlineBreadcrumbsContribution } from './outline-breadcrumbs-contribution'; export default new ContainerModule(bind => { bind(OutlineViewWidgetFactory).toFactory(ctx => () => createOutlineViewWidget(ctx.container) ); + bind(BreadcrumbPopupOutlineViewFactory).toFactory(({ container }) => () => { + const child = createOutlineViewWidgetContainer(container); + child.rebind(OutlineViewWidget).to(BreadcrumbPopupOutlineView); + child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, expandOnlyOnExpansionToggleClick: true, search: false, virtualized: false }); + return child.get(OutlineViewWidget); + }); + bind(OutlineViewService).toSelf().inSingletonScope(); bind(WidgetFactory).toService(OutlineViewService); bindViewContribution(bind, OutlineViewContribution); bind(FrontendApplicationContribution).toService(OutlineViewContribution); bind(TabBarToolbarContribution).toService(OutlineViewContribution); + + bind(OutlineBreadcrumbsContribution).toSelf().inSingletonScope(); + bind(BreadcrumbsContribution).toService(OutlineBreadcrumbsContribution); }); -/** - * Create an `OutlineViewWidget`. - * - The creation of the `OutlineViewWidget` includes: - * - The creation of the tree widget itself with it's own customized props. - * - The binding of necessary components into the container. - * @param parent the Inversify container. - * - * @returns the `OutlineViewWidget`. - */ -function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidget { +function createOutlineViewWidgetContainer(parent: interfaces.Container): interfaces.Container { const child = createTreeContainer(parent); child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, expandOnlyOnExpansionToggleClick: true, search: true }); @@ -73,6 +76,20 @@ function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidge child.bind(OutlineDecoratorService).toSelf().inSingletonScope(); child.rebind(TreeDecoratorService).toDynamicValue(ctx => ctx.container.get(OutlineDecoratorService)).inSingletonScope(); bindContributionProvider(child, OutlineTreeDecorator); + return child; +} + +/** + * Create an `OutlineViewWidget`. + * - The creation of the `OutlineViewWidget` includes: + * - The creation of the tree widget itself with it's own customized props. + * - The binding of necessary components into the container. + * @param parent the Inversify container. + * + * @returns the `OutlineViewWidget`. + */ +function createOutlineViewWidget(parent: interfaces.Container): OutlineViewWidget { + const child = createOutlineViewWidgetContainer(parent); return child.get(OutlineViewWidget); } diff --git a/packages/outline-view/src/browser/outline-view-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/outline-view/src/browser/outline-view-widget.tsx b/packages/outline-view/src/browser/outline-view-widget.tsx index b2a897b564759..40d4791c02248 100644 --- a/packages/outline-view/src/browser/outline-view-widget.tsx +++ b/packages/outline-view/src/browser/outline-view-widget.tsx @@ -27,9 +27,11 @@ import { } from '@theia/core/lib/browser'; import { OutlineViewTreeModel } from './outline-view-tree-model'; import { Message } from '@theia/core/shared/@phosphor/messaging'; -import { Emitter } from '@theia/core'; +import { Emitter, Mutable, UriSelection } from '@theia/core'; import { CompositeTreeNode } from '@theia/core/lib/browser'; import * as React from '@theia/core/shared/react'; +import { Range } from '@theia/core/shared/vscode-languageserver-types'; +import URI from '@theia/core/lib/common/uri'; /** * Representation of an outline symbol information node. @@ -58,6 +60,10 @@ export namespace OutlineSymbolInformationNode { export function is(node: TreeNode): node is OutlineSymbolInformationNode { return !!node && SelectableTreeNode.is(node) && 'iconClass' in node; } + + export function hasRange(node: unknown): node is { range: Range } { + return typeof node === 'object' && !!node && 'range' in node && Range.is((node as { range: Range }).range); + } } export type OutlineViewWidgetFactory = () => OutlineViewWidget; @@ -91,13 +97,17 @@ export class OutlineViewWidget extends TreeWidget { // Gather the list of available nodes. const nodes = this.reconcileTreeState(roots); // Update the model root node, appending the outline symbol information nodes as children. - this.model.root = { + this.model.root = this.getRoot(nodes); + } + + protected getRoot(children: TreeNode[]): CompositeTreeNode { + return { id: 'outline-view-root', name: 'Outline Root', visible: false, - children: nodes, + children, parent: undefined - } as CompositeTreeNode; + }; } /** @@ -171,4 +181,19 @@ export class OutlineViewWidget extends TreeWidget { return super.renderTree(model); } + protected deflateForStorage(node: TreeNode): object { + const deflated = super.deflateForStorage(node) as { uri: string }; + if (UriSelection.is(node)) { + deflated.uri = node.uri.toString(); + } + return deflated; + } + + protected inflateFromStorage(node: any, parent?: TreeNode): TreeNode { /* eslint-disable-line @typescript-eslint/no-explicit-any */ + const inflated = super.inflateFromStorage(node, parent) as Mutable; + if (node && 'uri' in node && typeof node.uri === 'string') { + inflated.uri = new URI(node.uri); + } + return inflated; + } } diff --git a/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts b/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts index 1cae03025f0a8..8740ae7e074eb 100644 --- a/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts +++ b/packages/plugin-ext/src/main/browser/plugin-icon-theme-service.ts @@ -36,6 +36,7 @@ import { WorkspaceRootNode } from '@theia/navigator/lib/browser/navigator-tree'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat, FileChangeType } from '@theia/filesystem/lib/common/files'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; export interface PluginIconDefinition { iconPath: string; @@ -107,6 +108,9 @@ export class PluginIconTheme extends PluginIconThemeDefinition implements IconTh @inject(PluginIconThemeDefinition) protected readonly definition: PluginIconThemeDefinition; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; @@ -508,9 +512,15 @@ export class PluginIconTheme extends PluginIconThemeDefinition implements IconTh const name = this.labelProvider.getName(element); const classNames = this.fileNameIcon(name); if (uri) { - const language = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(monaco.Uri.parse(uri)); + const parsedURI = new URI(uri); + const isRoot = this.workspaceService.getWorkspaceRootUri(new URI(uri))?.isEqual(parsedURI); + if (isRoot) { + classNames.unshift(this.rootFolderIcon); + } else { + classNames.unshift(this.fileIcon); + } + const language = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(parsedURI['codeUri']); classNames.push(this.languageIcon(language.languageIdentifier.language)); - classNames.unshift(this.fileIcon); } return classNames; } diff --git a/packages/preferences/src/browser/util/preference-tree-generator.ts b/packages/preferences/src/browser/util/preference-tree-generator.ts index c92b454990908..8c67a0569df28 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.ts +++ b/packages/preferences/src/browser/util/preference-tree-generator.ts @@ -48,6 +48,7 @@ export class PreferenceTreeGenerator { ['extensions', 'Extensions'] ]); protected readonly sectionAssignments = new Map([ + ['breadcrumbs', 'workbench'], ['comments', 'features'], ['debug', 'features'], ['diffEditor', 'editor'], diff --git a/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts new file mode 100644 index 0000000000000..5903c2f4beb7c --- /dev/null +++ b/packages/workspace/src/browser/workspace-breadcrumbs-contribution.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * 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 { FilepathBreadcrumbClassNameFactory, FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { WorkspaceService } from './workspace-service'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class WorkspaceBreadcrumbsContribution extends FilepathBreadcrumbsContribution { + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI); + return (location, index) => { + if (location.isEqual(fileURI)) { + return 'file'; + } else if (workspaceRoot?.isEqual(location)) { + return 'root_folder'; + } + return 'folder'; + }; + } + + getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory { + const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI); + return (location, index) => { + if (location.isEqual(fileURI) || workspaceRoot?.isEqual(location)) { + return this.labelProvider.getIcon(location) + ' file-icon'; + } + return ''; + }; + } + + protected filterBreadcrumbs(uri: URI, breadcrumb: FilepathBreadcrumb): boolean { + const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri); + const firstCrumbToHide = this.workspaceService.isMultiRootWorkspaceOpened ? workspaceRootUri?.parent : workspaceRootUri; + return super.filterBreadcrumbs(uri, breadcrumb) && (!firstCrumbToHide || !breadcrumb.uri.isEqualOrParent(firstCrumbToHide)); + } +} diff --git a/packages/workspace/src/browser/workspace-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(); }); diff --git a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts index 46661f03372af..6c53a5ed1a6a1 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts @@ -100,13 +100,13 @@ describe('WorkspaceUriLabelProviderContribution class', () => { expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon); }); - it('should return folder icon from a folder URI', async () => { + it('should return folder icon from a folder FileStat', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); expect(labelProvider.getIcon(FileStat.dir('file:///home/test'))).eq(labelProvider.defaultFolderIcon); }); - it('should return file icon from a file URI', async () => { + it('should return file icon from a file FileStat', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon); @@ -119,6 +119,11 @@ describe('WorkspaceUriLabelProviderContribution class', () => { expect(labelProvider.getIcon(new URI('file:///home/test'))).eq(ret); expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(ret); }); + + it('should return rootfolder-icon for a URI or file stat that corresponds to a workspace root', () => { + expect(labelProvider.getIcon(new URI('file:///workspace'))).eq('rootfolder-icon'); + expect(labelProvider.getIcon(FileStat.dir('file:///workspace'))).eq('rootfolder-icon'); + }); }); describe('getName()', () => { diff --git a/packages/workspace/src/browser/workspace-uri-contribution.ts b/packages/workspace/src/browser/workspace-uri-contribution.ts index afd07c37ecb3e..4db41501821f1 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.ts @@ -39,6 +39,10 @@ export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProvid } getIcon(element: URI | URIIconReference | FileStat): string { + const uri = this.getUri(element); + if (uri && this.workspaceVariable.getWorkspaceRootUri(uri)?.isEqual(uri)) { + return 'rootfolder-icon'; + } return super.getIcon(this.asURIIconReference(element)); }