From 1e9a861d77ef76a2047815b26f7af9a23eeca35d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 15 Apr 2021 20:33:41 +0200 Subject: [PATCH] Use file service in preference provider initialization Signed-off-by: Colin Grant --- examples/api-tests/src/keybindings.spec.js | 2 +- examples/api-tests/src/typescript.spec.js | 15 +- packages/core/src/browser/keybinding.ts | 18 +- .../src/browser/shell/application-shell.ts | 11 + packages/core/src/browser/widget-manager.ts | 8 +- .../core/src/browser/widget-open-handler.ts | 4 +- .../browser/editor-preview-contribution.ts | 72 ++++++ .../browser/editor-preview-factory.spec.ts | 91 ------- .../src/browser/editor-preview-factory.ts | 62 ----- .../browser/editor-preview-frontend-module.ts | 25 +- .../browser/editor-preview-manager.spec.ts | 141 ---------- .../src/browser/editor-preview-manager.ts | 185 +++++-------- .../browser/editor-preview-widget-factory.ts | 45 ++++ .../src/browser/editor-preview-widget.ts | 242 +++++------------- packages/editor-preview/src/browser/index.ts | 19 -- packages/editor/src/browser/editor-manager.ts | 29 +-- .../src/browser/editor-widget-factory.ts | 9 +- .../src/browser/navigator-contribution.ts | 20 +- .../plugin-vscode-commands-contribution.ts | 1 - ...tract-resource-preference-provider.spec.ts | 108 ++++++++ .../abstract-resource-preference-provider.ts | 38 ++- 21 files changed, 460 insertions(+), 685 deletions(-) create mode 100644 packages/editor-preview/src/browser/editor-preview-contribution.ts delete mode 100644 packages/editor-preview/src/browser/editor-preview-factory.spec.ts delete mode 100644 packages/editor-preview/src/browser/editor-preview-factory.ts delete mode 100644 packages/editor-preview/src/browser/editor-preview-manager.spec.ts create mode 100644 packages/editor-preview/src/browser/editor-preview-widget-factory.ts delete mode 100644 packages/editor-preview/src/browser/index.ts create mode 100644 packages/preferences/src/browser/abstract-resource-preference-provider.spec.ts diff --git a/examples/api-tests/src/keybindings.spec.js b/examples/api-tests/src/keybindings.spec.js index 2087a697cfa15..5f862ee5cb3a9 100644 --- a/examples/api-tests/src/keybindings.spec.js +++ b/examples/api-tests/src/keybindings.spec.js @@ -93,7 +93,7 @@ describe('Keybindings', function () { assert.notEqual(executedCommand, id); }); - it('later registered keybinding should has higher priority', async () => { + it('later registered keybinding should have higher priority', async () => { const id = '__test:keybindings.copy'; toTearDown.push(commands.registerCommand({ id }, { execute: () => { } diff --git a/examples/api-tests/src/typescript.spec.js b/examples/api-tests/src/typescript.spec.js index 1146d8da937f4..0cda26c50a6a8 100644 --- a/examples/api-tests/src/typescript.spec.js +++ b/examples/api-tests/src/typescript.spec.js @@ -33,7 +33,6 @@ describe('TypeScript', function () { const { CommandRegistry } = require('@theia/core/lib/common/command'); const { KeybindingRegistry } = require('@theia/core/lib/browser/keybinding'); const { OpenerService, open } = require('@theia/core/lib/browser/opener-service'); - const { EditorPreviewWidget } = require('@theia/editor-preview/lib/browser/editor-preview-widget'); const { animationFrame } = require('@theia/core/lib/browser/browser'); const { PreferenceService, PreferenceScope } = require('@theia/core/lib/browser/preferences/preference-service'); const { ProgressStatusBarItem } = require('@theia/core/lib/browser/progress-status-bar-item'); @@ -157,7 +156,7 @@ module.exports = (port, host, argv) => Promise.resolve() */ async function openEditor(uri, preview = false) { const widget = await open(openerService, uri, { mode: 'activate', preview }); - const editorWidget = widget instanceof EditorPreviewWidget ? widget.editorWidget : widget instanceof EditorWidget ? widget : undefined; + const editorWidget = widget instanceof EditorWidget ? widget : undefined; const editor = MonacoEditor.get(editorWidget); assert.isDefined(editor); @@ -284,7 +283,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.equal(editorManager.activeEditor.parent instanceof EditorPreviewWidget, preview); + assert.equal(editorManager.activeEditor.isPreview, preview); assert.equal(activeEditor.uri.toString(), serverUri.toString()); // const |container = new Container(); // @ts-ignore @@ -307,7 +306,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.isFalse(editorManager.activeEditor.parent instanceof EditorPreviewWidget); + assert.isFalse(editorManager.activeEditor.isPreview); assert.equal(activeEditor.uri.toString(), inversifyUri.toString()); // export { |Container } from "./container/container"; // @ts-ignore @@ -328,7 +327,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.isTrue(editorManager.activeEditor.parent instanceof EditorPreviewWidget); + assert.isTrue(editorManager.activeEditor.isPreview); assert.equal(activeEditor.uri.toString(), inversifyUri.toString()); // export { |Container } from "./container/container"; // @ts-ignore @@ -356,7 +355,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.equal(editorManager.activeEditor.parent instanceof EditorPreviewWidget, preview); + assert.equal(editorManager.activeEditor.isPreview, preview); assert.equal(activeEditor.uri.toString(), serverUri.toString()); // const |container = new Container(); // @ts-ignore @@ -382,7 +381,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.isFalse(editorManager.activeEditor.parent instanceof EditorPreviewWidget); + assert.isFalse(editorManager.activeEditor.isPreview); assert.equal(activeEditor.uri.toString(), inversifyUri.toString()); // export { |Container } from "./container/container"; // @ts-ignore @@ -406,7 +405,7 @@ module.exports = (port, host, argv) => Promise.resolve() const activeEditor = /** @type {MonacoEditor} */ (MonacoEditor.get(editorManager.activeEditor)); // @ts-ignore - assert.isTrue(editorManager.activeEditor.parent instanceof EditorPreviewWidget); + assert.isTrue(editorManager.activeEditor.isPreview); assert.equal(activeEditor.uri.toString(), inversifyUri.toString()); // export { |Container } from "./container/container"; // @ts-ignore diff --git a/packages/core/src/browser/keybinding.ts b/packages/core/src/browser/keybinding.ts index 06b22cca8362f..d76fadcf4fa47 100644 --- a/packages/core/src/browser/keybinding.ts +++ b/packages/core/src/browser/keybinding.ts @@ -232,7 +232,7 @@ export class KeybindingRegistry { try { this.resolveKeybinding(binding); const scoped = Object.assign(binding, { scope }); - this.keymaps[scope].unshift(scoped); + this.insertBindingIntoScope(scoped, scope); return Disposable.create(() => { const index = this.keymaps[scope].indexOf(scoped); if (index !== -1) { @@ -245,6 +245,22 @@ export class KeybindingRegistry { } } + /** + * Ensures that keybindings are inserted in order of increasing length of binding to ensure that if a + * user triggers a short keybinding (e.g. ctrl+k), the UI won't wait for a longer one (e.g. ctrl+k enter) + */ + protected insertBindingIntoScope(item: common.Keybinding & { scope: KeybindingScope; }, scope: KeybindingScope): void { + const scopedKeymap = this.keymaps[scope]; + const getNumberOfKeystrokes = (binding: common.Keybinding): number => (binding.keybinding.trim().match(/\s/g)?.length ?? 0) + 1; + const numberOfKeystrokesInBinding = getNumberOfKeystrokes(item); + const indexOfFirstItemWithEqualStrokes = scopedKeymap.findIndex(existingBinding => getNumberOfKeystrokes(existingBinding) === numberOfKeystrokesInBinding); + if (indexOfFirstItemWithEqualStrokes > -1) { + scopedKeymap.splice(indexOfFirstItemWithEqualStrokes, 0, item); + } else { + scopedKeymap.push(item); + } + } + /** * Ensure that the `resolved` property of the given binding is set by calling the KeyboardLayoutService. */ diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index bffd40875e236..eb5b08dc63781 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -850,6 +850,17 @@ export class ApplicationShell extends Widget { return this.currentTabBar; } + /** + * @returns the widget whose title has been targeted by a DOM event on a tabbar, or undefined if none can be found. + */ + findTargetedWidget(event?: Event): Widget | undefined { + if (event) { + const tab = this.findTabBar(event); + const title = tab && this.findTitle(tab, event); + return title && title.owner; + } + } + /** * The current widget in the application shell. The current widget is the last widget that * was active and not yet closed. See the remarks to `activeWidget` on what _active_ means. diff --git a/packages/core/src/browser/widget-manager.ts b/packages/core/src/browser/widget-manager.ts index dfa7439206e92..d327634ef4a93 100644 --- a/packages/core/src/browser/widget-manager.ts +++ b/packages/core/src/browser/widget-manager.ts @@ -172,15 +172,13 @@ export class WidgetManager { * * @returns a promise resolving to the widget if available, else `undefined`. */ - async getWidget(factoryId: string, options?: any): Promise { + getWidget(factoryId: string, options?: any): MaybePromise | undefined { const key = this.toKey({ factoryId, options }); - const pendingWidget = this.doGetWidget(key); - const widget = pendingWidget && await pendingWidget; - return widget; + return this.doGetWidget(key); } protected doGetWidget(key: string): MaybePromise | undefined { - const pendingWidget = this.widgetPromises.get(key) || this.pendingWidgetPromises.get(key); + const pendingWidget = this.widgetPromises.get(key) ?? this.pendingWidgetPromises.get(key); if (pendingWidget) { return pendingWidget as MaybePromise; } diff --git a/packages/core/src/browser/widget-open-handler.ts b/packages/core/src/browser/widget-open-handler.ts index c4d6cfdd2840e..8023cc3c1d4e7 100644 --- a/packages/core/src/browser/widget-open-handler.ts +++ b/packages/core/src/browser/widget-open-handler.ts @@ -111,7 +111,7 @@ export abstract class WidgetOpenHandler implements OpenHan * * @returns a promise that resolves to the existing widget or `undefined` if no widget for the given uri exists. */ - getByUri(uri: URI): Promise { + getByUri(uri: URI): MaybePromise | undefined { return this.getWidget(uri); } @@ -136,7 +136,7 @@ export abstract class WidgetOpenHandler implements OpenHan return this.widgetManager.getWidgets(this.id) as W[]; } - protected getWidget(uri: URI, options?: WidgetOpenerOptions): Promise { + protected getWidget(uri: URI, options?: WidgetOpenerOptions): MaybePromise | undefined { const widgetOptions = this.createWidgetOptions(uri, options); return this.widgetManager.getWidget(this.id, widgetOptions); } diff --git a/packages/editor-preview/src/browser/editor-preview-contribution.ts b/packages/editor-preview/src/browser/editor-preview-contribution.ts new file mode 100644 index 0000000000000..eeb3b5d3d2e34 --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-contribution.ts @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ApplicationShell, KeybindingContribution, KeybindingRegistry, SHELL_TABBAR_CONTEXT_MENU, Widget } from '@theia/core/lib/browser'; +import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { EditorPreviewWidget } from './editor-preview-widget'; + +export namespace EditorPreviewCommands { + export const PIN_PREVIEW_COMMAND: Command = { + id: 'workbench.action.keepEditor', + category: 'View', + label: 'Keep Editor', + }; +} + +@injectable() +export class EditorPreviewContribution implements CommandContribution, MenuContribution, KeybindingContribution { + @inject(ApplicationShell) protected readonly shell: ApplicationShell; + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(EditorPreviewCommands.PIN_PREVIEW_COMMAND, { + execute: async (event?: Event) => { + const widget = this.getTargetWidget(event); + if (widget instanceof EditorPreviewWidget) { + widget.convertToNonPreview(); + await this.shell.activateWidget(widget.id); + } + }, + isEnabled: (event?: Event) => { + const widget = this.getTargetWidget(event); + return widget instanceof EditorPreviewWidget && widget.isPreview; + }, + isVisible: (event?: Event) => { + const widget = this.getTargetWidget(event); + return widget instanceof EditorPreviewWidget; + } + }); + } + + registerKeybindings(registry: KeybindingRegistry): void { + registry.registerKeybinding({ + command: EditorPreviewCommands.PIN_PREVIEW_COMMAND.id, + keybinding: 'ctrlcmd+k enter' + }); + } + + registerMenus(registry: MenuModelRegistry): void { + registry.registerMenuAction(SHELL_TABBAR_CONTEXT_MENU, { + commandId: EditorPreviewCommands.PIN_PREVIEW_COMMAND.id, + label: EditorPreviewCommands.PIN_PREVIEW_COMMAND.label, + order: '6', + }); + } + + protected getTargetWidget(event?: Event): Widget | undefined { + return event ? this.shell.findTargetedWidget(event) : this.shell.activeWidget; + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-factory.spec.ts b/packages/editor-preview/src/browser/editor-preview-factory.spec.ts deleted file mode 100644 index 12e0d2be5c477..0000000000000 --- a/packages/editor-preview/src/browser/editor-preview-factory.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -/******************************************************************************** - * 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. -/* eslint-disable no-unused-expressions, @typescript-eslint/no-explicit-any */ - -import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; -const disableJsDom = enableJSDOM(); - -import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; -import { ApplicationProps } from '@theia/application-package/lib/application-props'; -FrontendApplicationConfigProvider.set({ - ...ApplicationProps.DEFAULT.frontend.config -}); - -import { Container } from '@theia/core/shared/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 deleted file mode 100644 index c8c1d84ca16b0..0000000000000 --- a/packages/editor-preview/src/browser/editor-preview-factory.ts +++ /dev/null @@ -1,62 +0,0 @@ -/******************************************************************************** - * 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 '@theia/core/shared/inversify'; -import { EditorManager } from '@theia/editor/lib/browser'; -import { UUID } from '@theia/core/shared/@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 index de819df09e544..f2168ce963ea7 100644 --- a/packages/editor-preview/src/browser/editor-preview-frontend-module.ts +++ b/packages/editor-preview/src/browser/editor-preview-frontend-module.ts @@ -14,20 +14,29 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { OpenHandler, WidgetFactory } from '@theia/core/lib/browser'; +import '../../src/browser/style/index.css'; +import { KeybindingContribution, OpenHandler, WidgetFactory } from '@theia/core/lib/browser'; import { ContainerModule } from '@theia/core/shared/inversify'; -import { EditorPreviewManager } from './editor-preview-manager'; -import { EditorPreviewWidgetFactory } from './editor-preview-factory'; import { bindEditorPreviewPreferences } from './editor-preview-preferences'; +import { EditorPreviewManager } from './editor-preview-manager'; +import { EditorManager } from '@theia/editor/lib/browser'; +import { EditorPreviewWidgetFactory } from './editor-preview-widget-factory'; +import { EditorPreviewContribution } from './editor-preview-contribution'; +import { CommandContribution, MenuContribution } from '@theia/core/lib/common'; -import '../../src/browser/style/index.css'; - -export default new ContainerModule(bind => { +export default new ContainerModule((bind, unbind, isBound, rebind) => { - bind(WidgetFactory).to(EditorPreviewWidgetFactory).inSingletonScope(); + bind(EditorPreviewWidgetFactory).toSelf().inSingletonScope(); + bind(WidgetFactory).toService(EditorPreviewWidgetFactory); bind(EditorPreviewManager).toSelf().inSingletonScope(); - bind(OpenHandler).to(EditorPreviewManager); + rebind(EditorManager).toService(EditorPreviewManager); + bind(OpenHandler).toService(EditorPreviewManager); + + bind(EditorPreviewContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(EditorPreviewContribution); + bind(KeybindingContribution).toService(EditorPreviewContribution); + bind(MenuContribution).toService(EditorPreviewContribution); 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 deleted file mode 100644 index 0c43e00c5f92c..0000000000000 --- a/packages/editor-preview/src/browser/editor-preview-manager.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -/******************************************************************************** - * 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. -/* eslint-disable no-unused-expressions, @typescript-eslint/no-explicit-any */ - -import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; -const disableJsDom = enableJSDOM(); - -import URI from '@theia/core/lib/common/uri'; -import { Container } from '@theia/core/shared/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 onCreateListeners: Function[] = []; -mockWidgetManager.onDidCreateWidget = sinon.stub().callsFake((fn: Function) => onCreateListeners.push(fn)); -(mockWidgetManager.getOrCreateWidget as sinon.SinonStub).returns(mockPreviewWidget); - -const mockShell = sinon.createStubInstance(ApplicationShell) as ApplicationShell; - -const mockPreference = sinon.createStubInstance(PreferenceServiceImpl); -mockPreference.onPreferencesChanged = 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); - (mockShell)['tracker'] = { activeWidget: undefined }; - testContainer.bind(ApplicationShell).toConstantValue(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); - sinon.stub(previewManager as any, 'onActive').resolves(); - sinon.stub(previewManager as any, 'onReveal').resolves(); - }); - afterEach(() => { - onCreateListeners = []; - onPinnedListeners = []; - }); - - it('should handle preview requests if editor.enablePreview enabled', async () => { - (mockPreference.get as sinon.SinonStub).returns(true); - expect(await previewManager.canHandle(new URI(), { preview: true })).to.be.greaterThan(0); - }); - it('should not handle preview requests if editor.enablePreview disabled', async () => { - (mockPreference.get as sinon.SinonStub).returns(false); - expect(await previewManager.canHandle(new URI(), { preview: true })).to.equal(0); - }); - it('should not handle requests that are not preview or currently being previewed', async () => { - expect(await 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 permanent on pin events.', async () => { - // Fake creation call. - // eslint-disable-next-line no-unsanitized/method - await onCreateListeners.pop()!({ factoryId: EditorPreviewWidgetFactory.ID, widget: mockPreviewWidget }); - // Fake pinned call - // eslint-disable-next-line no-unsanitized/method - onPinnedListeners.pop()!({ preview: mockPreviewWidget, editorWidget: mockEditorWidget }); - - expect(mockPreviewWidget.dispose.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 index 72f6756fb7472..c947312962f6a 100644 --- a/packages/editor-preview/src/browser/editor-preview-manager.ts +++ b/packages/editor-preview/src/browser/editor-preview-manager.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2018 Google and others. + * Copyright (C) 2018-2021 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 @@ -14,151 +14,108 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject, postConstruct } from '@theia/core/shared/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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { EditorPreviewPreferences } from './editor-preview-preferences'; -import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; - -/** - * Opener options containing an optional preview flag. - */ -export interface PreviewEditorOpenerOptions extends EditorOpenerOptions { - preview?: boolean -} +import { DisposableCollection, MaybePromise } from '@theia/core/lib/common'; +import URI from '@theia/core/lib/common/uri'; +import { EditorPreviewWidgetFactory, EditorPreviewOptions } from './editor-preview-widget-factory'; +import { EditorPreviewWidget } from './editor-preview-widget'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; -/** - * Class for managing an editor preview widget. - */ @injectable() -export class EditorPreviewManager extends WidgetOpenHandler { - +export class EditorPreviewManager extends EditorManager { readonly id = EditorPreviewWidgetFactory.ID; - readonly label = 'Code Editor Preview'; - - protected currentEditorPreview: Promise; + @inject(EditorPreviewPreferences) protected readonly preferences: EditorPreviewPreferences; + @inject(FrontendApplicationStateService) protected readonly stateService: FrontendApplicationStateService; - @inject(EditorManager) - protected readonly editorManager: EditorManager; - - @inject(ApplicationShell) - protected readonly shell: ApplicationShell; - - @inject(EditorPreviewPreferences) - protected readonly preferences: EditorPreviewPreferences; + protected currentPreview: EditorPreviewWidget | undefined; + protected toDisposeOnPreviewChange = new DisposableCollection(); + /** + * Until the layout has been restored, widget state is not reliable, so we ignore creation events. + */ + protected layoutIsSet = false; @postConstruct() protected init(): void { super.init(); - this.onCreated(widget => { - if (widget instanceof EditorPreviewWidget) { - return this.handlePreviewWidgetCreated(widget); + // All editors are created, but not all are opened. This sets up the logic to swap previews when the editor is attached. + this.onCreated((widget: EditorPreviewWidget) => { + if (this.layoutIsSet && widget.isPreview) { + const oneTimeDisposable = widget.onDidChangeVisibility(() => { + const { currentPreview } = this; + this.handleNewPreview(widget); + currentPreview?.dispose(); + oneTimeDisposable.dispose(); + }); } }); this.preferences.onPreferenceChanged(change => { - if (this.currentEditorPreview) { - this.currentEditorPreview.then(editorPreview => { - if (!change.newValue && editorPreview) { - editorPreview.pinEditorWidget(); - } - }); - } + if (!change.newValue) { + this.currentPreview?.convertToNonPreview(); + }; }); - } - - protected async handlePreviewWidgetCreated(widget: EditorPreviewWidget): Promise { - // Enforces only one preview widget exists at a given time. - const editorPreview = await this.currentEditorPreview; - if (editorPreview && editorPreview !== widget) { - editorPreview.pinEditorWidget(); - } - - this.currentEditorPreview = Promise.resolve(widget); - widget.disposed.connect(() => this.currentEditorPreview = Promise.resolve(undefined)); - widget.onPinned(({ preview, editorWidget }) => { - const wasActive = this.shell.activeWidget === preview || this.shell.activeWidget === editorWidget; - // TODO(caseyflynn): I don't believe there is ever a case where the parent will not be a DockPanel. - if (preview.parent && preview.parent instanceof DockPanel) { - preview.parent.addWidget(editorWidget, { ref: preview }); - } else { - this.shell.addWidget(editorWidget, { area: 'main' }); - } - preview.dispose(); - if (wasActive) { - this.shell.activateWidget(editorWidget.id); + this.stateService.reachedState('initialized_layout').then(() => { + const editors = this.all as EditorPreviewWidget[]; + const currentPreview = editors.find(editor => editor.isPreview); + if (currentPreview) { + this.handleNewPreview(currentPreview); } - this.currentEditorPreview = Promise.resolve(undefined); + this.layoutIsSet = true; }); - } - protected async isCurrentPreviewUri(uri: URI): Promise { - const editorPreview = await this.currentEditorPreview; - const currentUri = editorPreview && editorPreview.getResourceUri(); - return !!currentUri && currentUri.isEqualOrParent(uri); + document.addEventListener('dblclick', this.convertEditorOnDoubleClick.bind(this)); } - async canHandle(uri: URI, options?: PreviewEditorOpenerOptions): Promise { - if (this.preferences['editor.enablePreview'] && (options && options.preview || await this.isCurrentPreviewUri(uri))) { - return 200; + protected async doOpen(widget: EditorPreviewWidget, options?: EditorOpenerOptions): Promise { + const { preview, widgetOptions = { area: 'main' }, mode = 'activate' } = options ?? {}; + if (!widget.isAttached) { + if (preview && this.preferences['editor.enablePreview']) { + const insertionOptions = this.currentPreview ? { ref: this.currentPreview } : widgetOptions; + await this.shell.addWidget(widget, insertionOptions); + } else { + this.shell.addWidget(widget, widgetOptions); + } + } else if (!preview && widget === this.currentPreview) { + widget.convertToNonPreview(); } - return 0; - } - async open(uri: URI, options: PreviewEditorOpenerOptions = {}): Promise { - let widget = await this.pinCurrentEditor(uri, options); - if (widget) { - return widget; + if (mode === 'activate') { + await this.shell.activateWidget(widget.id); + } else if (mode === 'reveal') { + await this.shell.revealWidget(widget.id); } - widget = await this.replaceCurrentPreview(uri, options) || await this.openNewPreview(uri, options); - await this.editorManager.open(uri, options); - return widget; } - protected async pinCurrentEditor(uri: URI, options: PreviewEditorOpenerOptions): Promise { - if (await this.editorManager.getByUri(uri)) { - const editorWidget = await this.editorManager.open(uri, options); - if (editorWidget.parent instanceof EditorPreviewWidget) { - if (!options.preview) { - editorWidget.parent.pinEditorWidget(); - } - return editorWidget.parent; - } - return editorWidget; - } + protected handleNewPreview(widget: EditorPreviewWidget): void { + this.toDisposeOnPreviewChange.dispose(); + this.currentPreview = widget; + this.toDisposeOnPreviewChange.push({ dispose: () => this.currentPreview = undefined }); + this.toDisposeOnPreviewChange.push(widget.onDidChangePreviewState(() => this.toDisposeOnPreviewChange.dispose())); + this.toDisposeOnPreviewChange.push(widget.onDidDispose(() => this.toDisposeOnPreviewChange.dispose())); } - protected async replaceCurrentPreview(uri: URI, options: PreviewEditorOpenerOptions): Promise { - const currentPreview = await this.currentEditorPreview; - if (currentPreview) { - const editorWidget = await this.editorManager.getOrCreateByUri(uri); - currentPreview.replaceEditorWidget(editorWidget); - return currentPreview; - } + protected getWidget(uri: URI, options?: EditorOpenerOptions): MaybePromise | undefined { + return super.getWidget(uri, { ...options, preview: true }) ?? super.getWidget(uri, { ...options, preview: false }); } - protected openNewPreview(uri: URI, options: PreviewEditorOpenerOptions): Promise { - const result = super.open(uri, options); - this.currentEditorPreview = result.then(widget => { - if (widget instanceof EditorPreviewWidget) { - return widget; - } - return undefined; - }, () => undefined); - return result; + protected async getOrCreateWidget(uri: URI, options?: EditorOpenerOptions): Promise { + return this.getWidget(uri, options) ?? super.getOrCreateWidget(uri, options); } - protected createWidgetOptions(uri: URI, options?: WidgetOpenerOptions): EditorPreviewWidgetOptions { - return { - kind: 'editor-preview-widget', - id: EditorPreviewWidgetFactory.generateUniqueId(), - initialUri: uri.withoutFragment().toString(), - session: EditorPreviewWidgetFactory.sessionId - }; + protected createWidgetOptions(uri: URI, options?: EditorOpenerOptions): EditorPreviewOptions { + const navigatableOptions = super.createWidgetOptions(uri, options) as EditorPreviewOptions; + navigatableOptions.preview = !!options?.preview; + return navigatableOptions; + } + + protected convertEditorOnDoubleClick(event: Event): void { + const widget = this.shell.findTargetedWidget(event); + if (widget === this.currentPreview) { + this.currentPreview?.convertToNonPreview(); + } } } diff --git a/packages/editor-preview/src/browser/editor-preview-widget-factory.ts b/packages/editor-preview/src/browser/editor-preview-widget-factory.ts new file mode 100644 index 0000000000000..90de7cb2ddbc4 --- /dev/null +++ b/packages/editor-preview/src/browser/editor-preview-widget-factory.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * Copyright (C) 2018-2021 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 { EditorWidgetFactory } from '@theia/editor/lib/browser/editor-widget-factory'; +import { injectable } from '@theia/core/shared/inversify'; +import { EditorPreviewWidget } from './editor-preview-widget'; +import { NavigatableWidgetOptions } from '@theia/core/lib/browser'; + +export interface EditorPreviewOptions extends NavigatableWidgetOptions { + preview?: boolean; +} + +@injectable() +export class EditorPreviewWidgetFactory extends EditorWidgetFactory { + static ID: string = 'editor-preview-widget'; + readonly id = EditorPreviewWidgetFactory.ID; + + async createWidget(options: EditorPreviewOptions): Promise { + const uri = new URI(options.uri); + const editor = await this.createEditor(uri, options) as EditorPreviewWidget; + if (options.preview) { + editor.initializePreview(); + } + return editor; + } + + protected async constructEditor(uri: URI): Promise { + const textEditor = await this.editorProvider(uri); + return new EditorPreviewWidget(textEditor, this.selectionService); + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-widget.ts b/packages/editor-preview/src/browser/editor-preview-widget.ts index b9813cab53df0..c65125d5db58c 100644 --- a/packages/editor-preview/src/browser/editor-preview-widget.ts +++ b/packages/editor-preview/src/browser/editor-preview-widget.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2018 Google and others. + * Copyright (C) 2021 Ericsson and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -14,214 +14,88 @@ * 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 '@theia/core/shared/@phosphor/messaging'; +import { Message } from '@theia/core/shared/@phosphor/messaging'; +import { DockPanel, TabBar, Widget } from '@theia/core/lib/browser'; +import { EditorWidget, TextEditor } from '@theia/editor/lib/browser'; +import { Disposable, DisposableCollection, Emitter, SelectionService } from '@theia/core/lib/common'; import { find } from '@theia/core/shared/@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 { +const PREVIEW_TITLE_CLASS = 'theia-editor-preview-title-unpinned'; +export class EditorPreviewWidget extends EditorWidget { + protected _isPreview = false; + protected lastTabbar: TabBar | undefined; - protected pinned_: boolean; - protected pinListeners = new DisposableCollection(); - protected onDidChangeTrackableWidgetsEmitter = new Emitter(); + protected readonly onDidChangePreviewStateEmitter = new Emitter(); + readonly onDidChangePreviewState = this.onDidChangePreviewStateEmitter.event; - 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.addClass('theia-editor-preview'); - 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); - } + protected readonly toDisposeOnLocationChange = new DisposableCollection(); - 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_! }); + get isPreview(): boolean { + return this._isPreview; } - 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); + constructor( + readonly editor: TextEditor, + protected readonly selectionService: SelectionService + ) { + super(editor, selectionService); + this.toDispose.push(this.onDidChangePreviewStateEmitter); + this.toDispose.push(this.toDisposeOnLocationChange); } - protected onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - if (this.editorWidget_) { - this.editorWidget_.activate(); - } + initializePreview(): void { + this._isPreview = true; + this.title.className += ` ${PREVIEW_TITLE_CLASS}`; + const oneTimeDirtyChangeListener = this.saveable.onDirtyChanged(() => { + this.convertToNonPreview(); + oneTimeDirtyChangeListener.dispose(); + }); + this.toDispose.push(oneTimeDirtyChangeListener); } - 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(() => { - dirtyListener.dispose(); - this.pinEditorWidget(); - }); - this.toDispose.push(dirtyListener); + convertToNonPreview(): void { + if (this._isPreview) { + this._isPreview = false; + this.toDisposeOnLocationChange.dispose(); + this.lastTabbar = undefined; + this.title.className = this.title.className.replace(PREVIEW_TITLE_CLASS, ''); + this.onDidChangePreviewStateEmitter.fire(); + this.onDidChangePreviewStateEmitter.dispose(); } - 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_); + if (this._isPreview) { + this.checkForTabbarChange(); } - this.addTabPinningLogic(); } - protected addTabPinningLogic(): void { - const parent = this.parent; - if (!this.pinned_ && parent instanceof DockPanel) { - if (!this.lastParent) { - this.lastParent = parent; + protected checkForTabbarChange(): void { + const { parent } = this; + if (parent instanceof DockPanel) { + this.toDisposeOnLocationChange.dispose(); + const newTabbar = find(parent.tabBars(), tabbar => !!tabbar.titles.find(title => title === this.title)); + if (this.lastTabbar && this.lastTabbar !== newTabbar) { + this.convertToNonPreview(); + } else { + this.lastTabbar = newTabbar; + const listener = () => this.checkForTabbarChange(); + parent.layoutModified.connect(listener); + this.toDisposeOnLocationChange.push(Disposable.create(() => parent.layoutModified.disconnect(listener))); } - - 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/eclipse-theia/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(): Widget[] { - return this.editorWidget_ ? [this.editorWidget_] : []; + storeState(): { isPreview: boolean, editorState: object } { + const { _isPreview: isPreview } = this; + return { isPreview, editorState: this.editor.storeViewState() }; } - 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(); + restoreState(oldState: { isPreview: boolean, editorState: object }): void { + if (!oldState.isPreview) { + this.convertToNonPreview(); } + this.editor.restoreViewState(oldState.editorState); } } diff --git a/packages/editor-preview/src/browser/index.ts b/packages/editor-preview/src/browser/index.ts deleted file mode 100644 index 217243d7b6fa5..0000000000000 --- a/packages/editor-preview/src/browser/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/******************************************************************************** - * 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/src/browser/editor-manager.ts b/packages/editor/src/browser/editor-manager.ts index 0f960b2adb9a4..ecfc4673c6217 100644 --- a/packages/editor/src/browser/editor-manager.ts +++ b/packages/editor/src/browser/editor-manager.ts @@ -16,8 +16,8 @@ import { injectable, postConstruct, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; -import { RecursivePartial, Emitter, Event } from '@theia/core/lib/common'; -import { WidgetOpenerOptions, NavigatableWidgetOpenHandler, NavigatableWidgetOptions } from '@theia/core/lib/browser'; +import { RecursivePartial, Emitter, Event, MaybePromise } from '@theia/core/lib/common'; +import { WidgetOpenerOptions, NavigatableWidgetOpenHandler, NavigatableWidgetOptions, Widget } from '@theia/core/lib/browser'; import { EditorWidget } from './editor-widget'; import { Range, Position, Location } from './editor'; import { EditorWidgetFactory } from './editor-widget-factory'; @@ -84,7 +84,7 @@ export class EditorManager extends NavigatableWidgetOpenHandler { this.updateCurrentEditor(); } - async getByUri(uri: URI, options?: EditorOpenerOptions): Promise { + getByUri(uri: URI, options?: EditorOpenerOptions): MaybePromise | undefined { return this.getWidget(uri, options); } @@ -92,20 +92,21 @@ export class EditorManager extends NavigatableWidgetOpenHandler { return this.getOrCreateWidget(uri, options); } - protected async getWidget(uri: URI, options?: EditorOpenerOptions): Promise { - const optionsWithCounter: EditorOpenerOptions = { counter: this.getCounterForUri(uri), ...options }; - const editor = await super.getWidget(uri, optionsWithCounter); - if (editor) { + protected getWidget(uri: URI, options?: EditorOpenerOptions): MaybePromise | undefined { + const editorPromise = super.getWidget(uri, options); + if (editorPromise) { // Reveal selection before attachment to manage nav stack. (https://github.com/eclipse-theia/theia/issues/8955) - this.revealSelection(editor, optionsWithCounter, uri); + if (!(editorPromise instanceof Widget)) { + editorPromise.then(editor => this.revealSelection(editor, options, uri)); + } else { + this.revealSelection(editorPromise, options); + } } - return editor; + return editorPromise; } protected async getOrCreateWidget(uri: URI, options?: EditorOpenerOptions): Promise { - const counter = options?.counter === undefined ? this.getOrCreateCounterForUri(uri) : options.counter; - const optionsWithCounter: EditorOpenerOptions = { ...options, counter }; - const editor = await super.getOrCreateWidget(uri, optionsWithCounter); + const editor = await super.getOrCreateWidget(uri, options); // Reveal selection before attachment to manage nav stack. (https://github.com/eclipse-theia/theia/issues/8955) this.revealSelection(editor, options, uri); return editor; @@ -287,9 +288,7 @@ export class EditorManager extends NavigatableWidgetOpenHandler { protected createWidgetOptions(uri: URI, options?: EditorOpenerOptions): NavigatableWidgetOptions { const navigatableOptions = super.createWidgetOptions(uri, options); - if (options?.counter !== undefined) { - navigatableOptions.counter = options.counter; - } + navigatableOptions.counter = options?.counter ?? this.getOrCreateCounterForUri(uri); return navigatableOptions; } } diff --git a/packages/editor/src/browser/editor-widget-factory.ts b/packages/editor/src/browser/editor-widget-factory.ts index 5cf0dc3a6681a..3e6ce48883f9c 100644 --- a/packages/editor/src/browser/editor-widget-factory.ts +++ b/packages/editor/src/browser/editor-widget-factory.ts @@ -43,8 +43,7 @@ export class EditorWidgetFactory implements WidgetFactory { } protected async createEditor(uri: URI, options?: NavigatableWidgetOptions): Promise { - const textEditor = await this.editorProvider(uri); - const newEditor = new EditorWidget(textEditor, this.selectionService); + const newEditor = await this.constructEditor(uri); this.setLabels(newEditor, uri); const labelListener = this.labelProvider.onDidChange(event => { @@ -62,11 +61,15 @@ export class EditorWidgetFactory implements WidgetFactory { return newEditor; } + protected async constructEditor(uri: URI): Promise { + const textEditor = await this.editorProvider(uri); + return new EditorWidget(textEditor, this.selectionService); + } + private setLabels(editor: EditorWidget, uri: URI): void { editor.title.caption = this.labelProvider.getLongName(uri); const icon = this.labelProvider.getIcon(uri); editor.title.label = this.labelProvider.getName(uri); editor.title.iconClass = icon + ' file-icon'; - } } diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index e17d31912bc20..14f9271262807 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -29,7 +29,6 @@ import { SelectableTreeNode, SHELL_TABBAR_CONTEXT_MENU, Widget, - Title } from '@theia/core/lib/browser'; import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution'; import { @@ -265,15 +264,15 @@ export class FileNavigatorContribution extends AbstractViewContribution { - const widget = this.findTargetedWidget(event); + const widget = this.shell.findTargetedWidget(event); this.openView({ activate: true }).then(() => this.selectWidgetFileNode(widget || this.shell.currentWidget)); }, isEnabled: (event?: Event) => { - const widget = this.findTargetedWidget(event); + const widget = this.shell.findTargetedWidget(event); return widget ? Navigatable.is(widget) : Navigatable.is(this.shell.currentWidget); }, isVisible: (event?: Event) => { - const widget = this.findTargetedWidget(event); + const widget = this.shell.findTargetedWidget(event); return widget ? Navigatable.is(widget) : Navigatable.is(this.shell.currentWidget); } }); @@ -558,19 +557,6 @@ export class FileNavigatorContribution extends AbstractViewContribution | undefined; - if (event) { - const tab = this.shell.findTabBar(event); - title = tab && this.shell.findTitle(tab, event); - } - return title && title.owner; - } - /** * Reveals and selects node in the file navigator to which given widget is related. * Does nothing if given widget undefined or doesn't have related resource. diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index c471856b1906f..7b5ad6809ecfa 100755 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -418,7 +418,6 @@ export class PluginVscodeCommandsContribution implements CommandContribution { /** * TODO: - * Keep Open: workbench.action.keepEditor * Open Next: workbench.action.openNextRecentlyUsedEditorInGroup * Open Previous: workbench.action.openPreviousRecentlyUsedEditorInGroup * Copy Path of Active File: workbench.action.files.copyPathOfActiveFile diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.spec.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.spec.ts new file mode 100644 index 0000000000000..845cd97bc0527 --- /dev/null +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.spec.ts @@ -0,0 +1,108 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any,no-unused-expressions */ + +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +const disableJSDOM = enableJSDOM(); + +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +import { ApplicationProps } from '@theia/application-package/lib/application-props'; +FrontendApplicationConfigProvider.set({ + ...ApplicationProps.DEFAULT.frontend.config +}); + +import { expect } from 'chai'; +import { Container } from '@theia/core/shared/inversify'; +import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { bindPreferenceService } from '@theia/core/lib/browser/frontend-application-bindings'; +import { bindMockPreferenceProviders } from '@theia/core/lib/browser/preferences/test'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; +import { Disposable, MessageService } from '@theia/core/lib/common'; +import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; +import { PreferenceSchemaProvider } from '@theia/core/lib/browser'; + +disableJSDOM(); + +class MockFileService { + releaseContent = new Deferred(); + async read(): Promise<{ value: string }> { + await this.releaseContent.promise; + return { value: JSON.stringify({ 'editor.fontSize': 20 }) }; + } +} + +const DO_NOTHING = () => { }; +const RETURN_DISPOSABLE = () => Disposable.NULL; + +class MockTextModelService { + createModelReference(): any { + return { + dispose: DO_NOTHING, + object: { + onDidChangeContent: RETURN_DISPOSABLE, + onDirtyChanged: RETURN_DISPOSABLE, + onDidChangeValid: RETURN_DISPOSABLE, + } + }; + } +} + +const mockSchemaProvider = { getCombinedSchema: () => ({ properties: {} }) }; + +class LessAbstractPreferenceProvider extends AbstractResourcePreferenceProvider { + getUri(): any { } + getScope(): any { } +} + +describe('AbstractResourcePreferenceProvider', () => { + let provider: AbstractResourcePreferenceProvider; + let fileService: MockFileService; + + beforeEach(() => { + fileService = new MockFileService(); + const testContainer = new Container(); + bindPreferenceService(testContainer.bind.bind(testContainer)); + bindMockPreferenceProviders(testContainer.bind.bind(testContainer), testContainer.unbind.bind(testContainer)); + testContainer.rebind(PreferenceSchemaProvider).toConstantValue(mockSchemaProvider); + testContainer.bind(FileService).toConstantValue(fileService); + testContainer.bind(MonacoTextModelService).toConstantValue(new MockTextModelService); + testContainer.bind(MessageService).toConstantValue(undefined); + testContainer.bind(MonacoWorkspace).toConstantValue(undefined); + provider = testContainer.resolve(LessAbstractPreferenceProvider); + }); + + it('should not store any preferences before it is ready.', async () => { + const resolveWhenFinished = new Deferred(); + const errorIfReadyFirst = provider.ready.then(() => Promise.reject()); + + expect(provider.get('editor.fontSize')).to.be.undefined; + + resolveWhenFinished.resolve(); + fileService.releaseContent.resolve(); // Allow the initialization to run + + // This promise would reject if the provider had declared itself ready before we resolve `resolveWhenFinished` + await Promise.race([resolveWhenFinished.promise, errorIfReadyFirst]); + }); + + it('should report values in file when `ready` resolves.', async () => { + fileService.releaseContent.resolve(); + await provider.ready; + expect(provider.get('editor.fontSize')).to.equal(20); // The value provided by the mock FileService implementation. + }); +}); diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index 82be52478495a..6bd5ea780eb8a 100644 --- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts @@ -22,13 +22,14 @@ import { JSONExt } from '@theia/core/shared/@phosphor/coreutils'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { MessageService } from '@theia/core/lib/common/message-service'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { PreferenceProvider, PreferenceSchemaProvider, PreferenceScope, PreferenceProviderDataChange, PreferenceService } from '@theia/core/lib/browser'; +import { PreferenceProvider, PreferenceSchemaProvider, PreferenceScope, PreferenceProviderDataChange } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; @injectable() export abstract class AbstractResourcePreferenceProvider extends PreferenceProvider { @@ -36,10 +37,11 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi protected preferences: { [key: string]: any } = {}; protected model: MonacoEditorModel | undefined; protected readonly loading = new Deferred(); + protected modelInitialized = false; - @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(MessageService) protected readonly messageService: MessageService; @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; + @inject(FileService) protected readonly fileService: FileService; @inject(PreferenceConfigurations) protected readonly configurations: PreferenceConfigurations; @@ -54,6 +56,7 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi protected async init(): Promise { const uri = this.getUri(); this.toDispose.push(Disposable.create(() => this.loading.reject(new Error(`preference provider for '${uri}' was disposed`)))); + await this.readPreferencesFromFile(); this._ready.resolve(); const reference = await this.textModelService.createModelReference(uri); @@ -64,11 +67,11 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi this.model = reference.object; this.loading.resolve(); + this.modelInitialized = true; this.toDispose.push(reference); this.toDispose.push(Disposable.create(() => this.model = undefined)); - this.readPreferences(); this.toDispose.push(this.model.onDidChangeContent(() => this.readPreferences())); this.toDispose.push(this.model.onDirtyChanged(() => this.readPreferences())); this.toDispose.push(this.model.onDidChangeValid(() => this.readPreferences())); @@ -80,7 +83,7 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi protected abstract getScope(): PreferenceScope; protected get valid(): boolean { - return this.model && this.model.valid || false; + return this.modelInitialized ? !!this.model?.valid : Object.keys(this.preferences).length > 0; } getConfigUri(): URI; @@ -165,6 +168,11 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi return [preferenceName]; } + protected async readPreferencesFromFile(): Promise { + const content = await this.fileService.read(this.getUri()).catch(() => ({ value: '' })); + this.readPreferencesFromContent(content.value); + } + /** * It HAS to be sync to ensure that `setPreference` returns only when values are updated * or any other operation modifying the monaco model content. @@ -175,20 +183,24 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi return; } try { - let preferences; - if (model.valid) { - const content = model.getText(); - const jsonContent = this.parse(content); - preferences = this.getParsedContent(jsonContent); - } else { - preferences = {}; - } - this.handlePreferenceChanges(preferences); + const content = model.valid ? model.getText() : ''; + this.readPreferencesFromContent(content); } catch (e) { console.error(`Failed to load preferences from '${this.getUri()}'.`, e); } } + protected readPreferencesFromContent(content: string): void { + let preferencesInJson; + try { + preferencesInJson = this.parse(content); + } catch { + preferencesInJson = {}; + } + const parsedPreferences = this.getParsedContent(preferencesInJson); + this.handlePreferenceChanges(parsedPreferences); + } + protected parse(content: string): any { content = content.trim(); if (!content) {