Skip to content

Commit

Permalink
Add breadcrumbs bar to editor widget
Browse files Browse the repository at this point in the history
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 <cornelius.ludmann@typefox.io>
  • Loading branch information
corneliusludmann committed Oct 19, 2019
1 parent f255f5a commit e35e597
Show file tree
Hide file tree
Showing 14 changed files with 733 additions and 23 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/browser/widgets/react-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
186 changes: 186 additions & 0 deletions packages/editor/src/browser/breadcrumbs/breadcrumbs-items.tsx
Original file line number Diff line number Diff line change
@@ -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 <li key={index} title={this.text} className={Breadcrumbs.Styles.BREADCRUMB_ITEM}>
{this.text}
</li>;
}
}

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<BreadcrumbsListPopup | undefined>;
}

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 <li key={index} title={this.title}
className={this.itemCssClass}
onClick={this.showPopup}
>
<span className={this.icon + ' file-icon'}></span> <span>{this.text}</span>
</li>;
}

protected async createPopup(parent: HTMLElement, anchor: { x: number, y: number }): Promise<BreadcrumbsListPopup | undefined> {

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 <li key={index} title={this.node.name}
className={Breadcrumbs.Styles.BREADCRUMB_ITEM + (this.hasPopup() ? ' ' + Breadcrumbs.Styles.BREADCRUMB_ITEM_HAS_POPUP : '')}
onClick={this.showPopup}
>
<span className={'symbol-icon symbol-icon-center ' + this.node.iconClass}></span> <span>{this.node.name}</span>
</li>;
}

protected async createPopup(parent: HTMLElement, anchor: { x: number, y: number }): Promise<BreadcrumbsListPopup | undefined> {
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;
}
}
88 changes: 88 additions & 0 deletions packages/editor/src/browser/breadcrumbs/breadcrumbs-popups.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={Breadcrumbs.Styles.BREADCRUMB_POPUP}
style={{ left: `${this.anchor.x}px`, top: `${this.anchor.y}px` }}
onBlur={_ => this.dispose()}
tabIndex={0}
>
<ul>
{this.items.map((item, index) => <li key={index} title={item.title} onClick={_ => item.action()}>
<span className={item.iconClass}></span> <span>{item.label}</span>
</li>)}
</ul>
</div >;
}

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

0 comments on commit e35e597

Please sign in to comment.