From e35e597a32c0dd3d9e2cbaac65517ecd37c29015 Mon Sep 17 00:00:00 2001 From: "Cornelius A. Ludmann" Date: Sat, 12 Oct 2019 23:41:18 +0000 Subject: [PATCH] Add breadcrumbs bar to editor widget This commit adds a breadcrumbs bar to the editor widget. It shows the path to the current file and outline information as breadcrumbs. A click of breadcrumbs allows to switch to siblings. Fixes #5475 Signed-off-by: Cornelius A. Ludmann --- CHANGELOG.md | 1 + .../src/browser/widgets/react-renderer.tsx | 2 + packages/editor/package.json | 6 +- .../browser/breadcrumbs/breadcrumbs-items.tsx | 186 +++++++++++++++++ .../breadcrumbs/breadcrumbs-popups.tsx | 88 ++++++++ .../breadcrumbs/breadcrumbs-renderer.tsx | 195 ++++++++++++++++++ .../browser/breadcrumbs/breadcrumbs-utils.ts | 96 +++++++++ .../src/browser/breadcrumbs/breadcrumbs.css | 84 ++++++++ .../src/browser/breadcrumbs/breadcrumbs.ts | 25 +++ .../src/browser/editor-frontend-module.ts | 11 + .../src/browser/editor-widget-factory.ts | 7 +- packages/editor/src/browser/editor-widget.ts | 21 +- .../browser/monaco-outline-contribution.ts | 30 +-- .../src/browser/outline-view-service.ts | 4 +- 14 files changed, 733 insertions(+), 23 deletions(-) create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs-items.tsx create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs-popups.tsx create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs-renderer.tsx create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs-utils.ts create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs.css create mode 100644 packages/editor/src/browser/breadcrumbs/breadcrumbs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1a4bba6deb8..ff831291f6d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [cli] enable static compression of build artifacts [#6266](https://github.com/eclipse-theia/theia/pull/6266) - to disable pass `--no-static-compression` to `theia build` or `theia watch`. +- [editor] added breadcrumbs bar Breaking changes: diff --git a/packages/core/src/browser/widgets/react-renderer.tsx b/packages/core/src/browser/widgets/react-renderer.tsx index d75f02e0aaa60..1fc7e17425d8e 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -17,7 +17,9 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Disposable } from '../../common'; +import { injectable } from 'inversify'; +@injectable() export class ReactRenderer implements Disposable { readonly host: HTMLElement; constructor( diff --git a/packages/editor/package.json b/packages/editor/package.json index b515c84b0480f..cfcb2377ecd3b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -4,10 +4,14 @@ "description": "Theia - Editor Extension", "dependencies": { "@theia/core": "^0.11.0", + "@theia/filesystem": "^0.11.0", + "@theia/workspace": "^0.11.0", + "@theia/outline-view": "^0.11.0", "@theia/languages": "^0.11.0", "@theia/variable-resolver": "^0.11.0", "@types/base64-arraybuffer": "0.1.0", - "base64-arraybuffer": "^0.1.5" + "base64-arraybuffer": "^0.1.5", + "perfect-scrollbar": "^1.3.0" }, "publishConfig": { "access": "public" diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs-items.tsx b/packages/editor/src/browser/breadcrumbs/breadcrumbs-items.tsx new file mode 100644 index 0000000000000..298589e8fd1c1 --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs-items.tsx @@ -0,0 +1,186 @@ +/******************************************************************************** + * 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 { Breadcrumbs } from './breadcrumbs'; +import URI from '@theia/core/lib/common/uri'; +import { FileSystem } from '@theia/filesystem/lib/common'; +import { LabelProvider, OpenerService } from '@theia/core/lib/browser'; +import { MessageService } from '@theia/core'; +import { BreadcrumbsListPopup } from './breadcrumbs-popups'; +import { findParentBreadcrumbsHtmlElement, determinePopupAnchor } from './breadcrumbs-utils'; +import { OutlineSymbolInformationNode } from '@theia/outline-view/lib/browser'; +import { TextEditor, Range } from '../editor'; +import { OutlineViewService } from '@theia/outline-view/lib/browser/outline-view-service'; + +export interface BreadcrumbItem { + render(index: number): JSX.Element; +} + +export class SimpleBreadcrumbItem implements BreadcrumbItem { + constructor(readonly text: string) { } + render(index: number): JSX.Element { + return
  • + {this.text} +
  • ; + } +} + +abstract class BreadcrumbItemWithPopup implements BreadcrumbItem { + + protected popup: BreadcrumbsListPopup | undefined; + + abstract render(index: number): JSX.Element; + + protected showPopup = (event: React.MouseEvent) => { + if (this.popup) { + // Popup already shown. Hide popup instead. + this.popup.dispose(); + this.popup = undefined; + } else { + if (event.nativeEvent.target && event.nativeEvent.target instanceof HTMLElement) { + const breadcrumbsHtmlElement = findParentBreadcrumbsHtmlElement(event.nativeEvent.target as HTMLElement); + if (breadcrumbsHtmlElement && breadcrumbsHtmlElement.parentElement && breadcrumbsHtmlElement.parentElement.lastElementChild) { + const parentElement = breadcrumbsHtmlElement.parentElement.lastElementChild; + if (!parentElement.classList.contains(Breadcrumbs.Styles.BREADCRUMB_POPUP_CONTAINER)) { + // this is unexpected + } else { + const anchor: { x: number, y: number } = determinePopupAnchor(event.nativeEvent) || event.nativeEvent; + this.createPopup(parentElement as HTMLElement, anchor).then(popup => { this.popup = popup; }); + event.stopPropagation(); + event.preventDefault(); + } + } + } + } + } + + protected abstract async createPopup(parent: HTMLElement, anchor: { x: number, y: number }): Promise; +} + +export class FileBreadcrumbItem extends BreadcrumbItemWithPopup { + + constructor( + readonly text: string, + readonly title: string = text, + readonly icon: string, + readonly uri: URI, + readonly itemCssClass: string, + protected readonly fileSystem: FileSystem, + protected readonly labelProvider: LabelProvider, + protected readonly openerService: OpenerService, + protected readonly messageService: MessageService, + ) { super(); } + + render(index: number): JSX.Element { + return
  • + {this.text} +
  • ; + } + + protected async createPopup(parent: HTMLElement, anchor: { x: number, y: number }): Promise { + + const folderFileStat = await this.fileSystem.getFileStat(this.uri.parent.toString()); + + if (folderFileStat && folderFileStat.children) { + const items = await Promise.all(folderFileStat.children + .filter(child => !child.isDirectory) + .filter(child => child.uri !== this.uri.toString()) + .map(child => new URI(child.uri)) + .map( + async u => ({ + label: this.labelProvider.getName(u), + title: this.labelProvider.getLongName(u), + iconClass: await this.labelProvider.getIcon(u) + ' file-icon', + action: () => this.openFile(u) + }) + )); + if (items.length > 0) { + const filelistPopup = new BreadcrumbsListPopup(items, anchor, parent); + filelistPopup.render(); + return filelistPopup; + } + } + } + + protected openFile = (uri: URI) => { + this.openerService.getOpener(uri) + .then(opener => opener.open(uri)) + .catch(error => this.messageService.error(error)); + } +} + +export class OutlineBreadcrumbItem extends BreadcrumbItemWithPopup { + + constructor( + protected readonly node: OutlineSymbolInformationNode, + protected readonly editor: TextEditor, + protected readonly outlineViewService: OutlineViewService + ) { super(); } + + render(index: number): JSX.Element { + return
  • + {this.node.name} +
  • ; + } + + protected async createPopup(parent: HTMLElement, anchor: { x: number, y: number }): Promise { + const items = this.siblings().map(node => ({ + label: node.name, + title: node.name, + iconClass: 'symbol-icon symbol-icon-center ' + this.node.iconClass, + action: () => this.revealInEditor(node) + })); + if (items.length > 0) { + const filelistPopup = new BreadcrumbsListPopup(items, anchor, parent); + filelistPopup.render(); + return filelistPopup; + } + } + + private revealInEditor(node: OutlineSymbolInformationNode): void { + if (OutlineNodeWithRange.is(node)) { + this.editor.cursor = node.range.end; + this.editor.selection = node.range; + this.editor.revealRange(node.range); + this.editor.focus(); + } + } + + private hasPopup(): boolean { + return this.siblings().length > 0; + } + + private siblings(): OutlineSymbolInformationNode[] { + if (!this.node.parent) { return []; } + return this.node.parent.children.filter(n => n !== this.node).map(n => n as OutlineSymbolInformationNode); + } +} + +interface OutlineNodeWithRange extends OutlineSymbolInformationNode { + range: Range; +} +namespace OutlineNodeWithRange { + export function is(node: OutlineSymbolInformationNode): node is OutlineNodeWithRange { + return 'range' in node; + } +} diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs-popups.tsx b/packages/editor/src/browser/breadcrumbs/breadcrumbs-popups.tsx new file mode 100644 index 0000000000000..d5d9a1839db90 --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs-popups.tsx @@ -0,0 +1,88 @@ +/******************************************************************************** + * 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 { ReactRenderer } from '@theia/core/lib/browser'; +import { Breadcrumbs } from './breadcrumbs'; +import PerfectScrollbar from 'perfect-scrollbar'; + +export class BreadcrumbsListPopup extends ReactRenderer { + + protected scrollbar: PerfectScrollbar | undefined; + + constructor( + protected readonly items: { label: string, title: string, iconClass: string, action: () => void }[], + protected readonly anchor: { x: number, y: number }, + host: HTMLElement + ) { + super(host); + } + + protected doRender(): React.ReactNode { + return
    this.dispose()} + tabIndex={0} + > +
      + {this.items.map((item, index) =>
    • item.action()}> + {item.label} +
    • )} +
    +
    ; + } + + render(): void { + super.render(); + if (!this.scrollbar) { + if (this.host.firstChild) { + this.scrollbar = new PerfectScrollbar(this.host.firstChild as HTMLElement, { + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollYMarginOffset: 8, + suppressScrollX: true + }); + } + } else { + this.scrollbar.update(); + } + this.focus(); + document.addEventListener('keyup', this.escFunction); + } + + focus(): boolean { + if (this.host && this.host.firstChild) { + (this.host.firstChild as HTMLElement).focus(); + return true; + } + return false; + } + + dispose(): void { + super.dispose(); + if (this.scrollbar) { + this.scrollbar.destroy(); + this.scrollbar = undefined; + } + document.removeEventListener('keyup', this.escFunction); + } + + protected escFunction = (event: KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Esc') { + this.dispose(); + } + } +} diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs-renderer.tsx b/packages/editor/src/browser/breadcrumbs/breadcrumbs-renderer.tsx new file mode 100644 index 0000000000000..5e92a977ab15b --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs-renderer.tsx @@ -0,0 +1,195 @@ +/******************************************************************************** + * 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 '../../../src/browser/breadcrumbs/breadcrumbs.css'; + +import * as React from 'react'; +import { ReactRenderer, LabelProvider, OpenerService } from '@theia/core/lib/browser'; +import { TextEditor } from '../editor'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import URI from '@theia/core/lib/common/uri'; +import { MessageService, DisposableCollection } from '@theia/core'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { Breadcrumbs } from './breadcrumbs'; +import { BreadcrumbItem, FileBreadcrumbItem, OutlineBreadcrumbItem } from './breadcrumbs-items'; +import { OutlineViewService } from '@theia/outline-view/lib/browser/outline-view-service'; +import { OutlineSymbolInformationNode } from '@theia/outline-view/lib/browser'; +import { toOutlinePath, findSelectedNode } from './breadcrumbs-utils'; +import PerfectScrollbar from 'perfect-scrollbar'; +import { inject, injectable, postConstruct } from 'inversify'; + +@injectable() +export class BreadcrumbsRenderer extends ReactRenderer { + + @inject('TextEditor') + protected readonly editor: TextEditor; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @inject(OutlineViewService) + protected readonly outlineViewService: OutlineViewService; + + private readonly filePathItems = new Array(); + private outlineItems = new Array(); + + private disposables = new DisposableCollection(); + protected scrollbar: PerfectScrollbar | undefined; + + @postConstruct() + init(): void { + this.createFilePathItems(); + } + + protected async createFilePathItems(): Promise { + const resourceUri = this.editor.getResourceUri(); + const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(resourceUri); + if (resourceUri) { + for (const uri of resourceUri.allLocations.reverse().filter(u => !u.path.isRoot && (!workspaceRootUri || !u.isEqualOrParent(workspaceRootUri)))) { + const icon = await this.labelProvider.getIcon(uri); + const label = this.labelProvider.getName(uri); + const title = this.labelProvider.getLongName(uri); + const itemCssClass = Breadcrumbs.Styles.BREADCRUMB_ITEM + (await this.hasSiblings(uri) ? ' ' + Breadcrumbs.Styles.BREADCRUMB_ITEM_HAS_POPUP : ''); + this.filePathItems.push(new FileBreadcrumbItem( + label, + title, + icon, + uri, + itemCssClass, + this.fileSystem, + this.labelProvider, + this.openerService, + this.messageService + )); + } + } + this.refresh(); + } + + protected async updateOutlineItems(outlinePath: OutlineSymbolInformationNode[]): Promise { + this.outlineItems = outlinePath.map(node => new OutlineBreadcrumbItem(node, this.editor, this.outlineViewService)); + this.refresh(); + } + + dispose(): void { + super.dispose(); + this.disposables.dispose(); + if (this.scrollbar) { + this.scrollbar.destroy(); + this.scrollbar = undefined; + } + } + + refresh(): void { + this.render(); + + if (!this.scrollbar) { + if (this.host.firstChild) { + this.scrollbar = new PerfectScrollbar(this.host.firstChild as HTMLElement, { + handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], + useBothWheelAxes: true, + scrollXMarginOffset: 4, + suppressScrollY: true + }); + } + } else { + this.scrollbar.update(); + } + this.scrollToEnd(); + if (this.disposables.disposed) { + this.createOutlineChangeListener(); + } + } + + onAfterShow(): void { + this.createOutlineChangeListener(); + } + + private createOutlineChangeListener(): void { + this.disposables.push(this.outlineViewService.onDidChangeOutline(roots => { + if (this.editor.isFocused()) { + const outlinePath = toOutlinePath(findSelectedNode(roots)); + if (outlinePath) { this.updateOutlineItems(outlinePath); } + } + })); + + this.disposables.push(this.outlineViewService.onDidSelect(node => { + // Check if this event is for this editor (by comparing URIs) + if (OutlineNodeWithUri.is(node) && node.uri.toString() === this.editor.uri.toString()) { + const outlinePath = toOutlinePath(node); + if (outlinePath) { this.updateOutlineItems(outlinePath); } + } + })); + } + + onAfterHide(): void { + this.disposables.dispose(); + } + + private scrollToEnd(): void { + if (this.host.firstChild) { + const breadcrumbsHtmlElement = (this.host.firstChild as HTMLElement); + breadcrumbsHtmlElement.scrollLeft = breadcrumbsHtmlElement.scrollWidth; + } + } + + private async hasSiblings(uri: URI): Promise { + const fileStat = await this.fileSystem.getFileStat(uri.parent.toString()); + + if (fileStat && fileStat.children) { + const length = fileStat.children.filter(child => !child.isDirectory).filter(child => child.uri !== uri.toString()).length; + return length > 0; + } + return false; + } + + protected doRender(): React.ReactNode { + return [ +
      {this.renderItems()}
    , +
    + ]; + } + + protected renderItems(): JSX.Element[] { + return [...this.filePathItems, ...this.outlineItems].map((item, index) => item.render(index)); + } +} + +export const BreadcrumbsRendererFactory = Symbol('BreadcrumbsRendererFactory'); +export interface BreadcrumbsRendererFactory { + (editor: TextEditor): BreadcrumbsRenderer; +} + +interface OutlineNodeWithUri extends OutlineSymbolInformationNode { + uri: URI; +} +namespace OutlineNodeWithUri { + export function is(node: OutlineSymbolInformationNode): node is OutlineNodeWithUri { + return 'uri' in node; + } +} diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs-utils.ts b/packages/editor/src/browser/breadcrumbs/breadcrumbs-utils.ts new file mode 100644 index 0000000000000..5df8e97563a6a --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs-utils.ts @@ -0,0 +1,96 @@ +/******************************************************************************** + * 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 { Breadcrumbs } from './breadcrumbs'; +import { OutlineSymbolInformationNode } from '@theia/outline-view/lib/browser'; + +/** + * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element + * that has the CSS class `Breadcrumbs.Styles.BREADCRUMB_ITEM`. + */ +export function findParentItemHtmlElement(child: HTMLElement): HTMLElement | undefined { + return findParentHtmlElement(child, Breadcrumbs.Styles.BREADCRUMB_ITEM); +} + +/** + * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element + * that has the CSS class `Breadcrumbs.Styles.BREADCRUMBS`. + */ +export function findParentBreadcrumbsHtmlElement(child: HTMLElement): HTMLElement | undefined { + return findParentHtmlElement(child, Breadcrumbs.Styles.BREADCRUMBS); +} + +/** + * Traverse upstream (starting with the HTML element `child`) to find a parent HTML element + * that has the given CSS class. + */ +export function findParentHtmlElement(child: HTMLElement, cssClass: string): HTMLElement | undefined { + if (child.classList.contains(cssClass)) { + return child; + } else { + if (child.parentElement !== null) { + return findParentHtmlElement(child.parentElement, cssClass); + } + } +} + +/** + * Determines the popup anchor for the given mouse event. + * + * It finds the parent HTML element with CSS class `Breadcrumbs.Styles.BREADCRUMB_ITEM` of event's target element + * and return the bottom left corner of this element. + */ +export function determinePopupAnchor(event: MouseEvent): { x: number, y: number } | undefined { + if (event.target === null || !(event.target instanceof HTMLElement)) { + return undefined; + } + const itemHtmlElement = findParentItemHtmlElement(event.target); + if (itemHtmlElement) { + return { + x: itemHtmlElement.getBoundingClientRect().left, + y: itemHtmlElement.getBoundingClientRect().bottom + }; + } +} + +/** + * Find the node that is selected. Returns after the first match. + */ +export function findSelectedNode(roots: OutlineSymbolInformationNode[]): OutlineSymbolInformationNode | undefined { + const result = roots.find(node => node.selected); + if (result) { + return result; + } + for (const node of roots) { + const result2 = findSelectedNode(node.children.map(child => child as OutlineSymbolInformationNode)); + if (result2) { + return result2; + } + } +} + +/** + * Returns the path of the given outline node. + */ +export function toOutlinePath(node: OutlineSymbolInformationNode | undefined, path: OutlineSymbolInformationNode[] = []): OutlineSymbolInformationNode[] | undefined { + if (!node) { return undefined; } + if (node.id === 'outline-view-root') { return path; } + if (node.parent) { + return toOutlinePath(node.parent as OutlineSymbolInformationNode, [node, ...path]); + } else { + return [node, ...path]; + } +} diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs.css b/packages/editor/src/browser/breadcrumbs/breadcrumbs.css new file mode 100644 index 0000000000000..bb69218b870e2 --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs.css @@ -0,0 +1,84 @@ +.theia-breadcrumbs { + position: relative; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + outline-style: none; + margin: .5rem; + list-style-type: none; + overflow: hidden; +} + +.theia-breadcrumbs .ps__thumb-x { + /* Same scrollbar height than in tab bar. */ + height: var(--theia-private-horizontal-tab-scrollbar-height) !important; +} + +.theia-breadcrumbs .theia-breadcrumb-item { + display: flex; + align-items: center; + flex: 0 1 auto; + white-space: nowrap; + align-self: center; + height: 100%; + outline: none; + padding: .25rem .3rem .25rem .25rem; +} + +.theia-breadcrumbs .theia-breadcrumb-item::before { + font-family: FontAwesome; + font-size: calc(var(--theia-content-font-size) * 0.8); + content: "\F0DA"; + display: flex; + align-items: center; + width: .8em; + text-align: right; +} + +.theia-breadcrumb-item-haspopup:hover { + background: var(--theia-accent-color3); + cursor: pointer; +} + +.theia-breadcrumbs-popup { + position: fixed; + width: 8cm; + max-height: 5cm; + z-index: 10000; + padding: 0px; + background: var(--theia-menu-color1); + font-size: var(--theia-ui-font-size1); + color: var(--theia-ui-font-color1); + box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.theia-breadcrumbs-popup:focus { + outline-width: 0; + outline-style: none; +} + +.theia-breadcrumbs-popup ul { + display: flex; + flex-direction: column; + outline-style: none; + list-style-type: none; + padding-inline-start: 0px; + margin: 0 0 0 4px; +} + +.theia-breadcrumbs-popup ul li { + display: flex; + align-items: center; + flex: 0 1 auto; + white-space: nowrap; + cursor: pointer; + outline: none; + padding: .25rem .25rem .25rem .25rem; +} + +.theia-breadcrumbs-popup ul li:hover { + background: var(--theia-accent-color3); +} \ No newline at end of file diff --git a/packages/editor/src/browser/breadcrumbs/breadcrumbs.ts b/packages/editor/src/browser/breadcrumbs/breadcrumbs.ts new file mode 100644 index 0000000000000..1027c2a5ed523 --- /dev/null +++ b/packages/editor/src/browser/breadcrumbs/breadcrumbs.ts @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export namespace Breadcrumbs { + export namespace Styles { + export const BREADCRUMBS = 'theia-breadcrumbs'; + export const BREADCRUMB_ITEM = 'theia-breadcrumb-item'; + export const BREADCRUMB_POPUP_CONTAINER = 'theia-breadcrumbs-popup-container'; + export const BREADCRUMB_POPUP = 'theia-breadcrumbs-popup'; + export const BREADCRUMB_ITEM_HAS_POPUP = 'theia-breadcrumb-item-haspopup'; + } +} diff --git a/packages/editor/src/browser/editor-frontend-module.ts b/packages/editor/src/browser/editor-frontend-module.ts index 418f2ddb663b3..aaae9e9349bf6 100644 --- a/packages/editor/src/browser/editor-frontend-module.ts +++ b/packages/editor/src/browser/editor-frontend-module.ts @@ -33,6 +33,8 @@ import { NavigationLocationSimilarity } from './navigation/navigation-location-s import { EditorVariableContribution } from './editor-variable-contribution'; import { SemanticHighlightingService } from './semantic-highlight/semantic-highlighting-service'; import { EditorQuickOpenService } from './editor-quick-open-service'; +import { TextEditor } from './editor'; +import { BreadcrumbsRenderer, BreadcrumbsRendererFactory } from './breadcrumbs/breadcrumbs-renderer'; export default new ContainerModule(bind => { bindEditorPreferences(bind); @@ -74,4 +76,13 @@ export default new ContainerModule(bind => { bind(ActiveEditorAccess).toSelf().inSingletonScope(); bind(EditorAccess).to(CurrentEditorAccess).inSingletonScope().whenTargetNamed(EditorAccess.CURRENT); bind(EditorAccess).to(ActiveEditorAccess).inSingletonScope().whenTargetNamed(EditorAccess.ACTIVE); + + bind(BreadcrumbsRendererFactory).toFactory(ctx => + (editor: TextEditor) => { + const childContainer = ctx.container.createChild(); + childContainer.bind('TextEditor').toConstantValue(editor); + childContainer.bind(BreadcrumbsRenderer).toSelf(); + return childContainer.get(BreadcrumbsRenderer); + } + ); }); diff --git a/packages/editor/src/browser/editor-widget-factory.ts b/packages/editor/src/browser/editor-widget-factory.ts index 4e07734147435..5d7a32ea16566 100644 --- a/packages/editor/src/browser/editor-widget-factory.ts +++ b/packages/editor/src/browser/editor-widget-factory.ts @@ -20,6 +20,7 @@ import { SelectionService } from '@theia/core/lib/common'; import { NavigatableWidgetOptions, WidgetFactory, LabelProvider } from '@theia/core/lib/browser'; import { EditorWidget } from './editor-widget'; import { TextEditorProvider } from './editor'; +import { BreadcrumbsRendererFactory } from './breadcrumbs/breadcrumbs-renderer'; @injectable() export class EditorWidgetFactory implements WidgetFactory { @@ -37,6 +38,9 @@ export class EditorWidgetFactory implements WidgetFactory { @inject(SelectionService) protected readonly selectionService: SelectionService; + @inject(BreadcrumbsRendererFactory) + protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory; + createWidget(options: NavigatableWidgetOptions): Promise { const uri = new URI(options.uri); return this.createEditor(uri); @@ -45,7 +49,8 @@ export class EditorWidgetFactory implements WidgetFactory { protected async createEditor(uri: URI): Promise { const icon = await this.labelProvider.getIcon(uri); return this.editorProvider(uri).then(textEditor => { - const newEditor = new EditorWidget(textEditor, this.selectionService); + const breadcrumbsRenderer = this.breadcrumbsRendererFactory(textEditor); + const newEditor = new EditorWidget(textEditor, breadcrumbsRenderer, this.selectionService); newEditor.id = this.id + ':' + uri.toString(); newEditor.title.closable = true; newEditor.title.label = this.labelProvider.getName(uri); diff --git a/packages/editor/src/browser/editor-widget.ts b/packages/editor/src/browser/editor-widget.ts index a126339bfda9f..c7fb25ab1290f 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -18,14 +18,19 @@ import { Disposable, SelectionService } from '@theia/core/lib/common'; import { Widget, BaseWidget, Message, Saveable, SaveableSource, Navigatable, StatefulWidget } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { TextEditor } from './editor'; +import { BreadcrumbsRenderer } from './breadcrumbs/breadcrumbs-renderer'; export class EditorWidget extends BaseWidget implements SaveableSource, Navigatable, StatefulWidget { constructor( readonly editor: TextEditor, + readonly breadcrumbsRenderer: BreadcrumbsRenderer, protected readonly selectionService: SelectionService ) { - super(editor); + super(EditorWidget.createParentNode(editor, breadcrumbsRenderer)); + + this.toDispose.push(this.breadcrumbsRenderer); + this.toDispose.push(this.editor); this.toDispose.push(this.editor.onSelectionChanged(() => { if (this.editor.isFocused()) { @@ -39,6 +44,13 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata })); } + static createParentNode(editor: TextEditor, breadcrumbsWidget: BreadcrumbsRenderer): Widget.IOptions { + const div = document.createElement('div'); + div.appendChild(breadcrumbsWidget.host); + div.appendChild(editor.node); + return { node: div }; + } + get saveable(): Saveable { return this.editor.document; } @@ -60,12 +72,19 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata super.onAfterAttach(msg); if (this.isVisible) { this.editor.refresh(); + this.breadcrumbsRenderer.refresh(); } } protected onAfterShow(msg: Message): void { super.onAfterShow(msg); this.editor.refresh(); + this.breadcrumbsRenderer.refresh(); + } + + protected onAfterHide(msg: Message): void { + super.onAfterHide(msg); + this.breadcrumbsRenderer.onAfterHide(); } protected onResize(msg: Widget.ResizeMessage): void { diff --git a/packages/monaco/src/browser/monaco-outline-contribution.ts b/packages/monaco/src/browser/monaco-outline-contribution.ts index 14fec858971c8..9586672d9ca71 100644 --- a/packages/monaco/src/browser/monaco-outline-contribution.ts +++ b/packages/monaco/src/browser/monaco-outline-contribution.ts @@ -33,7 +33,6 @@ import debounce = require('lodash.debounce'); @injectable() export class MonacoOutlineContribution implements FrontendApplicationContribution { - protected readonly toDisposeOnClose = new DisposableCollection(); protected readonly toDisposeOnEditor = new DisposableCollection(); protected roots: MonacoOutlineSymbolInformationNode[] | undefined; protected canUpdateOutline: boolean = true; @@ -42,20 +41,17 @@ export class MonacoOutlineContribution implements FrontendApplicationContributio @inject(EditorManager) protected readonly editorManager: EditorManager; onStart(app: FrontendApplication): void { - this.outlineViewService.onDidChangeOpenState(async open => { - if (open) { - this.toDisposeOnClose.push(this.toDisposeOnEditor); - this.toDisposeOnClose.push(DocumentSymbolProviderRegistry.onDidChange( - debounce(() => this.updateOutline()) - )); - this.toDisposeOnClose.push(this.editorManager.onCurrentEditorChanged( - debounce(() => this.handleCurrentEditorChanged(), 50) - )); - this.handleCurrentEditorChanged(); - } else { - this.toDisposeOnClose.dispose(); - } - }); + + // updateOutline and handleCurrentEditorChanged need to be called even when the outline view widget is closed + // in order to udpate breadcrumbs. + DocumentSymbolProviderRegistry.onDidChange( + debounce(() => this.updateOutline()) + ); + this.editorManager.onCurrentEditorChanged( + debounce(() => this.handleCurrentEditorChanged(), 50) + ); + this.handleCurrentEditorChanged(); + this.outlineViewService.onDidSelect(async node => { if (MonacoOutlineSymbolInformationNode.is(node) && node.parent) { const options: EditorOpenerOptions = { @@ -89,10 +85,6 @@ export class MonacoOutlineContribution implements FrontendApplicationContributio protected handleCurrentEditorChanged(): void { this.toDisposeOnEditor.dispose(); - if (this.toDisposeOnClose.disposed) { - return; - } - this.toDisposeOnClose.push(this.toDisposeOnEditor); this.toDisposeOnEditor.push(Disposable.create(() => this.roots = undefined)); const editor = this.editorManager.currentEditor; if (editor) { diff --git a/packages/outline-view/src/browser/outline-view-service.ts b/packages/outline-view/src/browser/outline-view-service.ts index 0252677b17b54..d520862febc23 100644 --- a/packages/outline-view/src/browser/outline-view-service.ts +++ b/packages/outline-view/src/browser/outline-view-service.ts @@ -56,8 +56,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 {