From f2a2e519b6989abe8627c8b2d4ca8c3239856074 Mon Sep 17 00:00:00 2001 From: Casey Flynn Date: Thu, 1 Nov 2018 14:45:59 -0700 Subject: [PATCH] Editor Preview widget implementation. Signed-off-by: Casey Flynn --- .travis.yml | 1 + examples/browser/package.json | 1 + examples/electron/package.json | 1 + packages/core/src/browser/core-preferences.ts | 52 ++++ .../browser/frontend-application-module.ts | 4 + packages/core/src/browser/index.ts | 1 + packages/core/src/browser/opener-service.ts | 2 +- .../src/browser/shell/application-shell.ts | 6 +- packages/editor-preview/README.md | 7 + packages/editor-preview/compile.tsconfig.json | 10 + packages/editor-preview/package.json | 48 ++++ .../browser/editor-preview-factory.spec.ts | 85 +++++++ .../src/browser/editor-preview-factory.ts | 61 +++++ .../browser/editor-preview-frontend-module.ts | 33 +++ .../browser/editor-preview-manager.spec.ts | 136 +++++++++++ .../src/browser/editor-preview-manager.ts | 135 +++++++++++ .../src/browser/editor-preview-preferences.ts | 48 ++++ .../src/browser/editor-preview-widget.ts | 226 ++++++++++++++++++ packages/editor-preview/src/browser/index.ts | 19 ++ .../browser/style/editor-preview-widget.css | 19 ++ .../src/browser/style/index.css | 17 ++ packages/editor-preview/src/package.spec.ts | 28 +++ .../navigator/src/browser/navigator-model.ts | 10 +- 23 files changed, 947 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/browser/core-preferences.ts create mode 100644 packages/editor-preview/README.md create mode 100644 packages/editor-preview/compile.tsconfig.json create mode 100644 packages/editor-preview/package.json create mode 100644 packages/editor-preview/src/browser/editor-preview-factory.spec.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-factory.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-frontend-module.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-manager.spec.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-manager.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-preferences.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-widget.ts create mode 100644 packages/editor-preview/src/browser/index.ts create mode 100644 packages/editor-preview/src/browser/style/editor-preview-widget.css create mode 100644 packages/editor-preview/src/browser/style/index.css create mode 100644 packages/editor-preview/src/package.spec.ts diff --git a/.travis.yml b/.travis.yml index 4778d8688dc7e..d5ca1aa1f784f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,7 @@ cache: - packages/cpp/node_modules - packages/debug-nodejs/node_modules - packages/debug/node_modules + - packages/editor-preview/node_modules - packages/editor/node_modules - packages/editorconfig/node_modules - packages/extension-manager/node_modules diff --git a/examples/browser/package.json b/examples/browser/package.json index 56820c5248533..c07232c7e1908 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -17,6 +17,7 @@ "@theia/debug": "^0.3.16", "@theia/debug-nodejs": "^0.3.16", "@theia/editor": "^0.3.16", + "@theia/editor-preview": "^0.3.16", "@theia/editorconfig": "^0.3.16", "@theia/extension-manager": "^0.3.16", "@theia/file-search": "^0.3.16", diff --git a/examples/electron/package.json b/examples/electron/package.json index cd63b89cd65fb..915609959ae2b 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -18,6 +18,7 @@ "@theia/debug": "^0.3.16", "@theia/debug-nodejs": "^0.3.16", "@theia/editor": "^0.3.16", + "@theia/editor-preview": "^0.3.16", "@theia/editorconfig": "^0.3.16", "@theia/extension-manager": "^0.3.16", "@theia/file-search": "^0.3.16", diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts new file mode 100644 index 0000000000000..8c09ef8a25648 --- /dev/null +++ b/packages/core/src/browser/core-preferences.ts @@ -0,0 +1,52 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 { interfaces } from 'inversify'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from './preferences'; + +export const corePreferenceSchema: PreferenceSchema = { + 'type': 'object', + properties: { + 'list.openMode': { + type: 'string', + enum: [ + 'singleClick', + 'doubleClick' + ], + default: 'singleClick', + description: 'Controls how to open items in trees using the mouse.' + } + } +}; + +export interface CoreConfiguration { + 'list.openMode': string; +} + +export const CorePreferences = Symbol('CorePreferences'); +export type CorePreferences = PreferenceProxy; + +export function createCorePreferences(preferences: PreferenceService): CorePreferences { + return createPreferenceProxy(preferences, corePreferenceSchema); +} + +export function bindCorePreferences(bind: interfaces.Bind): void { + bind(CorePreferences).toDynamicValue(ctx => { + const preferences = ctx.container.get(PreferenceService); + return createCorePreferences(preferences); + }).inSingletonScope(); + bind(PreferenceContribution).toConstantValue({ schema: corePreferenceSchema}); +} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index b8af912340a86..732e89d7ff90b 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -62,6 +62,8 @@ import { EnvVariablesServer, envVariablesPath } from './../common/env-variables' import { FrontendApplicationStateService } from './frontend-application-state'; import { JsonSchemaStore } from './json-schema-store'; import { TabBarToolbarRegistry, TabBarToolbarContribution, TabBarToolbarFactory, TabBarToolbar } from './shell/tab-bar-toolbar'; +import { WidgetTracker } from './widgets'; +import { bindCorePreferences } from './core-preferences'; export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => { const themeService = ThemeService.get(); @@ -215,4 +217,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo [CommandContribution, MenuContribution].forEach(serviceIdentifier => bind(serviceIdentifier).toService(ThemingCommandContribution), ); + + bindCorePreferences(bind); }); diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index dacbd0e7d2442..455f7e14bc16d 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -37,3 +37,4 @@ export * from './label-provider'; export * from './widget-open-handler'; export * from './navigatable'; export * from './diff-uris'; +export * from './core-preferences'; diff --git a/packages/core/src/browser/opener-service.ts b/packages/core/src/browser/opener-service.ts index 3da5080007ec9..779de74c3e8b9 100644 --- a/packages/core/src/browser/opener-service.ts +++ b/packages/core/src/browser/opener-service.ts @@ -78,7 +78,7 @@ export interface OpenerService { } export async function open(openerService: OpenerService, uri: URI, options?: OpenerOptions): Promise { - const opener = await openerService.getOpener(uri); + const opener = await openerService.getOpener(uri, options); return opener.open(uri, options); } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index c7effdadf06d0..1f81d3ba091d1 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -23,7 +23,7 @@ import { } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; import { IDragEvent } from '@phosphor/dragdrop'; -import { RecursivePartial, MaybePromise } from '../../common'; +import { RecursivePartial, MaybePromise, Event as CommonEvent } from '../../common'; import { Saveable } from '../saveable'; import { StatusBarImpl, StatusBarEntry, StatusBarAlignment } from '../status-bar/status-bar'; import { TheiaDockPanel } from './theia-dock-panel'; @@ -807,6 +807,9 @@ export class ApplicationShell extends Widget { this.tracker.add(toTrack); Saveable.apply(toTrack); } + if (widget.onDidChangeTrackableWidgets) { + widget.onDidChangeTrackableWidgets(widgets => widgets.forEach(w => this.track(w))); + } } } @@ -1435,6 +1438,7 @@ export namespace ApplicationShell { */ export interface TrackableWidgetProvider { getTrackableWidgets(): MaybePromise + readonly onDidChangeTrackableWidgets?: CommonEvent } export namespace TrackableWidgetProvider { diff --git a/packages/editor-preview/README.md b/packages/editor-preview/README.md new file mode 100644 index 0000000000000..e5731a931d3d3 --- /dev/null +++ b/packages/editor-preview/README.md @@ -0,0 +1,7 @@ +# Theia - Editor Preview Extension + +See [here](https://www.theia-ide.org/doc/index.html) for a detailed documentation. + +## License +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) diff --git a/packages/editor-preview/compile.tsconfig.json b/packages/editor-preview/compile.tsconfig.json new file mode 100644 index 0000000000000..a23513b5e6b13 --- /dev/null +++ b/packages/editor-preview/compile.tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ] +} diff --git a/packages/editor-preview/package.json b/packages/editor-preview/package.json new file mode 100644 index 0000000000000..b52d0ed5186c6 --- /dev/null +++ b/packages/editor-preview/package.json @@ -0,0 +1,48 @@ +{ + "name": "@theia/editor-preview", + "version": "0.3.16", + "description": "Theia - Editor Preview Extension", + "dependencies": { + "@theia/core": "^0.3.16", + "@theia/editor": "^0.3.16", + "@theia/navigator": "^0.3.16" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/editor-preview-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/theia-ide/theia.git" + }, + "bugs": { + "url": "https://github.com/theia-ide/theia/issues" + }, + "homepage": "https://github.com/theia-ide/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "prepare": "yarn run clean && yarn run build", + "clean": "theiaext clean", + "build": "theiaext build", + "watch": "theiaext watch", + "test": "theiaext test", + "docs": "theiaext docs" + }, + "devDependencies": { + "@theia/ext-scripts": "^0.3.16" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-factory.spec.ts b/packages/editor-preview/src/browser/editor-preview-factory.spec.ts new file mode 100644 index 0000000000000..168a8bc5a06ec --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-factory.spec.ts @@ -0,0 +1,85 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 + ********************************************************************************/ + +// This file is strictly for testing; disable no-any so we can mock out objects not under test +// disable no-unused-expression for chai. +// tslint:disable:no-any no-unused-expression + +import {enableJSDOM} from '@theia/core/lib/browser/test/jsdom'; +const disableJsDom = enableJSDOM(); + +import { Container } from 'inversify'; +import { WidgetFactory, WidgetManager } from '@theia/core/lib/browser'; +import { EditorWidget, EditorManager } from '@theia/editor/lib/browser'; +import { EditorPreviewWidgetFactory, EditorPreviewWidgetOptions } from './editor-preview-factory'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as previewFrontEndModule from './editor-preview-frontend-module'; + +const mockEditorWidget = sinon.createStubInstance(EditorWidget); +const mockEditorManager = { + getOrCreateByUri: () => {} +}; +const getOrCreateStub = sinon.stub(mockEditorManager, 'getOrCreateByUri').returns(mockEditorWidget); + +let testContainer: Container; + +before(() => { + testContainer = new Container(); + // Mock out injected dependencies. + testContainer.bind(WidgetManager).toDynamicValue(ctx => ({} as any)); + testContainer.bind(EditorManager).toDynamicValue(ctx => (mockEditorManager as any)); + testContainer.load(previewFrontEndModule.default); +}); + +after(() => { + disableJsDom(); +}); + +describe('editor-preview-factory', () => { + let widgetFactory: EditorPreviewWidgetFactory; + + beforeEach(() => { + widgetFactory = testContainer.get(WidgetFactory); + getOrCreateStub.resetHistory(); + }); + + it('should create a new editor widget via editor manager if same session', async () => { + const opts: EditorPreviewWidgetOptions = { + kind: 'editor-preview-widget', + id: '1', + initialUri: 'file://a/b/c', + session: EditorPreviewWidgetFactory.sessionId + }; + const widget = await widgetFactory.createWidget(opts); + expect((mockEditorManager.getOrCreateByUri as sinon.SinonStub).calledOnce).to.be.true; + expect(widget.id).to.equal(opts.id); + expect(widget.editorWidget).to.equal(mockEditorWidget); + }); + + it('should not create a widget if restoring from previous session', async () => { + const opts: EditorPreviewWidgetOptions = { + kind: 'editor-preview-widget', + id: '2', + initialUri: 'file://a/b/c', + session: 'session-mismatch' + }; + const widget = await widgetFactory.createWidget(opts); + expect((mockEditorManager.getOrCreateByUri as sinon.SinonStub).called).to.be.false; + expect(widget.id).to.equal(opts.id); + expect(widget.editorWidget).to.be.undefined; + }); +}); diff --git a/packages/editor-preview/src/browser/editor-preview-factory.ts b/packages/editor-preview/src/browser/editor-preview-factory.ts new file mode 100644 index 0000000000000..345321c14f84e --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-factory.ts @@ -0,0 +1,61 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 { WidgetFactory, WidgetManager } from '@theia/core/lib/browser'; +import { MaybePromise } from '@theia/core/lib/common/types'; +import { EditorPreviewWidget } from './editor-preview-widget'; +import { inject, injectable } from 'inversify'; +import { EditorManager } from '@theia/editor/lib/browser'; +import { UUID } from '@phosphor/coreutils'; + +export interface EditorPreviewWidgetOptions { + kind: 'editor-preview-widget', + id: string, + initialUri: string, + session: string, +} + +@injectable() +export class EditorPreviewWidgetFactory implements WidgetFactory { + + static ID: string = 'editor-preview-widget'; + + static generateUniqueId(): string { + return UUID.uuid4(); + } + + readonly id = EditorPreviewWidgetFactory.ID; + static readonly sessionId = EditorPreviewWidgetFactory.generateUniqueId(); + + @inject(WidgetManager) + protected readonly widgetManager: WidgetManager; + + @inject(EditorManager) + protected readonly editorManager: EditorManager; + + createWidget(options: EditorPreviewWidgetOptions): MaybePromise { + return this.doCreate(options); + } + + protected async doCreate(options: EditorPreviewWidgetOptions): Promise { + const widget = (options.session === EditorPreviewWidgetFactory.sessionId) ? + await this.editorManager.getOrCreateByUri(new URI(options.initialUri)) : undefined; + const previewWidget = new EditorPreviewWidget(this.widgetManager, widget); + previewWidget.id = options.id; + return previewWidget; + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-frontend-module.ts b/packages/editor-preview/src/browser/editor-preview-frontend-module.ts new file mode 100644 index 0000000000000..202601f019efc --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-frontend-module.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 Liffcense, 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 { OpenHandler, WidgetFactory } from '@theia/core/lib/browser'; +import {ContainerModule} from 'inversify'; +import { EditorPreviewManager } from './editor-preview-manager'; +import { EditorPreviewWidgetFactory } from './editor-preview-factory'; +import { bindEditorPreviewPreferences } from './editor-preview-preferences'; + +import '../../src/browser/style/index.css'; + +export default new ContainerModule(bind => { + + bind(WidgetFactory).to(EditorPreviewWidgetFactory).inSingletonScope(); + + bind(EditorPreviewManager).toSelf().inSingletonScope(); + bind(OpenHandler).to(EditorPreviewManager); + + bindEditorPreviewPreferences(bind); +}); diff --git a/packages/editor-preview/src/browser/editor-preview-manager.spec.ts b/packages/editor-preview/src/browser/editor-preview-manager.spec.ts new file mode 100644 index 0000000000000..2a4dd6372a768 --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-manager.spec.ts @@ -0,0 +1,136 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 + ********************************************************************************/ + +// This file is strictly for testing; disable no-any so we can mock out objects not under test +// disable no-unused-expression for chai. +// tslint:disable:no-any no-unused-expression + +import {enableJSDOM} from '@theia/core/lib/browser/test/jsdom'; +const disableJsDom = enableJSDOM(); + +import URI from '@theia/core/lib/common/uri'; +import { Container } from 'inversify'; +import { EditorPreviewManager } from './editor-preview-manager'; +import { EditorPreviewWidget } from './editor-preview-widget'; +import { EditorPreviewWidgetFactory } from './editor-preview-factory'; +import { OpenHandler, PreferenceService, PreferenceServiceImpl } from '@theia/core/lib/browser'; +import { ApplicationShell, WidgetManager } from '@theia/core/lib/browser'; +import { EditorManager, EditorWidget } from '@theia/editor/lib/browser'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as previewFrontEndModule from './editor-preview-frontend-module'; + +const mockEditorWidget = sinon.createStubInstance(EditorWidget); +sinon.stub(mockEditorWidget, 'id').get(() => 'mockEditorWidget'); + +const mockPreviewWidget = sinon.createStubInstance(EditorPreviewWidget); +sinon.stub(mockPreviewWidget, 'id').get(() => 'mockPreviewWidget'); +sinon.stub(mockPreviewWidget, 'disposed').get(() => ({connect: () => 1})); +let onPinnedListeners: Function[] = []; +sinon.stub(mockPreviewWidget, 'onPinned').get(() => (fn: Function) => onPinnedListeners.push(fn)); + +const mockEditorManager = sinon.createStubInstance(EditorManager); +mockEditorManager.getOrCreateByUri = sinon.stub().returns(mockEditorWidget); + +const mockWidgetManager = sinon.createStubInstance(WidgetManager); +let onCreateListners: Function[] = []; +mockWidgetManager.onDidCreateWidget = sinon.stub().callsFake((fn: Function) => onCreateListners.push(fn)); +(mockWidgetManager.getOrCreateWidget as sinon.SinonStub).returns(mockPreviewWidget); + +const mockShell = sinon.createStubInstance(ApplicationShell); + +const mockPreference = sinon.createStubInstance(PreferenceServiceImpl); +mockPreference.onPreferenceChanged = sinon.stub().returns({dispose: () => {}}); + +let testContainer: Container; + +before(() => { + testContainer = new Container(); + // Mock out injected dependencies. + testContainer.bind(EditorManager).toDynamicValue(ctx => mockEditorManager); + testContainer.bind(WidgetManager).toDynamicValue(ctx => mockWidgetManager); + testContainer.bind(ApplicationShell).toDynamicValue(ctx => mockShell); + testContainer.bind(PreferenceService).toDynamicValue(ctx => mockPreference); + + testContainer.load(previewFrontEndModule.default); +}); + +after(() => { + disableJsDom(); +}); + +describe('editor-preview-manager', () => { + let previewManager: EditorPreviewManager; + + beforeEach(() => { + previewManager = testContainer.get(OpenHandler); + }); + afterEach(() => { + onCreateListners = []; + onPinnedListeners = []; + }); + + it('should handle preview requests if editor.enablePreview enabled', () => { + (mockPreference.get as sinon.SinonStub).returns(true); + expect(previewManager.canHandle(new URI(), {preview: true})).to.be.greaterThan(0); + }); + it('should not handle preview requests if editor.enablePreview disabled', () => { + (mockPreference.get as sinon.SinonStub).returns(false); + expect(previewManager.canHandle(new URI(), {preview: true})).to.equal(0); + }); + it('should not handle requests that are not preview or currently being previewed', () => { + expect(previewManager.canHandle(new URI())).to.equal(0); + }); + it('should create a preview editor and replace where required.', async () => { + const w = await previewManager.open(new URI(), {preview: true}); + expect(w instanceof EditorPreviewWidget).to.be.true; + expect((w as any).replaceEditorWidget.calledOnce).to.be.false; + + // Replace the EditorWidget with another open call to an editor that doesn't exist. + const afterReplace = await previewManager.open(new URI(), {preview: true}); + expect((afterReplace as any).replaceEditorWidget.calledOnce).to.be.true; + + // Ensure the same preview widget was re-used. + expect(w).to.equal(afterReplace); + }); + it('Should return an existing editor on preview request', async () => { + // Activate existing editor + mockEditorManager.getByUri.returns(mockEditorWidget); + mockEditorManager.open.returns(mockEditorWidget); + expect(await previewManager.open(new URI(), {})).to.equal(mockEditorWidget); + + // Activate existing preview + mockEditorWidget.parent = mockPreviewWidget; + expect(await previewManager.open(new URI(), {preview: true})).to.equal(mockPreviewWidget); + // Ensure it is not pinned. + expect((mockPreviewWidget.pinEditorWidget as sinon.SinonStub).calledOnce).to.be.false; + + // Pin existing preview + expect(await previewManager.open(new URI(), {})).to.equal(mockPreviewWidget); + expect((mockPreviewWidget.pinEditorWidget as sinon.SinonStub).calledOnce).to.be.true; + }); + it('should should transition the editor to perminent on pin events.', () => { + // Fake creation call. + onCreateListners.pop()!({factoryId: EditorPreviewWidgetFactory.ID, widget: mockPreviewWidget}); + // Fake pinned call + onPinnedListeners.pop()!({preview: mockPreviewWidget, editorWidget: mockEditorWidget}); + + expect(mockPreviewWidget.close.calledOnce).to.be.true; + expect(mockEditorWidget.close.calledOnce).to.be.false; + expect(mockEditorWidget.dispose.calledOnce).to.be.false; + }); + +}); diff --git a/packages/editor-preview/src/browser/editor-preview-manager.ts b/packages/editor-preview/src/browser/editor-preview-manager.ts new file mode 100644 index 0000000000000..d8c251be80412 --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-manager.ts @@ -0,0 +1,135 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 { injectable, inject, postConstruct } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { ApplicationShell, DockPanel } from '@theia/core/lib/browser'; +import { EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser'; +import { EditorPreviewWidget } from './editor-preview-widget'; +import { EditorPreviewWidgetFactory, EditorPreviewWidgetOptions } from './editor-preview-factory'; +import { EditorPreviewPreferences } from './editor-preview-preferences'; +import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { MaybePromise } from '@theia/core/src/common'; + +/** + * Opener options containing an optional preview flag. + */ +export interface PreviewEditorOpenerOptions extends EditorOpenerOptions { + preview?: boolean +} + +/** + * Class for managing an editor preview widget. + */ +@injectable() +export class EditorPreviewManager extends WidgetOpenHandler { + + readonly id = EditorPreviewWidgetFactory.ID; + + readonly label = 'Code Editor Preview'; + + protected currentEditorPreview: EditorPreviewWidget | undefined; + + @inject(EditorManager) + protected readonly editorManager: EditorManager; + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + @inject(EditorPreviewPreferences) + protected readonly preferences: EditorPreviewPreferences; + + @postConstruct() + protected init(): void { + super.init(); + this.onCreated(widget => { + if (widget instanceof EditorPreviewWidget) { + this.handlePreviewWidgetCreated(widget); + } + }); + } + + protected handlePreviewWidgetCreated(widget: EditorPreviewWidget): void { + // Enforces only one preview widget exists at a given time. + if (this.currentEditorPreview) { + this.currentEditorPreview.pinEditorWidget(); + } + + this.currentEditorPreview = widget; + widget.disposed.connect(() => this.currentEditorPreview = undefined); + + widget.onPinned(({preview, editorWidget}) => { + // TODO(caseyflynn): I don't believe there is ever a case where + // this will not hold true. + if (preview.parent && preview.parent instanceof DockPanel) { + preview.parent.addWidget(editorWidget, {ref: preview}); + } else { + this.shell.addWidget(editorWidget, {area: 'main'}); + } + preview.close(); + this.shell.activateWidget(editorWidget.id); + this.currentEditorPreview = undefined; + }); + } + + protected isCurrentPreviewUri(uri: URI): boolean { + const currentUri = this.currentEditorPreview && this.currentEditorPreview.getResourceUri(); + return !!currentUri && currentUri.isEqualOrParent(uri); + } + + canHandle(uri: URI, options?: PreviewEditorOpenerOptions): MaybePromise { + if (this.preferences['editor.enablePreview'] && (options && options.preview || this.isCurrentPreviewUri(uri))) { + return 200; + } + return 0; + } + + async open(uri: URI, options?: PreviewEditorOpenerOptions): Promise { + options = {...options, mode: 'open'}; + + if (await this.editorManager.getByUri(uri)) { + let widget: EditorWidget | EditorPreviewWidget = await this.editorManager.open(uri, options); + if (widget.parent instanceof EditorPreviewWidget) { + if (!options.preview) { + widget.parent.pinEditorWidget(); + } + widget = widget.parent; + } + this.shell.revealWidget(widget.id); + return widget; + } + + if (!this.currentEditorPreview) { + this.currentEditorPreview = await super.open(uri, options) as EditorPreviewWidget; + } else { + const childWidget = await this.editorManager.getOrCreateByUri(uri); + this.currentEditorPreview.replaceEditorWidget(childWidget); + } + + this.editorManager.open(uri, options); + this.shell.revealWidget(this.currentEditorPreview!.id); + return this.currentEditorPreview; + } + + protected createWidgetOptions(uri: URI, options?: WidgetOpenerOptions): EditorPreviewWidgetOptions { + return { + kind: 'editor-preview-widget', + id: EditorPreviewWidgetFactory.generateUniqueId(), + initialUri: uri.withoutFragment().toString(), + session: EditorPreviewWidgetFactory.sessionId + }; + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-preferences.ts b/packages/editor-preview/src/browser/editor-preview-preferences.ts new file mode 100644 index 0000000000000..fb412af748107 --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-preferences.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 { interfaces } from 'inversify'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; + +export const EditorPreviewConfigSchema: PreferenceSchema = { + 'type': 'object', + properties: { + 'editor.enablePreview': { + type: 'boolean', + description: 'Controls whether editors are opened as previews when selected or single-clicked.', + default: true + }, + } +}; + +export interface EditorPreviewConfiguration { + 'editor.enablePreview': boolean; +} + +export const EditorPreviewPreferences = Symbol('EditorPreviewPreferences'); +export type EditorPreviewPreferences = PreferenceProxy; + +export function createEditorPreviewPreferences(preferences: PreferenceService): EditorPreviewPreferences { + return createPreferenceProxy(preferences, EditorPreviewConfigSchema); +} + +export function bindEditorPreviewPreferences(bind: interfaces.Bind): void { + bind(EditorPreviewPreferences).toDynamicValue(ctx => { + const preferences = ctx.container.get(PreferenceService); + return createEditorPreviewPreferences(preferences); + }).inSingletonScope(); + bind(PreferenceContribution).toConstantValue({ schema: EditorPreviewConfigSchema }); +} diff --git a/packages/editor-preview/src/browser/editor-preview-widget.ts b/packages/editor-preview/src/browser/editor-preview-widget.ts new file mode 100644 index 0000000000000..9c467d68516db --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-widget.ts @@ -0,0 +1,226 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 { + ApplicationShell, BaseWidget, DockPanel, Navigatable, PanelLayout, Saveable, + StatefulWidget, Title, Widget, WidgetConstructionOptions, WidgetManager +} from '@theia/core/lib/browser'; +import { Emitter, DisposableCollection } from '@theia/core/lib/common'; +import URI from '@theia/core/lib/common/uri'; +import { EditorWidget } from '@theia/editor/lib/browser'; +import { Message, MessageLoop } from '@phosphor/messaging'; +import { find } from '@phosphor/algorithm'; + +export interface PreviewViewState { + pinned: boolean, + editorState: object | undefined, + previewDescription: WidgetConstructionOptions | undefined +} + +export interface PreviewEditorPinnedEvent { + preview: EditorPreviewWidget, + editorWidget: EditorWidget +} + +/** The class name added to Editor Preview Widget titles. */ +const PREVIEW_TITLE_CLASS = ' theia-editor-preview-title-unpinned'; + +export class EditorPreviewWidget extends BaseWidget implements ApplicationShell.TrackableWidgetProvider, Navigatable, StatefulWidget { + + protected pinned_: boolean; + protected pinListeners = new DisposableCollection(); + protected onDidChangeTrackableWidgetsEmitter = new Emitter(); + + private lastParent: DockPanel | undefined; + + readonly onDidChangeTrackableWidgets = this.onDidChangeTrackableWidgetsEmitter.event; + + protected onPinnedEmitter = new Emitter(); + + readonly onPinned = this.onPinnedEmitter.event; + + constructor(protected widgetManager: WidgetManager, protected editorWidget_?: EditorWidget) { + super(); + this.title.closable = true; + this.title.className += PREVIEW_TITLE_CLASS; + this.layout = new PanelLayout(); + this.toDispose.push(this.onDidChangeTrackableWidgetsEmitter); + this.toDispose.push(this.onPinnedEmitter); + this.toDispose.push(this.pinListeners); + } + + get editorWidget(): EditorWidget | undefined { + return this.editorWidget_; + } + + get pinned(): boolean { + return this.pinned_; + } + + get saveable(): Saveable|undefined { + if (this.editorWidget_) { + return this.editorWidget_.saveable; + } + } + + getResourceUri(): URI | undefined { + return this.editorWidget_ && this.editorWidget_.getResourceUri(); + } + createMoveToUri(resourceUri: URI): URI | undefined { + return this.editorWidget_ && this.editorWidget_.createMoveToUri(resourceUri); + } + + pinEditorWidget(): void { + this.title.className = this.title.className.replace(PREVIEW_TITLE_CLASS, ''); + this.pinListeners.dispose(); + this.pinned_ = true; + this.onPinnedEmitter.fire({preview: this, editorWidget: this.editorWidget_!}); + } + + replaceEditorWidget(editorWidget: EditorWidget): void { + if (editorWidget === this.editorWidget_) { + return; + } + if (this.editorWidget_) { + this.editorWidget_.dispose(); + } + this.editorWidget_ = editorWidget; + this.attachPreviewWidget(this.editorWidget_); + this.onResize(Widget.ResizeMessage.UnknownSize); + } + + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + if (this.editorWidget_) { + this.editorWidget_.activate(); + } + } + + protected attachPreviewWidget(w: Widget): void { + (this.layout as PanelLayout).addWidget(w); + this.title.label = w.title.label; + this.title.iconClass = w.title.iconClass; + this.title.caption = w.title.caption; + + if (Saveable.isSource(w)) { + Saveable.apply(this); + const dirtyListener = w.saveable.onDirtyChanged(() => { + this.pinEditorWidget(); + }); + this.toDispose.push(dirtyListener); + } + w.parent = this; + this.onDidChangeTrackableWidgetsEmitter.fire([w]); + } + + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + if (this.editorWidget_ && !this.editorWidget_.isAttached) { + this.attachPreviewWidget(this.editorWidget_); + } + this.addTabPinningLogic(); + } + + protected addTabPinningLogic(): void { + const parent = this.parent; + if (!this.pinned_ && parent instanceof DockPanel) { + if (!this.lastParent) { + this.lastParent = parent; + } + + const tabBar = find(parent.tabBars(), bar => bar.titles.indexOf(this.title) !== -1); + + // Widget has been dragged into a different panel + if (this.lastParent !== parent || !tabBar) { + this.pinEditorWidget(); + return; + } + + const layoutListener = (panel: DockPanel) => { + if (tabBar !== find(panel.tabBars(), bar => bar.titles.indexOf(this.title) !== -1)) { + this.pinEditorWidget(); + } + }; + parent.layoutModified.connect(layoutListener); + this.pinListeners.push({dispose: () => parent.layoutModified.disconnect(layoutListener)}); + + const tabMovedListener = (w: Widget, args: {title: Title}) => { + if (args.title === this.title) { + this.pinEditorWidget(); + } + }; + tabBar.tabMoved.connect(tabMovedListener); + this.pinListeners.push({dispose: () => tabBar.tabMoved.disconnect(tabMovedListener)}); + + const attachDoubleClickListener = (attempt: number): number | undefined => { + const tabNode = tabBar.contentNode.children.item(tabBar.currentIndex); + if (!tabNode) { + return attempt < 60 ? requestAnimationFrame(() => attachDoubleClickListener(++attempt)) : undefined; + } + const dblClickListener = (event: Event) => this.pinEditorWidget(); + tabNode.addEventListener('dblclick', dblClickListener); + this.pinListeners.push({dispose: () => tabNode.removeEventListener('dblclick', dblClickListener)}); + }; + requestAnimationFrame(() => attachDoubleClickListener(0)); + } + } + + protected onResize(msg: Widget.ResizeMessage): void { + if (this.editorWidget_) { + // Currently autosizing does not work with the Monaco Editor Widget + // https://github.com/theia-ide/theia/blob/c86a33b9ee0e5bb1dc49c66def123ffb2cadbfe4/packages/monaco/src/browser/monaco-editor.ts#L461 + // After this is supported we can rely on the underlying widget to resize and remove + // the following if statement. (Without it, the editor will be initialized to its + // minimum size) + if (msg.width < 0 || msg.height < 0) { + const width = parseInt(this.node.style.width || ''); + const height = parseInt(this.node.style.height || ''); + if (width && height) { + this.editorWidget_.editor.setSize({width, height}); + } + } + MessageLoop.sendMessage(this.editorWidget_, msg); + } + } + + getTrackableWidgets(): Promise { + return new Promise( + resolve => resolve(this.editorWidget_ ? [this.editorWidget_] : [])); + } + + storeState(): PreviewViewState { + return { + pinned: this.pinned_, + editorState: this.editorWidget_ ? this.editorWidget_.storeState() : undefined, + previewDescription: this.editorWidget_ ? this.widgetManager.getDescription(this.editorWidget_) : undefined + }; + } + + async restoreState(state: PreviewViewState): Promise { + const {pinned, editorState, previewDescription} = state; + if (!this.editorWidget_ && previewDescription) { + const {factoryId, options} = previewDescription; + const editorWidget = await this.widgetManager.getOrCreateWidget(factoryId, options) as EditorWidget; + this.replaceEditorWidget(editorWidget); + } + if (this.editorWidget && editorState) { + this.editorWidget.restoreState(editorState); + } + if (pinned) { + this.pinEditorWidget(); + } + } +} diff --git a/packages/editor-preview/src/browser/index.ts b/packages/editor-preview/src/browser/index.ts new file mode 100644 index 0000000000000..217243d7b6fa5 --- /dev/null +++ b/packages/editor-preview/src/browser/index.ts @@ -0,0 +1,19 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 './editor-preview-frontend-module'; +export * from './editor-preview-manager'; +export * from './editor-preview-widget'; diff --git a/packages/editor-preview/src/browser/style/editor-preview-widget.css b/packages/editor-preview/src/browser/style/editor-preview-widget.css new file mode 100644 index 0000000000000..b058227fde91e --- /dev/null +++ b/packages/editor-preview/src/browser/style/editor-preview-widget.css @@ -0,0 +1,19 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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-editor-preview-title-unpinned .p-TabBar-tabLabel { + font-style: italic; +} diff --git a/packages/editor-preview/src/browser/style/index.css b/packages/editor-preview/src/browser/style/index.css new file mode 100644 index 0000000000000..e6d5be76898a7 --- /dev/null +++ b/packages/editor-preview/src/browser/style/index.css @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 './editor-preview-widget.css'; diff --git a/packages/editor-preview/src/package.spec.ts b/packages/editor-preview/src/package.spec.ts new file mode 100644 index 0000000000000..c2b1d6ef5a44d --- /dev/null +++ b/packages/editor-preview/src/package.spec.ts @@ -0,0 +1,28 @@ +/******************************************************************************** + * Copyright (C) 2018 Google 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 + ********************************************************************************/ + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('editor package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts index b2fa5b4a4c2d2..60354bd2dcd88 100644 --- a/packages/navigator/src/browser/navigator-model.ts +++ b/packages/navigator/src/browser/navigator-model.ts @@ -17,7 +17,7 @@ import { injectable, inject, postConstruct } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; -import { OpenerService, open, TreeNode, ExpandableTreeNode } from '@theia/core/lib/browser'; +import { OpenerService, open, TreeNode, ExpandableTreeNode, SelectableTreeNode, CorePreferences } from '@theia/core/lib/browser'; import { FileNavigatorTree, WorkspaceRootNode, WorkspaceNode } from './navigator-tree'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @@ -27,6 +27,7 @@ export class FileNavigatorModel extends FileTreeModel { @inject(OpenerService) protected readonly openerService: OpenerService; @inject(FileNavigatorTree) protected readonly tree: FileNavigatorTree; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @postConstruct() protected async init(): Promise { @@ -132,6 +133,13 @@ export class FileNavigatorModel extends FileTreeModel { return undefined; } + selectNode(node: SelectableTreeNode): void { + if (FileNode.is(node) && this.corePreferences['list.openMode'] === 'singleClick') { + open(this.openerService, node.uri, {mode: 'reveal', preview: true}); + } + super.selectNode(node); + } + protected getNodeClosestToRootByUri(uri: URI): TreeNode | undefined { const nodes = [...this.getNodesByUri(uri)]; return nodes.length > 0