From 2dfc8aa1adfef9402fcbda0f4a3c521dbab049d7 Mon Sep 17 00:00:00 2001 From: elaihau Date: Tue, 13 Nov 2018 13:53:40 -0500 Subject: [PATCH 01/49] add minimatch to navigator&filesystem package.json "minimatch" is used in 2 extensions (filesystem and navigator) while the dependency is declared in the package.json of neither. This change adds it back. Signed-off-by: elaihau --- packages/filesystem/package.json | 1 + packages/navigator/package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/package.json b/packages/filesystem/package.json index 410990b587dbe..dde62bf15f1c0 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -18,6 +18,7 @@ "fs-extra": "^4.0.2", "http-status-codes": "^1.3.0", "mime-types": "^2.1.18", + "minimatch": "^3.0.4", "mv": "^2.1.1", "rimraf": "^2.6.2", "tar-fs": "^1.16.2", diff --git a/packages/navigator/package.json b/packages/navigator/package.json index 1597a4269e56a..b3b9501cd82c9 100644 --- a/packages/navigator/package.json +++ b/packages/navigator/package.json @@ -6,7 +6,8 @@ "@theia/core": "^0.3.16", "@theia/filesystem": "^0.3.16", "@theia/workspace": "^0.3.16", - "fuzzy": "^0.1.3" + "fuzzy": "^0.1.3", + "minimatch": "^3.0.4" }, "publishConfig": { "access": "public" From 69eaf80db46a490e784e05da084f84b73f01274d Mon Sep 17 00:00:00 2001 From: Florent Benoit Date: Mon, 12 Nov 2018 13:47:42 +0100 Subject: [PATCH 02/49] In production mode, theia script may not be there, use same runtime as current process to spawn new process Change-Id: I11505e82d691fb5a4cbf6be8b11847593a27b613 Signed-off-by: Florent Benoit --- .../src/hosted/node/hosted-instance-manager.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts b/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts index b7352767e97f6..518927670085d 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts @@ -26,6 +26,7 @@ import { LogType } from './../../common/types'; import { HostedPluginUriPostProcessor, HostedPluginUriPostProcessorSymbolName } from './hosted-plugin-uri-postprocessor'; import { HostedPluginSupport } from './hosted-plugin'; import { DebugConfiguration } from '../../common'; +import { environment } from '@theia/core'; const processTree = require('ps-tree'); export const HostedInstanceManager = Symbol('HostedInstanceManager'); @@ -232,7 +233,21 @@ export abstract class AbstractHostedInstanceManager implements HostedInstanceMan } protected async getStartCommand(port?: number, debugConfig?: DebugConfiguration): Promise { - const command = ['yarn', 'theia', 'start']; + + const processArguments = process.argv; + let command: string[]; + if (environment.electron.is()) { + command = ['yarn', 'theia', 'start']; + } else { + command = processArguments.filter(arg => { + // remove --port= argument if set + if (arg.startsWith('--port=')) { + return; + } else { + return arg; + } + }); + } if (process.env.HOSTED_PLUGIN_HOSTNAME) { command.push('--hostname=' + process.env.HOSTED_PLUGIN_HOSTNAME); } From 7c444871227012aa913963141f4824e7a89fa3e0 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Tue, 13 Nov 2018 20:10:01 -0500 Subject: [PATCH 03/49] Fix tslint:no-any errors - fixed tslint `no-any` errors in an effort to improve code quality metrics Signed-off-by: Vincent Fugnitto --- .../application-package/src/json-file.ts | 3 +- dev-packages/cli/src/theia.ts | 1 + examples/electron/test/basic-example.espec.ts | 3 +- .../test/mock-preference-service.ts | 1 + .../src/browser/test/mock-storage-service.ts | 3 + .../core/src/browser/tree/tree-widget.tsx | 1 + packages/core/src/browser/tree/tree.spec.ts | 93 ++++++++++--------- .../common/messaging/proxy-factory.spec.ts | 2 +- .../src/common/messaging/proxy-factory.ts | 4 +- packages/core/src/common/test/mock-logger.ts | 2 + .../browser/quick-file-open-contribution.ts | 1 + .../browser/file-dialog/file-dialog-widget.ts | 1 + .../src/browser/file-tree/file-tree-model.ts | 2 + .../nsfw-filesystem-watcher.spec.ts | 1 + .../browser/textmate/monaco-theme-registry.ts | 10 +- .../node/plugin-vscode-directory-handler.ts | 1 + .../src/node/plugin-vscode-init.ts | 4 +- .../src/node/plugin-vscode-resolver.ts | 2 + .../src/hosted/browser/hosted-plugin.ts | 3 + .../src/hosted/browser/worker/worker-main.ts | 1 + .../hosted/node/hosted-instance-manager.ts | 2 + .../src/hosted/node/hosted-plugin-process.ts | 3 + .../src/hosted/node/hosted-plugin.ts | 2 + .../src/hosted/node/plugin-host-rpc.ts | 1 + .../src/hosted/node/scanners/scanner-theia.ts | 1 + .../src/main/browser/command-registry-main.ts | 3 + .../src/main/browser/languages-main.ts | 5 +- .../src/main/browser/message-registry-main.ts | 3 + .../main/browser/preference-registry-main.ts | 1 + .../src/main/browser/workspace-main.ts | 1 + .../plugin-theia-directory-handler.ts | 1 + .../main/node/plugin-deployer-entry-impl.ts | 1 + .../src/main/node/plugin-deployer-impl.ts | 3 + .../plugin-deployer-resolver-context-impl.ts | 2 + .../src/main/node/plugin-github-resolver.ts | 1 + .../resolvers/plugin-local-dir-resolver.ts | 2 + .../plugin-ext/src/plugin/command-registry.ts | 4 + .../plugin/languages/document-formatting.ts | 1 + .../plugin/languages/on-type-formatting.ts | 1 + .../src/plugin/languages/range-formatting.ts | 1 + .../plugin-ext/src/plugin/tree/tree-views.ts | 2 + .../plugin-ext/src/plugin/type-converters.ts | 1 + packages/plugin-ext/src/plugin/types-impl.ts | 15 +-- .../markdown/markdown-preview-handler.spec.ts | 1 + 44 files changed, 134 insertions(+), 63 deletions(-) diff --git a/dev-packages/application-package/src/json-file.ts b/dev-packages/application-package/src/json-file.ts index bd29cb09f94d5..c883ed8ef87a0 100644 --- a/dev-packages/application-package/src/json-file.ts +++ b/dev-packages/application-package/src/json-file.ts @@ -15,8 +15,9 @@ ********************************************************************************/ import * as fs from 'fs'; - import writeJsonFile = require('write-json-file'); + +// tslint:disable-next-line:no-any function readJsonFile(path: string): any { return JSON.parse(fs.readFileSync(path, { encoding: 'utf-8' })); } diff --git a/dev-packages/cli/src/theia.ts b/dev-packages/cli/src/theia.ts index 4294e0c6ca2cb..cf220ef628e76 100644 --- a/dev-packages/cli/src/theia.ts +++ b/dev-packages/cli/src/theia.ts @@ -121,6 +121,7 @@ function rebuildCommand(command: string, target: ApplicationProps.Target): yargs .command(rebuildCommand('rebuild:electron', 'electron')); // see https://github.com/yargs/yargs/issues/287#issuecomment-314463783 + // tslint:disable-next-line:no-any const commands = (yargs as any).getCommandInstance().getCommands(); const argv = yargs.demandCommand(1).argv; const command = argv._[0]; diff --git a/examples/electron/test/basic-example.espec.ts b/examples/electron/test/basic-example.espec.ts index 1c4302f4b2c82..344fc008102c5 100644 --- a/examples/electron/test/basic-example.espec.ts +++ b/examples/electron/test/basic-example.espec.ts @@ -26,13 +26,14 @@ const { app } = require('electron'); describe('basic-example-spec', () => { describe('01 #start example app', () => { - it('should start the electron example app', (done) => { + it('should start the electron example app', done => { if (app.isReady()) { require('../src-gen/backend/main'); // start the express server mainWindow.webContents.openDevTools(); mainWindow.loadURL(`file://${path.join(__dirname, 'index.html')}`); } + // tslint:disable-next-line:no-unused-expression expect(mainWindow.isVisible()).to.be.true; done(); }); diff --git a/packages/core/src/browser/preferences/test/mock-preference-service.ts b/packages/core/src/browser/preferences/test/mock-preference-service.ts index 92747dc6100e2..98dbac441e2cc 100644 --- a/packages/core/src/browser/preferences/test/mock-preference-service.ts +++ b/packages/core/src/browser/preferences/test/mock-preference-service.ts @@ -27,6 +27,7 @@ export class MockPreferenceService implements PreferenceService { get(preferenceName: string, defaultValue?: T): T | undefined { return undefined; } + // tslint:disable-next-line:no-any set(preferenceName: string, value: any): Promise { return Promise.resolve(); } ready: Promise = Promise.resolve(); readonly onPreferenceChanged: Event = new Emitter().event; diff --git a/packages/core/src/browser/test/mock-storage-service.ts b/packages/core/src/browser/test/mock-storage-service.ts index 475d8d1c54390..03b14010062a9 100644 --- a/packages/core/src/browser/test/mock-storage-service.ts +++ b/packages/core/src/browser/test/mock-storage-service.ts @@ -23,8 +23,11 @@ import { injectable } from 'inversify'; @injectable() export class MockStorageService implements StorageService { readonly data = new Map(); + + // tslint:disable-next-line:no-any onSetDataCallback?: (key: string, data?: any) => void; + // tslint:disable-next-line:no-any onSetData(callback: (key: string, data?: any) => void) { this.onSetDataCallback = callback; } diff --git a/packages/core/src/browser/tree/tree-widget.tsx b/packages/core/src/browser/tree/tree-widget.tsx index cf14f81408c3b..6a6bd2ad22eb7 100644 --- a/packages/core/src/browser/tree/tree-widget.tsx +++ b/packages/core/src/browser/tree/tree-widget.tsx @@ -653,6 +653,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { this.addKeyListener(this.node, up, event => this.handleUp(event)); this.addKeyListener(this.node, down, event => this.handleDown(event)); this.addKeyListener(this.node, Key.ENTER, event => this.handleEnter(event)); + // tslint:disable-next-line:no-any this.addEventListener(this.node, 'ps-scroll-y', (e: Event & { target: { scrollTop: number } }) => { if (this.view && this.view.list && this.view.list.Grid) { const { scrollTop } = e.target; diff --git a/packages/core/src/browser/tree/tree.spec.ts b/packages/core/src/browser/tree/tree.spec.ts index 300b707fc6414..6254dd83ddb05 100644 --- a/packages/core/src/browser/tree/tree.spec.ts +++ b/packages/core/src/browser/tree/tree.spec.ts @@ -19,8 +19,8 @@ import { TreeNode, CompositeTreeNode } from './tree'; describe('Tree', () => { - it('addChildren', () => { - assertTreeNode(`{ + it('addChildren', () => { + assertTreeNode(`{ "id": "parent", "name": "parent", "children": [ @@ -45,12 +45,12 @@ describe('Tree', () => { } ] }`, getNode()); - }); + }); - it('removeChild - first', () => { - const node = getNode(); - CompositeTreeNode.removeChild(node, node.children[0]); - assertTreeNode(`{ + it('removeChild - first', () => { + const node = getNode(); + CompositeTreeNode.removeChild(node, node.children[0]); + assertTreeNode(`{ "id": "parent", "name": "parent", "children": [ @@ -68,12 +68,12 @@ describe('Tree', () => { } ] }`, node); - }); + }); - it('removeChild - second', () => { - const node = getNode(); - CompositeTreeNode.removeChild(node, node.children[1]); - assertTreeNode(`{ + it('removeChild - second', () => { + const node = getNode(); + CompositeTreeNode.removeChild(node, node.children[1]); + assertTreeNode(`{ "id": "parent", "name": "parent", "children": [ @@ -91,12 +91,12 @@ describe('Tree', () => { } ] }`, node); - }); + }); - it('removeChild - thrid', () => { - const node = getNode(); - CompositeTreeNode.removeChild(node, node.children[2]); - assertTreeNode(`{ + it('removeChild - thrid', () => { + const node = getNode(); + CompositeTreeNode.removeChild(node, node.children[2]); + assertTreeNode(`{ "id": "parent", "name": "parent", "children": [ @@ -114,36 +114,37 @@ describe('Tree', () => { } ] }`, node); - }); + }); - function getNode(): CompositeTreeNode { - return CompositeTreeNode.addChildren({ - id: 'parent', - name: 'parent', - children: [], - parent: undefined - }, [{ - id: 'foo', - name: 'foo', - parent: undefined - }, { - id: 'bar', - name: 'bar', - parent: undefined - }, { - id: 'baz', - name: 'baz', - parent: undefined - }]); - } + function getNode(): CompositeTreeNode { + return CompositeTreeNode.addChildren({ + id: 'parent', + name: 'parent', + children: [], + parent: undefined + }, [{ + id: 'foo', + name: 'foo', + parent: undefined + }, { + id: 'bar', + name: 'bar', + parent: undefined + }, { + id: 'baz', + name: 'baz', + parent: undefined + }]); + } - function assertTreeNode(expectation: string, node: TreeNode): void { - assert.deepEqual(expectation, JSON.stringify(node, (key: keyof CompositeTreeNode, value: any) => { - if (key === 'parent' || key === 'previousSibling' || key === 'nextSibling') { - return value && value.id; - } - return value; - }, 2)); - } + function assertTreeNode(expectation: string, node: TreeNode): void { + // tslint:disable-next-line:no-any + assert.deepEqual(expectation, JSON.stringify(node, (key: keyof CompositeTreeNode, value: any) => { + if (key === 'parent' || key === 'previousSibling' || key === 'nextSibling') { + return value && value.id; + } + return value; + }, 2)); + } }); diff --git a/packages/core/src/common/messaging/proxy-factory.spec.ts b/packages/core/src/common/messaging/proxy-factory.spec.ts index 6b3f3429b5899..2b7f8cd05cd26 100644 --- a/packages/core/src/common/messaging/proxy-factory.spec.ts +++ b/packages/core/src/common/messaging/proxy-factory.spec.ts @@ -24,8 +24,8 @@ const expect = chai.expect; class NoTransform extends stream.Transform { + // tslint:disable-next-line:no-any public _transform(chunk: any, encoding: string, callback: Function): void { - // console.log((chunk as Buffer).toString()) callback(undefined, chunk); } } diff --git a/packages/core/src/common/messaging/proxy-factory.ts b/packages/core/src/common/messaging/proxy-factory.ts index b0687ac82567f..85d2dfaee26bb 100644 --- a/packages/core/src/common/messaging/proxy-factory.ts +++ b/packages/core/src/common/messaging/proxy-factory.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { MessageConnection, ResponseError } from 'vscode-jsonrpc'; import { ApplicationError } from '../application-error'; import { Event, Emitter } from '../event'; @@ -271,7 +273,7 @@ export class JsonRpcProxyFactory implements ProxyHandler { } protected deserializeError(capturedError: Error, e: any): any { if (e instanceof ResponseError) { - const capturedStack = capturedError.stack || ''; + const capturedStack = capturedError.stack || ''; if (e.data && e.data.kind === 'application') { const { stack, data, message } = e.data; return ApplicationError.fromJson(e.code, { diff --git a/packages/core/src/common/test/mock-logger.ts b/packages/core/src/common/test/mock-logger.ts index 5f10d8442b475..2eb399082971c 100644 --- a/packages/core/src/common/test/mock-logger.ts +++ b/packages/core/src/common/test/mock-logger.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { injectable } from 'inversify'; import { ILogger, Loggable } from '../logger'; diff --git a/packages/file-search/src/browser/quick-file-open-contribution.ts b/packages/file-search/src/browser/quick-file-open-contribution.ts index 807fb29215af0..719b370d1ce11 100644 --- a/packages/file-search/src/browser/quick-file-open-contribution.ts +++ b/packages/file-search/src/browser/quick-file-open-contribution.ts @@ -28,6 +28,7 @@ export class QuickFileOpenFrontendContribution implements CommandContribution, K registerCommands(commands: CommandRegistry): void { commands.registerCommand(quickFileOpen, { + // tslint:disable-next-line:no-any execute: (args: any[]) => { if (args) { const [fileURI] = args; diff --git a/packages/filesystem/src/browser/file-dialog/file-dialog-widget.ts b/packages/filesystem/src/browser/file-dialog/file-dialog-widget.ts index e5979c2a6b8bf..09227ececf4d9 100644 --- a/packages/filesystem/src/browser/file-dialog/file-dialog-widget.ts +++ b/packages/filesystem/src/browser/file-dialog/file-dialog-widget.ts @@ -42,6 +42,7 @@ export class FileDialogWidget extends FileTreeWidget { } protected createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes { + // tslint:disable-next-line:no-any const attr = super.createNodeAttributes(node, props) as any; if (this.shouldDisableSelection(node)) { const keys = Object.keys(attr); diff --git a/packages/filesystem/src/browser/file-tree/file-tree-model.ts b/packages/filesystem/src/browser/file-tree/file-tree-model.ts index 3eb2a59e0d0b1..72fd45b1c190a 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-model.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree-model.ts @@ -212,6 +212,7 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { * Read all entries within a folder by block of 100 files or folders until the * whole folder has been read. */ + // tslint:disable-next-line:no-any protected readEntries(entry: WebKitDirectoryEntry, cb: (items: any) => void): void { const reader = entry.createReader(); const getEntries = () => { @@ -232,6 +233,7 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { } protected uploadFileEntry(base: URI, entry: WebKitFileEntry): void { + // tslint:disable-next-line:no-any entry.file(file => this.uploadFile(base, file as any)); } diff --git a/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.spec.ts b/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.spec.ts index fcbb492518694..60f653487c868 100644 --- a/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.spec.ts +++ b/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.spec.ts @@ -125,6 +125,7 @@ describe('nsfw-filesystem-watcher', function () { }); +// tslint:disable-next-line:no-any process.on('unhandledRejection', (reason: any) => { console.error('Unhandled promise rejection: ' + reason); }); diff --git a/packages/monaco/src/browser/textmate/monaco-theme-registry.ts b/packages/monaco/src/browser/textmate/monaco-theme-registry.ts index 243bfacdbf391..3b448998d7484 100644 --- a/packages/monaco/src/browser/textmate/monaco-theme-registry.ts +++ b/packages/monaco/src/browser/textmate/monaco-theme-registry.ts @@ -15,22 +15,24 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { IRawTheme, Registry } from 'vscode-textmate'; -export interface ThemeMix extends IRawTheme, monaco.editor.IStandaloneThemeData {} +export interface ThemeMix extends IRawTheme, monaco.editor.IStandaloneThemeData { } export class MonacoThemeRegistry { protected themes = new Map(); - public getTheme(name: string): IRawTheme| undefined { + public getTheme(name: string): IRawTheme | undefined { return this.themes.get(name); } /** * Register VS Code compatible themes */ - public register(json: any, includes?: {[includePath: string]: any}, givenName?: string, monacoBase?: monaco.editor.BuiltinTheme): ThemeMix { + public register(json: any, includes?: { [includePath: string]: any }, givenName?: string, monacoBase?: monaco.editor.BuiltinTheme): ThemeMix { const name = givenName || json.name!; const result: ThemeMix = { name, @@ -83,7 +85,7 @@ export class MonacoThemeRegistry { return result; } - protected transform(tokenColor: any, acceptor: (rule: monaco.editor.ITokenThemeRule) => void) { + protected transform(tokenColor: any, acceptor: (rule: monaco.editor.ITokenThemeRule) => void) { if (typeof tokenColor.scope === 'undefined') { tokenColor.scope = ['']; } else if (typeof tokenColor.scope === 'string') { diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts index a4255b157641c..5162dc604d97a 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts @@ -74,6 +74,7 @@ export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHand } + // tslint:disable-next-line:no-any handle(context: PluginDeployerDirectoryHandlerContext): Promise { const packageJson: PluginPackage = context.pluginEntry().getValue('package.json'); if (packageJson.main) { diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index a328abb1e2dac..4865b5e1eb219 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import * as theia from '@theia/plugin'; import { BackendInitializationFn, PluginAPIFactory, Plugin, emptyPlugin } from '@theia/plugin-ext'; @@ -36,7 +38,7 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF } else { commandLabel = commandItem.title; } - vscode.commands.registerCommand({id: commandItem.command, label: commandLabel }); + vscode.commands.registerCommand({ id: commandItem.command, label: commandLabel }); }); } diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-resolver.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-resolver.ts index 1fb0604dc0dd2..08bd9caf904e7 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-resolver.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-resolver.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { PluginDeployerResolver, PluginDeployerResolverContext } from '@theia/plugin-ext'; import { injectable } from 'inversify'; import * as request from 'request'; diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index ae6af964e8c1a..19a584cf4270f 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -13,6 +13,9 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +// tslint:disable:no-any + import { injectable, inject, interfaces } from 'inversify'; import { PluginWorker } from '../../main/browser/plugin-worker'; import { HostedPluginServer, PluginMetadata, getPluginId } from '../../common/plugin-protocol'; diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts index f71031860e16a..6536be32484fe 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts @@ -107,6 +107,7 @@ const apiFactory = createAPIFactory(rpc, pluginManager, envExt, preferenceRegist let defaultApi: typeof theia; const handler = { + // tslint:disable-next-line:no-any get: (target: any, name: string) => { const plugin = pluginsModulesNames.get(name); if (plugin) { diff --git a/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts b/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts index 518927670085d..09c7c57e63dec 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-instance-manager.ts @@ -144,7 +144,9 @@ export abstract class AbstractHostedInstanceManager implements HostedInstanceMan terminate(): void { if (this.isPluginRunnig) { + // tslint:disable-next-line:no-any processTree(this.hostedInstanceProcess.pid, (err: Error, children: Array) => { + // tslint:disable-next-line:no-any const args = ['-SIGTERM', this.hostedInstanceProcess.pid.toString()].concat(children.map((p: any) => p.PID)); cp.spawn('kill', args); }); diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts index 992076ae09833..17bd890821391 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import * as path from 'path'; import * as cp from 'child_process'; import { injectable, inject } from 'inversify'; @@ -52,10 +53,12 @@ export class HostedPluginProcess implements ServerPluginRunner { } + // tslint:disable-next-line:no-any public acceptMessage(jsonMessage: any): boolean { return jsonMessage.type !== undefined && jsonMessage.id; } + // tslint:disable-next-line:no-any public onMessage(jsonMessage: any): void { if (this.childProcess) { this.childProcess.send(JSON.stringify(jsonMessage)); diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin.ts index d8559fd452d2a..09e08c83d7915 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import { injectable, inject, multiInject, postConstruct, optional } from 'inversify'; import { ILogger, ConnectionErrorHandler } from '@theia/core/lib/common'; import { HostedPluginClient, PluginModel, ServerPluginRunner } from '../../common/plugin-protocol'; @@ -72,6 +73,7 @@ export class HostedPluginSupport { onMessage(message: string): void { // need to perform routing + // tslint:disable-next-line:no-any const jsonMessage: any = JSON.parse(message); if (this.pluginRunners.length > 0) { this.pluginRunners.forEach(runner => { diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts index 2ca7c629a95d5..c53ad9b743b59 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts @@ -30,6 +30,7 @@ export class PluginHostRPC { private pluginManager: PluginManagerExtImpl; + // tslint:disable-next-line:no-any constructor(protected readonly rpc: any) { } diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 7996e35626801..5f7b6c0234043 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -144,6 +144,7 @@ export class TheiaPluginScanner implements PluginScanner { return contributions; } + // tslint:disable-next-line:no-any private readConfiguration(rawConfiguration: any, pluginPath: string): any { return { type: rawConfiguration.type, diff --git a/packages/plugin-ext/src/main/browser/command-registry-main.ts b/packages/plugin-ext/src/main/browser/command-registry-main.ts index 4991373c3d16d..491f786da56ef 100644 --- a/packages/plugin-ext/src/main/browser/command-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/command-registry-main.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import { interfaces } from 'inversify'; import { CommandRegistry } from '@theia/core/lib/common/command'; import * as theia from '@theia/plugin'; @@ -33,6 +34,7 @@ export class CommandRegistryMainImpl implements CommandRegistryMain { this.disposables.set( command.id, this.delegate.registerCommand(command, { + // tslint:disable-next-line:no-any execute: (...args: any[]) => { this.proxy.$executeCommand(command.id, ...args); }, @@ -47,6 +49,7 @@ export class CommandRegistryMainImpl implements CommandRegistryMain { this.disposables.delete(id); } } + // tslint:disable-next-line:no-any $executeCommand(id: string, args: any[]): PromiseLike { try { return Promise.resolve(this.delegate.executeCommand(id, ...args)); diff --git a/packages/plugin-ext/src/main/browser/languages-main.ts b/packages/plugin-ext/src/main/browser/languages-main.ts index b7fc7b9d49ec7..e8f81b4b35a79 100644 --- a/packages/plugin-ext/src/main/browser/languages-main.ts +++ b/packages/plugin-ext/src/main/browser/languages-main.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import { LanguagesMain, SerializedLanguageConfiguration, @@ -76,6 +77,7 @@ export class LanguagesMainImpl implements LanguagesMain { return { suggestions: result.completions, incomplete: result.incomplete, + // tslint:disable-next-line:no-any dispose: () => this.proxy.$releaseCompletionItems(handle, (result)._id) }; }), @@ -220,6 +222,7 @@ export class LanguagesMainImpl implements LanguagesMain { }; } + // tslint:disable-next-line:no-any $emitCodeLensEvent(eventHandle: number, event?: any): void { const obj = this.disposables.get(eventHandle); if (obj instanceof Emitter) { @@ -271,7 +274,7 @@ export class LanguagesMainImpl implements LanguagesMain { if (Array.isArray(result)) { const references: monaco.languages.Location[] = []; for (const item of result) { - references.push({...item, uri: monaco.Uri.revive(item.uri) }); + references.push({ ...item, uri: monaco.Uri.revive(item.uri) }); } return references; } diff --git a/packages/plugin-ext/src/main/browser/message-registry-main.ts b/packages/plugin-ext/src/main/browser/message-registry-main.ts index 57374ff3375f4..b30e74fca1981 100644 --- a/packages/plugin-ext/src/main/browser/message-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/message-registry-main.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import { interfaces } from 'inversify'; import * as theia from '@theia/plugin'; import { MessageService } from '@theia/core/lib/common/message-service'; @@ -44,7 +45,9 @@ export class MessageRegistryMainImpl implements MessageRegistryMain { return this.showMessage(MessageType.Error, message, optionsOrFirstItem, ...items); } + // tslint:disable-next-line:no-any protected showMessage(type: MessageType, message: string, ...args: any[]): PromiseLike { + // tslint:disable-next-line:no-any const actionsMap = new Map(); const actionTitles: string[] = []; const options: theia.MessageOptions = { modal: false }; diff --git a/packages/plugin-ext/src/main/browser/preference-registry-main.ts b/packages/plugin-ext/src/main/browser/preference-registry-main.ts index a2c39a9ebf08e..784601ff68c94 100644 --- a/packages/plugin-ext/src/main/browser/preference-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/preference-registry-main.ts @@ -42,6 +42,7 @@ export class PreferenceRegistryMainImpl implements PreferenceRegistryMain { }); } + // tslint:disable-next-line:no-any $updateConfigurationOption(target: boolean | ConfigurationTarget | undefined, key: string, value: any): PromiseLike { const scope = this.parseConfigurationTarget(target); return this.preferenceService.set(key, value, scope); diff --git a/packages/plugin-ext/src/main/browser/workspace-main.ts b/packages/plugin-ext/src/main/browser/workspace-main.ts index 0fcc6d5089c70..6e0977e280f04 100644 --- a/packages/plugin-ext/src/main/browser/workspace-main.ts +++ b/packages/plugin-ext/src/main/browser/workspace-main.ts @@ -140,6 +140,7 @@ export class WorkspaceMainImpl implements WorkspaceMain { maxResults?: number, token?: theia.CancellationToken): Promise { const uris: UriComponents[] = new Array(); let j = 0; + // tslint:disable-next-line:no-any const promises: Promise[] = new Array(); for (const root of this.roots) { promises[j++] = this.fileSearchService.find(includePattern, { rootUri: root.uri }).then(value => { diff --git a/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts b/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts index fdef66afef5b9..3ffc9af9213aa 100644 --- a/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts +++ b/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts @@ -60,6 +60,7 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl } + // tslint:disable-next-line:no-any handle(context: PluginDeployerDirectoryHandlerContext): Promise { const types: PluginDeployerEntryType[] = []; const packageJson: PluginPackage = context.pluginEntry().getValue('package.json'); diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-entry-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-entry-impl.ts index 595590366c7f4..42314c2a20f6b 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-entry-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-entry-impl.ts @@ -23,6 +23,7 @@ export class PluginDeployerEntryImpl implements PluginDeployerEntry { private currentPath: string; + // tslint:disable-next-line:no-any private map: Map; private resolved: boolean; diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts index d21f474bda29e..a3548ad05cf33 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts @@ -13,6 +13,9 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +// tslint:disable:no-any + import { injectable, optional, multiInject, inject } from 'inversify'; import { PluginDeployerResolver, PluginDeployerFileHandler, PluginDeployerDirectoryHandler, diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-resolver-context-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-resolver-context-impl.ts index 20402a7503254..558cdc3231eb5 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-resolver-context-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-resolver-context-impl.ts @@ -22,12 +22,14 @@ export class PluginDeployerResolverContextImpl implements PluginDeployerResol /** * Name of the resolver for this context */ + // tslint:disable-next-line:no-any private resolverName: any; private pluginEntries: PluginDeployerEntry[]; constructor(resolver: T, private readonly sourceId: string) { this.pluginEntries = []; + // tslint:disable-next-line:no-any this.resolverName = (resolver as any).constructor.name; } diff --git a/packages/plugin-ext/src/main/node/plugin-github-resolver.ts b/packages/plugin-ext/src/main/node/plugin-github-resolver.ts index 5775ead1897be..86308998220f2 100644 --- a/packages/plugin-ext/src/main/node/plugin-github-resolver.ts +++ b/packages/plugin-ext/src/main/node/plugin-github-resolver.ts @@ -115,6 +115,7 @@ export class GithubPluginDeployerResolver implements PluginDeployerResolver { * Grab the github file specified by the plugin's ID */ protected grabGithubFile(pluginResolverContext: PluginDeployerResolverContext, orgName: string, repoName: string, filename: string, version: string, + // tslint:disable-next-line:no-any resolve: (value?: void | PromiseLike) => void, reject: (reason?: any) => void): void { const unpackedPath = path.resolve(this.unpackedFolder, path.basename(version + filename)); diff --git a/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts b/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts index e6f7851a6077f..05bee15ea17fb 100644 --- a/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts +++ b/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { PluginDeployerResolver, PluginDeployerResolverContext } from '../../../common/plugin-protocol'; import { injectable } from 'inversify'; import * as fs from 'fs'; diff --git a/packages/plugin-ext/src/plugin/command-registry.ts b/packages/plugin-ext/src/plugin/command-registry.ts index aca2e9ca6b6a8..e84bbdb24dad9 100644 --- a/packages/plugin-ext/src/plugin/command-registry.ts +++ b/packages/plugin-ext/src/plugin/command-registry.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + import * as theia from '@theia/plugin'; import { CommandRegistryExt, PLUGIN_RPC_CONTEXT as Ext, CommandRegistryMain } from '../api/plugin-api'; import { RPCProtocol } from '../api/rpc-protocol'; @@ -153,6 +154,7 @@ export class CommandsConverter { } } + // tslint:disable-next-line:no-any private executeConvertedCommand(...args: any[]): PromiseLike { const actualCmd = this.cache.get(args[0]); if (!actualCmd) { @@ -164,7 +166,9 @@ export class CommandsConverter { /** * @returns `false` if the provided object is an array and not empty. */ + // tslint:disable-next-line:no-any private static isFalsyOrEmpty(obj: any): boolean { + // tslint:disable-next-line:no-any return !Array.isArray(obj) || (>obj).length === 0; } } diff --git a/packages/plugin-ext/src/plugin/languages/document-formatting.ts b/packages/plugin-ext/src/plugin/languages/document-formatting.ts index b9ae28fa311f1..ed826fe3a22ce 100644 --- a/packages/plugin-ext/src/plugin/languages/document-formatting.ts +++ b/packages/plugin-ext/src/plugin/languages/document-formatting.ts @@ -36,6 +36,7 @@ export class DocumentFormattingAdapter { const doc = document.document; + // tslint:disable-next-line:no-any return Promise.resolve(this.provider.provideDocumentFormattingEdits(doc, options, createToken())).then(value => { if (Array.isArray(value)) { return value.map(Converter.fromTextEdit); diff --git a/packages/plugin-ext/src/plugin/languages/on-type-formatting.ts b/packages/plugin-ext/src/plugin/languages/on-type-formatting.ts index 3b4412fb1adca..02b5424127168 100644 --- a/packages/plugin-ext/src/plugin/languages/on-type-formatting.ts +++ b/packages/plugin-ext/src/plugin/languages/on-type-formatting.ts @@ -38,6 +38,7 @@ export class OnTypeFormattingAdapter { const doc = document.document; const pos = Converter.toPosition(position); + // tslint:disable-next-line:no-any return Promise.resolve(this.provider.provideOnTypeFormattingEdits(doc, pos, ch, options, createToken())).then(value => { if (Array.isArray(value)) { return value.map(Converter.fromTextEdit); diff --git a/packages/plugin-ext/src/plugin/languages/range-formatting.ts b/packages/plugin-ext/src/plugin/languages/range-formatting.ts index 1f7b3fa641a11..5ff1994b8e617 100644 --- a/packages/plugin-ext/src/plugin/languages/range-formatting.ts +++ b/packages/plugin-ext/src/plugin/languages/range-formatting.ts @@ -37,6 +37,7 @@ export class RangeFormattingAdapter { const doc = document.document; const ran = Converter.toRange(range); + // tslint:disable-next-line:no-any return Promise.resolve(this.provider.provideDocumentRangeFormattingEdits(doc, ran, options, createToken())).then(value => { if (Array.isArray(value)) { return value.map(Converter.fromTextEdit); diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index cef10419ccf69..fb1b3aa213016 100644 --- a/packages/plugin-ext/src/plugin/tree/tree-views.ts +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { TreeDataProvider, TreeView, TreeViewExpansionEvent } from '@theia/plugin'; import { Emitter } from '@theia/core/lib/common/event'; import { Disposable } from '../types-impl'; diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index 366e879b20ccd..cafd842b31d8e 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -389,6 +389,7 @@ export function toInternalCommand(command: theia.Command): Command { }; } +// tslint:disable-next-line:no-any export function fromWorkspaceEdit(value: theia.WorkspaceEdit, documents?: any): WorkspaceEditDto { const result: WorkspaceEditDto = { edits: [] diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 195a57f786365..431a64c8bd035 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1067,6 +1067,7 @@ export class WorkspaceEdit implements theia.WorkspaceEdit { return this.entries().length; } + // tslint:disable-next-line:no-any toJSON(): any { return this.entries(); } @@ -1204,14 +1205,14 @@ export class DocumentSymbol { } export enum FileChangeType { - Changed = 1, - Created = 2, - Deleted = 3, + Changed = 1, + Created = 2, + Deleted = 3, } export enum FileType { - Unknown = 0, - File = 1, - Directory = 2, - SymbolicLink = 64 + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64 } diff --git a/packages/preview/src/browser/markdown/markdown-preview-handler.spec.ts b/packages/preview/src/browser/markdown/markdown-preview-handler.spec.ts index a36465162d327..d8304e07db904 100644 --- a/packages/preview/src/browser/markdown/markdown-preview-handler.spec.ts +++ b/packages/preview/src/browser/markdown/markdown-preview-handler.spec.ts @@ -31,6 +31,7 @@ let previewHandler: MarkdownPreviewHandler; before(() => { previewHandler = new MarkdownPreviewHandler(); + // tslint:disable-next-line:no-any (previewHandler as any).linkNormalizer = { normalizeLink: (documentUri: URI, link: string) => 'endpoint/' + documentUri.parent.resolve(link).path.toString().substr(1) From 74b3528e47f29e3ae6947cd41a5a07ca76ddba38 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Tue, 13 Nov 2018 20:40:55 -0500 Subject: [PATCH 04/49] Add user feedback to git-diff widgets when no files have been changed I noticed that when we attempt to do diff compare (`git-diff` widget) without any files changed, we silently display the widget without any results. I thought it might be better to instead display a feedback message to end users informing them that no files have been changed. Signed-off-by: Vincent Fugnitto --- packages/git/src/browser/diff/git-diff-widget.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/git/src/browser/diff/git-diff-widget.tsx b/packages/git/src/browser/diff/git-diff-widget.tsx index 054b2e9c9ebf4..ca4d18e821729 100644 --- a/packages/git/src/browser/diff/git-diff-widget.tsx +++ b/packages/git/src/browser/diff/git-diff-widget.tsx @@ -218,6 +218,9 @@ export class GitDiffWidget extends GitNavigableListWidget imp const fileChangeElement: React.ReactNode = this.renderGitItem(fileChange); files.push(fileChangeElement); } + if (!files.length) { + return
No files changed.
; + } return this.listView = ref || undefined} id={this.scrollContainer} From c8813ae369a722cb533428ce6867474c959480e2 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Wed, 14 Nov 2018 20:37:19 -0500 Subject: [PATCH 05/49] Add change count for changes, staged changes, and merged changed in git-view - Added count to `changes`, `staged changes`, `merged changes` in `git-view` - Updated `changes` header label - Improved count styling for better visibility Signed-off-by: Vincent Fugnitto --- packages/git/src/browser/git-widget.tsx | 18 ++++++++++++++---- packages/git/src/browser/style/index.css | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/git/src/browser/git-widget.tsx b/packages/git/src/browser/git-widget.tsx index ae12363702c47..448ed02ebbeb6 100644 --- a/packages/git/src/browser/git-widget.tsx +++ b/packages/git/src/browser/git-widget.tsx @@ -800,7 +800,10 @@ export class GitChangesListContainer extends React.Component 0) { return
-
Merge Changes
+
+ Merge Changes + {this.renderChangeCount(this.props.mergeChanges.length)} +
{this.props.mergeChanges.map(change => this.renderGitItem(change, repository))}
; } else { @@ -813,7 +816,8 @@ export class GitChangesListContainer extends React.Component
Staged Changes -
+ {this.renderChangeCount(this.props.stagedChanges.length)} + {this.props.stagedChanges.map(change => this.renderGitItem(change, repository))} ; } else { @@ -824,11 +828,17 @@ export class GitChangesListContainer extends React.Component 0) { return
-
Changed
+
+ Changes + {this.renderChangeCount(this.props.unstagedChanges.length)} +
{this.props.unstagedChanges.map(change => this.renderGitItem(change, repository))}
; } - return undefined; } + + protected renderChangeCount(changes: number): React.ReactNode { + return {changes}; + } } diff --git a/packages/git/src/browser/style/index.css b/packages/git/src/browser/style/index.css index 9546b833733a9..09e868f7a3cac 100644 --- a/packages/git/src/browser/style/index.css +++ b/packages/git/src/browser/style/index.css @@ -329,3 +329,18 @@ .git-tab-icon::before { content: "\f126" } + +.git-change-count { + align-self: center; + background-color: var(--theia-ui-font-color3); + border-radius: 20px; + color: var(--theia-ui-font-color0); + float: right; + font-size: calc(var(--theia-ui-font-size0) * 0.8); + font-weight: 500; + height: calc(var(--theia-private-horizontal-tab-height) * 0.7); + line-height: calc(var(--theia-private-horizontal-tab-height) * 0.7); + min-width: 6px; + padding: 0 5px; + text-align: center; +} From 7ef5a891f4767379848c54b81be377c471bd7fbe Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Tue, 13 Nov 2018 22:33:03 -0500 Subject: [PATCH 06/49] Add feedback message to outline view when no data is available Added the feedback message `No outline information available.` when a user does not have a file opened or no outline data is available. Addresses an issue where the outline view is empty which may confuse some users. Signed-off-by: Vincent Fugnitto (cherry picked from commit 367b7a89b713c5d21965d60f1b819247e59ad347) --- .../src/browser/outline-view-widget.tsx | 18 ++++++++++++------ .../outline-view/src/browser/styles/index.css | 8 +++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/outline-view/src/browser/outline-view-widget.tsx b/packages/outline-view/src/browser/outline-view-widget.tsx index e5641d76b0665..888770869c23e 100644 --- a/packages/outline-view/src/browser/outline-view-widget.tsx +++ b/packages/outline-view/src/browser/outline-view-widget.tsx @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2017 TypeFox and others. + * Copyright (C) 2017-2018 TypeFox and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -62,7 +62,7 @@ export class OutlineViewWidget extends TreeWidget { this.addClass('theia-outline-view'); } - public setOutlineTree(roots: OutlineSymbolInformationNode[]) { + public setOutlineTree(roots: OutlineSymbolInformationNode[]): void { const nodes = this.reconcileTreeState(roots); this.model.root = { id: 'outline-view-root', @@ -87,12 +87,12 @@ export class OutlineViewWidget extends TreeWidget { return nodes; } - protected onAfterHide(msg: Message) { + protected onAfterHide(msg: Message): void { super.onAfterHide(msg); this.onDidChangeOpenStateEmitter.fire(false); } - protected onAfterShow(msg: Message) { + protected onAfterShow(msg: Message): void { super.onAfterShow(msg); this.onDidChangeOpenStateEmitter.fire(true); } @@ -101,12 +101,18 @@ export class OutlineViewWidget extends TreeWidget { if (OutlineSymbolInformationNode.is(node)) { return
; } - // tslint:disable-next-line:no-null-keyword - return null; + return undefined; } protected isExpandable(node: TreeNode): node is ExpandableTreeNode { return OutlineSymbolInformationNode.is(node) && node.children.length > 0; } + protected renderTree(model: TreeModel): React.ReactNode { + if (CompositeTreeNode.is(this.model.root) && !this.model.root.children.length) { + return
No outline information available.
; + } + return super.renderTree(model); + } + } diff --git a/packages/outline-view/src/browser/styles/index.css b/packages/outline-view/src/browser/styles/index.css index 81c934595ce37..ff35e708224af 100644 --- a/packages/outline-view/src/browser/styles/index.css +++ b/packages/outline-view/src/browser/styles/index.css @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2017 TypeFox and others. + * Copyright (C) 2017-2018 TypeFox and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -17,3 +17,9 @@ .outline-view-tab-icon::before { content: "\f03a" } + +.no-outline { + color: var(--theia-ui-font-color2); + padding: 10px; + text-align: center; +} From 5b05e4282caaeac93cf907a3d85d9a69dcbdd227 Mon Sep 17 00:00:00 2001 From: elaihau Date: Mon, 5 Nov 2018 07:06:15 -0500 Subject: [PATCH 07/49] unit tests for WorkspaceService Signed-off-by: elaihau --- .../core/src/browser/label-provider.spec.ts | 4 +- packages/core/src/browser/label-provider.ts | 7 +- .../src/browser/preferences/test/index.ts | 1 + .../preferences/test/mock-preference-proxy.ts | 47 ++ .../src/browser/workspace-service.spec.ts | 697 ++++++++++++++++++ .../src/browser/workspace-service.ts | 7 +- .../workspace-uri-contribution.spec.ts | 199 +++-- .../src/browser/workspace-uri-contribution.ts | 16 +- .../src/common/test/mock-workspace-service.ts | 31 + 9 files changed, 948 insertions(+), 61 deletions(-) create mode 100644 packages/core/src/browser/preferences/test/mock-preference-proxy.ts create mode 100644 packages/workspace/src/browser/workspace-service.spec.ts create mode 100644 packages/workspace/src/common/test/mock-workspace-service.ts diff --git a/packages/core/src/browser/label-provider.spec.ts b/packages/core/src/browser/label-provider.spec.ts index 0f4b8b744cffd..0abdef2b696af 100644 --- a/packages/core/src/browser/label-provider.spec.ts +++ b/packages/core/src/browser/label-provider.spec.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { expect } from 'chai'; -import { DefaultUriLabelProviderContribution } from './label-provider'; +import { DefaultUriLabelProviderContribution, FOLDER_ICON } from './label-provider'; import URI from '../common/uri'; describe('DefaultUriLabelProviderContribution', function () { @@ -45,6 +45,6 @@ describe('DefaultUriLabelProviderContribution', function () { const prov = new DefaultUriLabelProviderContribution(); const icon = prov.getIcon(new URI('file:///tmp/hello')); - expect(icon).eq('fa fa-folder'); + expect(icon).eq(FOLDER_ICON); }); }); diff --git a/packages/core/src/browser/label-provider.ts b/packages/core/src/browser/label-provider.ts index 93183b324608a..75e3421e46c3c 100644 --- a/packages/core/src/browser/label-provider.ts +++ b/packages/core/src/browser/label-provider.ts @@ -20,6 +20,9 @@ import URI from '../common/uri'; import { ContributionProvider } from '../common/contribution-provider'; import { Prioritizeable, MaybePromise } from '../common/types'; +export const FOLDER_ICON = 'fa fa-folder'; +export const FILE_ICON = 'fa fa-file'; + export const LabelProviderContribution = Symbol('LabelProviderContribution'); export interface LabelProviderContribution { @@ -61,9 +64,9 @@ export class DefaultUriLabelProviderContribution implements LabelProviderContrib const iconClass = this.getFileIcon(uri); if (!iconClass) { if (uri.displayName.indexOf('.') === -1) { - return 'fa fa-folder'; + return FOLDER_ICON; } else { - return 'fa fa-file'; + return FILE_ICON; } } return iconClass; diff --git a/packages/core/src/browser/preferences/test/index.ts b/packages/core/src/browser/preferences/test/index.ts index 5fa5648fa9bb7..d475c62da8335 100644 --- a/packages/core/src/browser/preferences/test/index.ts +++ b/packages/core/src/browser/preferences/test/index.ts @@ -15,3 +15,4 @@ ********************************************************************************/ export * from './mock-preference-service'; +export * from './mock-preference-proxy'; diff --git a/packages/core/src/browser/preferences/test/mock-preference-proxy.ts b/packages/core/src/browser/preferences/test/mock-preference-proxy.ts new file mode 100644 index 0000000000000..d64e26c9f910e --- /dev/null +++ b/packages/core/src/browser/preferences/test/mock-preference-proxy.ts @@ -0,0 +1,47 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { Emitter } from '../../../common'; +import { PreferenceChange } from '../preference-service'; + +// tslint:disable:no-any +export function createMockPreferenceProxy(preferences: { [p: string]: any }) { + const unsupportedOperation = (_: any, __: string) => { + throw new Error('Unsupported operation'); + }; + return new Proxy({}, { + get: (_, property: string) => { + if (property === 'onPreferenceChanged') { + return new Emitter().event; + } + if (property === 'dispose') { + return () => { }; + } + if (property === 'ready') { + return Promise.resolve(); + } + if (preferences[property] !== undefined && preferences[property] !== null) { + return preferences[property]; + } + return undefined; + }, + ownKeys: () => [], + getOwnPropertyDescriptor: (_, property: string) => ({}), + set: unsupportedOperation, + deleteProperty: unsupportedOperation, + defineProperty: unsupportedOperation + }); +} diff --git a/packages/workspace/src/browser/workspace-service.spec.ts b/packages/workspace/src/browser/workspace-service.spec.ts new file mode 100644 index 0000000000000..5ce7e8e01a763 --- /dev/null +++ b/packages/workspace/src/browser/workspace-service.spec.ts @@ -0,0 +1,697 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { Container } from 'inversify'; +import { WorkspaceService } from './workspace-service'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import { FileSystemNode } from '@theia/filesystem/lib/node/node-filesystem'; +import { FileSystemWatcher, FileChangeEvent, FileChangeType } from '@theia/filesystem/lib/browser/filesystem-watcher'; +import { DefaultWindowService, WindowService } from '@theia/core/lib/browser/window/window-service'; +import { WorkspaceServer } from '../common'; +import { DefaultWorkspaceServer } from '../node/default-workspace-server'; +import { Emitter, Disposable, DisposableCollection, ILogger, Logger } from '@theia/core'; +import { WorkspacePreferences } from './workspace-preferences'; +import { createMockPreferenceProxy } from '@theia/core/lib/browser/preferences/test'; +import * as sinon from 'sinon'; +import * as chai from 'chai'; +import URI from '@theia/core/lib/common/uri'; +const expect = chai.expect; + +const folderA = Object.freeze({ + uri: 'file:///home/folderA', + lastModification: 0, + isDirectory: true +}); +const folderB = Object.freeze({ + uri: 'file:///home/folderB', + lastModification: 0, + isDirectory: true +}); + +// tslint:disable:no-any +// tslint:disable:no-unused-expression +describe('WorkspaceService', () => { + const toRestore: Array = []; + const toDispose: Disposable[] = []; + let wsService: WorkspaceService; + let updateTitleStub: sinon.SinonStub; + let reloadWindowStub: sinon.SinonStub; + let onFilesChangedStub: sinon.SinonStub; + + let mockFileChangeEmitter: Emitter; + let mockPreferenceValues: { [p: string]: any }; + let mockFilesystem: FileSystem; + let mockFileSystemWatcher: FileSystemWatcher; + let mockWorkspaceServer: WorkspaceServer; + let mockWindowService: WindowService; + let mockILogger: ILogger; + let mockPref: WorkspacePreferences; + + beforeEach(() => { + mockPreferenceValues = {}; + mockFilesystem = sinon.createStubInstance(FileSystemNode); + mockFileSystemWatcher = sinon.createStubInstance(FileSystemWatcher); + mockWorkspaceServer = sinon.createStubInstance(DefaultWorkspaceServer); + mockWindowService = sinon.createStubInstance(DefaultWindowService); + mockILogger = sinon.createStubInstance(Logger); + mockPref = createMockPreferenceProxy(mockPreferenceValues); + + const testContainer = new Container(); + testContainer.bind(WorkspaceService).toSelf().inSingletonScope(); + testContainer.bind(FileSystem).toConstantValue(mockFilesystem); + testContainer.bind(FileSystemWatcher).toConstantValue(mockFileSystemWatcher); + testContainer.bind(WorkspaceServer).toConstantValue(mockWorkspaceServer); + testContainer.bind(WindowService).toConstantValue(mockWindowService); + testContainer.bind(ILogger).toConstantValue(mockILogger); + testContainer.bind(WorkspacePreferences).toConstantValue(mockPref); + + // stub the updateTitle() & reloadWindow() function because `document` and `window` are unavailable + updateTitleStub = sinon.stub(WorkspaceService.prototype, 'updateTitle').callsFake(() => { }); + reloadWindowStub = sinon.stub(WorkspaceService.prototype, 'reloadWindow').callsFake(() => { }); + mockFileChangeEmitter = new Emitter(); + onFilesChangedStub = sinon.stub(mockFileSystemWatcher, 'onFilesChanged').value(mockFileChangeEmitter.event); + toDispose.push(mockFileChangeEmitter); + toRestore.push(...[updateTitleStub, reloadWindowStub, onFilesChangedStub]); + + wsService = testContainer.get(WorkspaceService); + }); + afterEach(() => { + wsService['toDisposeOnWorkspace'].dispose(); + toRestore.forEach(res => { + res.restore(); + }); + toRestore.length = 0; + toDispose.forEach(dis => dis.dispose()); + toDispose.length = 0; + }); + + describe('constructor and init', () => { + it('should reset the exposed roots and title if the most recently used workspace is unavailable', async () => { + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(undefined); + + await wsService['init'](); + expect(wsService.workspace).to.to.be.undefined; + expect((await wsService.roots).length).to.eq(0); + expect(wsService.tryGetRoots().length).to.eq(0); + expect(updateTitleStub.called).to.be.true; + }); + + it('should reset the exposed roots and title if server returns an invalid or nonexistent file / folder', async () => { + const invalidStat = { + uri: 'file:///home/invalid', + lastModification: 0, + isDirectory: true + }; + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(invalidStat.uri); + (mockFilesystem.getFileStat).resolves(undefined); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + await wsService['init'](); + expect(wsService.workspace).to.to.be.undefined; + expect((await wsService.roots).length).to.eq(0); + expect(wsService.tryGetRoots().length).to.eq(0); + expect(updateTitleStub.called).to.be.true; + }); + + ['file:///home/oneFolder', 'file:///home/oneFolder/'].forEach(uriStr => { + it('should set the exposed roots and workspace to the folder returned by server as the most recently used workspace, and start watching that folder', async () => { + const stat = { + uri: uriStr, + lastModification: 0, + isDirectory: true + }; + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(stat.uri); + (mockFilesystem.getFileStat).resolves(stat); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + await wsService['init'](); + expect(wsService.workspace).to.eq(stat); + expect((await wsService.roots).length).to.eq(1); + expect(wsService.tryGetRoots().length).to.eq(1); + expect(wsService.tryGetRoots()[0]).to.eq(stat); + expect((mockFileSystemWatcher.watchFileChanges).calledWith(new URI(stat.uri))).to.be.true; + }); + }); + + it('should set the exposed roots and workspace to the folders listed in the workspace file returned by the server, ' + + 'and start watching the workspace file and all the folders', async () => { + const workspaceFileUri = 'file:///home/workspaceFile'; + const workspaceFileStat = { + uri: workspaceFileUri, + lastModification: 0, + isDirectory: false + }; + const rootA = 'file:///folderA'; + const rootB = 'file:///folderB'; + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(workspaceFileStat.uri); + const stubGetFileStat = (mockFilesystem.getFileStat); + stubGetFileStat.withArgs(workspaceFileUri).resolves(workspaceFileStat); + (mockFilesystem.exists).resolves(true); + (mockFilesystem.resolveContent).resolves({ + stat: workspaceFileStat, + content: `{"folders":[{"path":"${rootA}"},{"path":"${rootB}"}],"settings":{}}` + }); + stubGetFileStat.withArgs(rootA).resolves({ + uri: rootA, lastModification: 0, isDirectory: true + }); // rootA exists + stubGetFileStat.withArgs(rootB).throws(new Error()); // no access to rootB + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + await wsService['init'](); + expect(wsService.workspace).to.eq(workspaceFileStat); + expect((await wsService.roots).length).to.eq(2); + expect(wsService.tryGetRoots().length).to.eq(2); + expect(wsService.tryGetRoots()[0].uri).to.eq(rootA); + expect(wsService.tryGetRoots()[1].uri).to.eq(rootB); + + expect((>wsService['rootWatchers']).size).to.eq(2); + expect((>wsService['rootWatchers']).has(rootA)).to.be.true; + expect((>wsService['rootWatchers']).has(rootB)).to.be.true; + }); + + it('should set the exposed roots an empty array if the workspace file stores invalid workspace data', async () => { + const workspaceFileUri = 'file:///home/workspaceFile'; + const workspaceFileStat = { + uri: workspaceFileUri, + lastModification: 0, + isDirectory: false + }; + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(workspaceFileStat.uri); + (mockFilesystem.getFileStat).withArgs(workspaceFileUri).resolves(workspaceFileStat); + (mockFilesystem.exists).resolves(true); + (mockFilesystem.resolveContent).resolves({ + stat: workspaceFileStat, + content: 'invalid workspace data' + }); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + await wsService['init'](); + expect(wsService.workspace && wsService.workspace.uri).to.eq(workspaceFileStat.uri); + expect((await wsService.roots).length).to.eq(0); + expect(wsService.tryGetRoots().length).to.eq(0); + expect((mockILogger.error).called).to.be.true; + }); + }); + + describe('onStop() function', () => { + it('should send server an empty string if there is no workspace', () => { + wsService['_workspace'] = undefined; + wsService.onStop(); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith('')).to.be.true; + }); + + it('should send server the uri of current workspace if there is workspace opened', () => { + const uri = 'file:///home/testUri'; + wsService['_workspace'] = { + uri, + lastModification: 0, + isDirectory: false + }; + wsService.onStop(); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(uri)).to.be.true; + }); + }); + + describe('recentWorkspaces() function', () => { + it('should get the recent workspaces from the server', () => { + wsService.recentWorkspaces(); + expect((mockWorkspaceServer.getRecentWorkspaces).called).to.be.true; + }); + }); + + describe('open() function', () => { + it('should call doOpen() with exactly the same arguments', () => { + const uri = new URI('file:///home/testUri'); + toRestore.push(sinon.stub(WorkspaceService.prototype, 'doOpen').callsFake(() => { })); + wsService.open(uri, {}); + expect((wsService['doOpen']).calledWith(uri, {})).to.be.true; + }); + + it('should throw an error if the uri passed in is invalid or nonexistent', done => { + (mockFilesystem.getFileStat).resolves(undefined); + wsService['doOpen'](new URI('file:///home/testUri')) + .then(() => { + done(new Error('WorkspaceService.doOpen() should throw an error but did not')); + }).catch(e => { + done(); + }); + }); + + it('should reload the current window with new uri if preferences["workspace.preserveWindow"] = true and there is an opened current workspace', async () => { + mockPreferenceValues['workspace.preserveWindow'] = true; + const newUriStr = 'file:///home/newWorkspaceUri'; + const newUri = new URI(newUriStr); + const stat = { + uri: newUriStr, + lastModification: 0, + isDirectory: true + }; + (mockFilesystem.getFileStat).resolves(stat); + toRestore.push(sinon.stub(wsService, 'roots').resolves([stat])); + (wsService['_workspace'] as any) = stat; + + await wsService['doOpen'](newUri, {}); + expect(reloadWindowStub.called).to.be.true; + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newUriStr)).to.be.true; + expect(wsService.workspace).to.eq(stat); + }); + + it('should keep the old Theia window & open a new window if preferences["workspace.preserveWindow"] = false and there is an opened current workspace', async () => { + mockPreferenceValues['workspace.preserveWindow'] = false; + const oldWorkspaceUriStr = 'file:///home/oldWorkspaceUri'; + const oldStat = { + uri: oldWorkspaceUriStr, + lastModification: 0, + isDirectory: true + }; + toRestore.push(sinon.stub(wsService, 'roots').resolves([oldStat])); + (wsService['_workspace'] as any) = oldStat; + const newWorkspaceUriStr = 'file:///home/newWorkspaceUri'; + const uri = new URI(newWorkspaceUriStr); + const newStat = { + uri: newWorkspaceUriStr, + lastModification: 0, + isDirectory: true + }; + (mockFilesystem.getFileStat).resolves(newStat); + const stubOpenNewWindow = sinon.stub(wsService, 'openNewWindow').callsFake(() => { }); + toRestore.push(stubOpenNewWindow); + + await wsService['doOpen'](uri, {}); + expect(reloadWindowStub.called).to.be.false; + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newWorkspaceUriStr)).to.be.true; + expect(stubOpenNewWindow.called).to.be.true; + expect(wsService.workspace).to.eq(oldStat); + }); + + it('should reload the current window with new uri if preferences["workspace.preserveWindow"] = false and browser blocks the new window being opened', async () => { + mockPreferenceValues['workspace.preserveWindow'] = false; + const oldWorkspaceUriStr = 'file:///home/oldWorkspaceUri'; + const oldStat = { + uri: oldWorkspaceUriStr, + lastModification: 0, + isDirectory: true + }; + toRestore.push(sinon.stub(wsService, 'roots').resolves([oldStat])); + (wsService['_workspace'] as any) = oldStat; + const newWorkspaceUriStr = 'file:///home/newWorkspaceUri'; + const uri = new URI(newWorkspaceUriStr); + const newStat = { + uri: newWorkspaceUriStr, + lastModification: 0, + isDirectory: true + }; + (mockFilesystem.getFileStat).resolves(newStat); + (mockILogger.error).resolves(undefined); + const stubOpenNewWindow = sinon.stub(wsService, 'openNewWindow').throws(); + toRestore.push(stubOpenNewWindow); + + await wsService['doOpen'](uri, {}); + expect(reloadWindowStub.called).to.be.true; + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newWorkspaceUriStr)).to.be.true; + expect(stubOpenNewWindow.called).to.be.true; + expect(wsService.workspace).to.eq(newStat); + }); + }); + + describe('close() function', () => { + it('should reset the exposed roots and workspace, and set the most recently used workspace empty through the server', async () => { + const stat = { + uri: 'file:///home/folder', + lastModification: 0, + isDirectory: true + }; + wsService['_workspace'] = stat; + wsService['_roots'] = [stat]; + + await wsService.close(); + expect(wsService.workspace).to.be.undefined; + expect((await wsService.roots).length).to.eq(0); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith('')).to.be.true; + }); + }); + + describe('addRoot() function', () => { + it('should throw an error if there is no opened workspace', done => { + wsService['_workspace'] = undefined; + + wsService.addRoot(new URI()) + .then(() => { + done(new Error('WorkspaceService.addRoot() should throw an error but did not.')); + }).catch(e => { + done(); + }); + }); + + it('should throw an error if the added uri is invalid or nonexistent', done => { + (mockFilesystem.getFileStat).resolves(undefined); + toRestore.push(sinon.stub(wsService, 'opened').value(true)); + wsService.addRoot(new URI()) + .then(() => { + done(new Error('WorkspaceService.addRoot() should throw an error but did not.')); + }).catch(e => { + done(); + }); + }); + + it('should throw an error if the added uri points to a file instead of a folder', done => { + (mockFilesystem.getFileStat).resolves({ + uri: 'file:///home/file', + lastModification: 0, + isDirectory: false + }); + toRestore.push(sinon.stub(wsService, 'opened').value(true)); + wsService.addRoot(new URI()) + .then(() => { + done(new Error('WorkspaceService.addRoot() should throw an error but did not.')); + }).catch(e => { + done(); + }); + }); + + it('should do nothing if the added uri is already part of the current workspace', async () => { + const stat = { + uri: 'file:///home/folder', + lastModification: 0, + isDirectory: true + }; + wsService['_workspace'] = stat; + wsService['_roots'] = [stat]; + (mockFilesystem.getFileStat).resolves(stat); + + await wsService.addRoot(new URI(stat.uri)); + expect(wsService.workspace && wsService.workspace.uri).to.eq(stat.uri); + expect(wsService.tryGetRoots().length).to.eq(1); + }); + + it('should write new data into the workspace file when the workspace data is stored in a file', async () => { + const workspaceFileStat = { + uri: 'file:///home/file', + lastModification: 0, + isDirectory: false + }; + wsService['_workspace'] = workspaceFileStat; + wsService['_roots'] = [folderA]; + (mockFilesystem.getFileStat).resolves(folderB); + + await wsService.addRoot(new URI(folderB.uri)); + expect((mockFilesystem.setContent).calledWith(workspaceFileStat, + JSON.stringify({ + folders: [ + { path: 'folderA' }, { path: 'folderB' } + ] + }))).to.be.true; + }); + + [true, false].forEach(existTemporaryWorkspaceFile => { + it('should write workspace data into a temporary file when theia currently uses a folder as the workspace ' + + `and the temporary file ${existTemporaryWorkspaceFile ? 'exists' : 'does not exist'}`, async () => { + const stubSave = sinon.stub(wsService, 'save').callsFake(() => { }); + const stubWriteWorkspaceFile = sinon.stub(wsService, 'writeWorkspaceFile').callsFake(() => { }); + toRestore.push(...[stubSave, stubWriteWorkspaceFile]); + wsService['_workspace'] = folderA; + wsService['_roots'] = [folderA]; + const homeStat = { + uri: 'file:///home/user', + lastModification: 0, + isDirectory: true + }; + const untitledStat = { + uri: 'file:///home/user/.theia/Untitled.theia-workspace', + lastModification: 0, + isDirectory: true + }; + (mockFilesystem.getCurrentUserHome).resolves(homeStat); + const stubGetFileStat = mockFilesystem.getFileStat; + stubGetFileStat.onCall(0).resolves(folderB); + (mockFilesystem.exists).resolves(existTemporaryWorkspaceFile); + const stubCreateFile = mockFilesystem.createFile; + stubCreateFile.resolves(untitledStat); + if (existTemporaryWorkspaceFile) { + stubGetFileStat.onCall(1).resolves(untitledStat); + } + wsService['_workspace'] = folderA; + wsService['_roots'] = [folderA]; + + await wsService.addRoot(new URI(folderB.uri)); + expect(stubCreateFile.calledWith(untitledStat.uri)).to.eq(!existTemporaryWorkspaceFile); + expect(stubSave.calledWith(untitledStat)).to.be.true; + expect(stubWriteWorkspaceFile.called).to.be.true; + }); + }); + }); + + describe('save() function', () => { + it('should leave the current workspace unchanged if the passed in uri points to the current workspace', async () => { + const file = { + uri: 'file:///home/file', + lastModification: 0, + isDirectory: false + }; + (mockFilesystem.exists).resolves(true); + (mockFilesystem.getFileStat).resolves(file); + wsService['_workspace'] = file; + const stubSetContent = (mockFilesystem.setContent).resolves(file); + + expect(wsService.workspace && wsService.workspace.uri).to.eq(file.uri); + await wsService.save(new URI(file.uri)); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(file.uri)).to.be.true; + expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [] }))).to.be.true; + expect(wsService.workspace && wsService.workspace.uri).to.eq(file.uri); + }); + + it('should create a new workspace file, save the workspace data into that new file, and update the title of theia', async () => { + const oldFile = { + uri: 'file:///home/oldfile', + lastModification: 0, + isDirectory: false + }; + const newFile = { + uri: 'file:///home/newfile', + lastModification: 0, + isDirectory: false + }; + const stubExist = mockFilesystem.exists; + stubExist.withArgs(oldFile.uri).resolves(true); + stubExist.withArgs(newFile.uri).resolves(false); + (mockFilesystem.getFileStat).resolves(newFile); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + wsService['_workspace'] = oldFile; + const stubSetContent = (mockFilesystem.setContent).resolves(newFile); + + expect(wsService.workspace && wsService.workspace.uri).to.eq(oldFile.uri); + await wsService.save(new URI(newFile.uri)); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newFile.uri)).to.be.true; + expect(stubSetContent.calledWith(newFile, JSON.stringify({ folders: [] }))).to.be.true; + expect(wsService.workspace && wsService.workspace.uri).to.eq(newFile.uri); + expect(updateTitleStub.called).to.be.true; + }); + + it('should use relative paths or translate relative paths to absolute path when necessary before saving', async () => { + const oldFile = { + uri: 'file:///home/oldFolder/oldFile', + lastModification: 0, + isDirectory: false + }; + const newFile = { + uri: 'file:///home/newFolder/newFile', + lastModification: 0, + isDirectory: false + }; + const folder1 = { + uri: 'file:///home/thirdFolder/folder1', + lastModification: 0, + isDirectory: true + }; + const folder2 = { + uri: 'file:///home/newFolder/folder2', + lastModification: 0, + isDirectory: true + }; + const stubExist = mockFilesystem.exists; + stubExist.withArgs(oldFile.uri).resolves(true); + stubExist.withArgs(newFile.uri).resolves(false); + (mockFilesystem.getFileStat).resolves(newFile); + wsService['_workspace'] = oldFile; + wsService['_roots'] = [folder1, folder2]; + const stubSetContent = (mockFilesystem.setContent).resolves(newFile); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + expect(wsService.workspace && wsService.workspace.uri).to.eq(oldFile.uri); + await wsService.save(new URI(newFile.uri)); + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newFile.uri)).to.be.true; + expect(stubSetContent.calledWith(newFile, JSON.stringify({ folders: [{ path: folder1.uri }, { path: 'folder2' }] }))).to.be.true; + expect(wsService.workspace && wsService.workspace.uri).to.eq(newFile.uri); + expect(updateTitleStub.called).to.be.true; + }); + }); + + describe('saved status', () => { + it('should be true if there is an opened workspace, and the opened workspace is not a folder, othewise false', () => { + const file = { + uri: 'file:///home/file', + lastModification: 0, + isDirectory: false + }; + + expect(wsService.saved).to.be.false; + wsService['_workspace'] = file; + expect(wsService.saved).to.be.true; + wsService['_workspace'] = folderA; + expect(wsService.saved).to.be.false; + }); + }); + + describe('containsSome() function', () => { + it('should resolve false if the current workspace is not open', async () => { + sinon.stub(wsService, 'roots').resolves([]); + sinon.stub(wsService, 'opened').value(false); + wsService['_roots'] = []; + + expect(await wsService.containsSome([])).to.be.false; + }); + + it('should resolve false if the passed in paths is an empty array', async () => { + sinon.stub(wsService, 'roots').resolves([]); + sinon.stub(wsService, 'opened').value(true); + wsService['_roots'] = [folderA, folderB]; + + expect(await wsService.containsSome([])).to.be.false; + }); + + it('should resolve false if on or more passed in paths are found in the workspace, otherwise false', async () => { + sinon.stub(wsService, 'roots').value([folderA, folderB]); + sinon.stub(wsService, 'opened').value(true); + wsService['_roots'] = [folderA, folderB]; + + (mockFilesystem.exists).withArgs('file:///home/folderB/subfolder').resolves(true); + const val = await wsService.containsSome(['A', 'subfolder', 'C']); + expect(val).to.be.true; + expect(await wsService.containsSome(['C', 'A', 'B'])).to.be.false; + }); + }); + + describe('removeRoots() function', () => { + it('should throw an error if the current workspace is not open', done => { + sinon.stub(wsService, 'opened').value(false); + + wsService.removeRoots([]).then(() => { + done(new Error('WorkspaceService.removeRoots() should throw an error while did not.')); + }).catch(e => { + done(); + }); + }); + + it('should not update the workspace file if the workspace is undefined', async () => { + wsService['_workspace'] = undefined; + sinon.stub(wsService, 'opened').value(true); + const stubWriteWorkspaceFile = sinon.stub(wsService, 'writeWorkspaceFile'); + + await wsService.removeRoots([]); + expect(stubWriteWorkspaceFile.called).to.be.false; + }); + + it('should update the working space file with remaining folders', async () => { + const file = { + uri: 'file:///home/oneFile', + lastModification: 0, + isDirectory: false + }; + wsService['_workspace'] = file; + sinon.stub(wsService, 'opened').value(true); + wsService['_roots'] = [folderA, folderB]; + const stubSetContent = mockFilesystem.setContent; + stubSetContent.resolves(file); + + await wsService.removeRoots([new URI()]); + expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [{ path: 'folderA' }, { path: 'folderB' }] }))).to.be.true; + + await wsService.removeRoots([new URI(folderB.uri)]); + expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [{ path: 'folderA' }] }))).to.be.true; + }); + }); + + it('should emit roots in the current workspace when initialized', done => { + const rootA = 'file:///folderA'; + const rootB = 'file:///folderB'; + const statA = { + uri: rootA, + lastModification: 0, + isDirectory: true + }; + const statB = { + uri: rootB, + lastModification: 0, + isDirectory: true + }; + const dis = wsService.onWorkspaceChanged(roots => { + expect(roots.length).to.eq(2); + expect(roots[0].uri).to.eq(rootA); + expect(roots[1].uri).to.eq(rootB); + dis.dispose(); + done(); + }); + toDispose.push(dis); + wsService['onWorkspaceChangeEmitter'].fire([statA, statB]); + }).timeout(2000); + + it('should emit updated roots when workspace file is changed', done => { + const workspaceFileUri = 'file:///home/workspaceFile'; + const workspaceFileStat = { + uri: workspaceFileUri, + lastModification: 0, + isDirectory: false + }; + wsService['_workspace'] = workspaceFileStat; + const folderC = { + uri: 'file:///home/folderC', + lastModification: 0, + isDirectory: true + }; + + (mockWorkspaceServer.getMostRecentlyUsedWorkspace).resolves(workspaceFileUri); + const stubGetFileStat = (mockFilesystem.getFileStat); + stubGetFileStat.withArgs(workspaceFileUri).resolves(workspaceFileStat); + (mockFilesystem.exists).resolves(true); + const oldWorkspaceFileContent = { + stat: workspaceFileStat, + content: '{"folders":[{"path":"folderA"},{"path":"folderB"}],"settings":{}}' + }; + const newWorkspaceFileContent = { + stat: workspaceFileStat, + content: '{"folders":[{"path":"folderB"},{"path":"folderC"}],"settings":{}}' + }; + (mockFilesystem.resolveContent).onCall(0).resolves(oldWorkspaceFileContent); + (mockFilesystem.resolveContent).onCall(1).resolves(newWorkspaceFileContent); + (mockFilesystem.resolveContent).onCall(2).resolves(newWorkspaceFileContent); + stubGetFileStat.withArgs(folderA.uri).resolves(folderA); + stubGetFileStat.withArgs(folderB.uri).resolves(folderB); + stubGetFileStat.withArgs(folderC.uri).resolves(folderC); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + wsService['init']().then(() => { + const dis = wsService.onWorkspaceChanged(roots => { + expect(roots.length).to.eq(2); + expect(roots[0].uri).to.eq(folderB.uri); + expect(roots[1].uri).to.eq(folderC.uri); + dis.dispose(); + done(); + }); + toDispose.push(dis); + mockFileChangeEmitter.fire([{ uri: new URI(workspaceFileUri), type: FileChangeType.UPDATED }]); + }); + }).timeout(2000); +}); diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index ee7777f5557f0..e7f10d4590e29 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -20,7 +20,7 @@ import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; import { WorkspaceServer } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ILogger, Disposable, DisposableCollection, Emitter, Event } from '@theia/core'; import { WorkspacePreferences } from './workspace-preferences'; @@ -185,9 +185,8 @@ export class WorkspaceService implements FrontendApplicationContribution { /** * on unload, we set our workspace root as the last recently used on the backend. - * @param app */ - onStop(app: FrontendApplication): void { + onStop(): void { this.server.setMostRecentlyUsedWorkspace(this._workspace ? this._workspace.uri : ''); } @@ -407,7 +406,7 @@ export class WorkspaceService implements FrontendApplicationContribution { await this.fileSystem.createFile(uriStr); } let stat = await this.toFileStat(uriStr); - stat = await this.writeWorkspaceFile(stat, await this.roots); + stat = await this.writeWorkspaceFile(stat, this._roots); await this.server.setMostRecentlyUsedWorkspace(uriStr); await this.setWorkspace(stat); } diff --git a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts index 55bea223ea477..dc1d9873e27c1 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.spec.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.spec.ts @@ -14,58 +14,175 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Container } from 'inversify'; +import { FileStat, FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { MockFilesystem } from '@theia/filesystem/lib/common/test'; +import { FOLDER_ICON, FILE_ICON, DefaultUriLabelProviderContribution } from '@theia/core/lib/browser/label-provider'; +import { IWorkspaceService } from './workspace-service'; import { WorkspaceUriLabelProviderContribution } from './workspace-uri-contribution'; -import { Container, ContainerModule, injectable } from 'inversify'; +import { MockWorkspaceService } from '../common/test/mock-workspace-service'; import URI from '@theia/core/lib/common/uri'; -import { IWorkspaceService } from './workspace-service'; -import { FileStat, FileSystem } from '@theia/filesystem/lib/common/filesystem'; import { expect } from 'chai'; -import { MockFilesystem } from '@theia/filesystem/lib/common/test'; +import * as sinon from 'sinon'; +let container: Container; let labelProvider: WorkspaceUriLabelProviderContribution; - -@injectable() -class MockWorkspaceService implements IWorkspaceService { - get roots(): Promise { - const stat: FileStat = { - uri: 'file:///workspace', - lastModification: 0, - isDirectory: true, - }; - return Promise.resolve([stat]); - } -} - -beforeEach(function() { - - const module = new ContainerModule(bind => { - bind(WorkspaceUriLabelProviderContribution).toSelf().inSingletonScope(); - bind(IWorkspaceService).to(MockWorkspaceService).inSingletonScope(); - bind(FileSystem).to(MockFilesystem); - }); - const container = new Container(); - container.load(module); +before(() => { + container = new Container(); + container.bind(WorkspaceUriLabelProviderContribution).toSelf().inSingletonScope(); + container.bind(IWorkspaceService).to(MockWorkspaceService).inSingletonScope(); + container.bind(FileSystem).to(MockFilesystem).inSingletonScope(); labelProvider = container.get(WorkspaceUriLabelProviderContribution); }); -describe('getLongName', function() { - it('should trim workspace for a file in workspace', function() { - const file = new URI('file:///workspace/some/very-long/path.js'); - const longName = labelProvider.getLongName(file); - expect(longName).eq('some/very-long/path.js'); +describe('WorkspaceUriLabelProviderContribution class', () => { + const stubs: sinon.SinonStub[] = []; + + afterEach(() => { + stubs.forEach(s => s.restore()); + stubs.length = 0; }); - it('should not trim workspace for a file not in workspace', function() { - const file = new URI('file:///tmp/prout.txt'); - const longName = labelProvider.getLongName(file); - expect(longName).eq('/tmp/prout.txt'); + describe('canHandle()', () => { + it('should return 0 if the passed in argument is not a FileStat or URI with the "file" scheme', () => { + expect(labelProvider.canHandle(new URI('user_storage:settings.json'))).eq(0); + expect(labelProvider.canHandle({ uri: 'file:///home/settings.json' })).eq(0); + }); + + it('should return 10 if the passed in argument is a FileStat or URI with the "file" scheme', () => { + expect(labelProvider.canHandle(new URI('file:///home/settings.json'))).eq(10); + expect(labelProvider.canHandle({ + uri: 'file:///home/settings.json', + lastModification: 0, + isDirectory: false + })).eq(10); + }); }); - it('should not trim workspace for a file not in workspace 2', function() { - // Test with a path that is textually a prefix of the workspace, - // but is not really a child in the filesystem. - const file = new URI('file:///workspace-2/jacques.doc'); - const longName = labelProvider.getLongName(file); - expect(longName).eq('/workspace-2/jacques.doc'); + describe('getIcon()', () => { + let fs: MockFilesystem; + + beforeEach(() => { + fs = container.get(FileSystem); + }); + + it('should return FOLDER_ICON from the FileStat of a folder', async () => { + expect(await labelProvider.getIcon({ + uri: 'file:///home/', + lastModification: 0, + isDirectory: true + })).eq(FOLDER_ICON); + }); + + it('should return FILE_ICON from a non-folder FileStat', async () => { + const stat = { + uri: 'file:///home/test', + lastModification: 0, + isDirectory: false + }; + stubs.push(sinon.stub(fs, 'getFileStat').resolves(stat)); + // tslint:disable-next-line:no-any + stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); + expect(await labelProvider.getIcon(stat)).eq(FILE_ICON); + }); + + it('should return FOLDER_ICON from a folder URI', async () => { + stubs.push(sinon.stub(fs, 'getFileStat').resolves({ + uri: 'file:///home/test', + lastModification: 0, + isDirectory: true + })); + // tslint:disable-next-line:no-any + stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); + expect(await labelProvider.getIcon(new URI('file:///home/test'))).eq(FOLDER_ICON); + }); + + it('should return FILE_ICON from a file URI', async () => { + stubs.push(sinon.stub(fs, 'getFileStat').resolves({ + uri: 'file:///home/test', + lastModification: 0, + isDirectory: false + })); + // tslint:disable-next-line:no-any + stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(undefined)); + expect(await labelProvider.getIcon(new URI('file:///home/test'))).eq(FILE_ICON); + }); + + it('should return FILE_ICON from a URI when FileSystem.getFileStat() throws', async () => { + stubs.push(sinon.stub(fs, 'getFileStat').throws(new Error())); + expect(await labelProvider.getIcon(new URI('file:///home/test'))).eq(FILE_ICON); + }); + + it('should return what getFileIcon() returns from a URI or non-folder FileStat, if getFileIcon() does not return null or undefined', async () => { + const ret = 'TestString'; + // tslint:disable-next-line:no-any + stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, 'getFileIcon').returns(ret)); + expect(await labelProvider.getIcon(new URI('file:///home/test'))).eq(ret); + expect(await labelProvider.getIcon({ + uri: 'file:///home/test', + lastModification: 0, + isDirectory: false + })).eq(ret); + }); }); + + describe('getName()', () => { + it('should return the display name of a file from its URI', () => { + const file = new URI('file:///workspace-2/jacques.doc'); + const name = labelProvider.getName(file); + expect(name).eq('jacques.doc'); + }); + + it('should return the display name of a file from its FileStat', () => { + const file: FileStat = { + uri: 'file:///workspace-2/jacques.doc', + lastModification: 0, + isDirectory: false + }; + const name = labelProvider.getName(file); + expect(name).eq('jacques.doc'); + }); + }); + + describe('getLongName()', () => { + it('should return the path of a file relative to the workspace from the file\'s URI if the file is in the workspace', () => { + const file = new URI('file:///workspace/some/very-long/path.js'); + const longName = labelProvider.getLongName(file); + expect(longName).eq('some/very-long/path.js'); + }); + + it('should return the path of a file relative to the workspace from the file\'s FileStat if the file is in the workspace', () => { + const file: FileStat = { + uri: 'file:///workspace/some/very-long/path.js', + lastModification: 0, + isDirectory: false + }; + const longName = labelProvider.getLongName(file); + expect(longName).eq('some/very-long/path.js'); + }); + + it('should return the absolute path of a file from the file\'s URI if the file is not in the workspace', () => { + const file = new URI('file:///tmp/prout.txt'); + const longName = labelProvider.getLongName(file); + expect(longName).eq('/tmp/prout.txt'); + }); + + it('should return the absolute path of a file from the file\'s FileStat if the file is not in the workspace', () => { + const file: FileStat = { + uri: 'file:///tmp/prout.txt', + lastModification: 0, + isDirectory: false + }; + const longName = labelProvider.getLongName(file); + expect(longName).eq('/tmp/prout.txt'); + }); + + it('should return the path of a file if WorkspaceService returns no roots', () => { + stubs.push(sinon.stub(labelProvider, 'wsRoot').value(undefined)); + const file = new URI('file:///tmp/prout.txt'); + const longName = labelProvider.getLongName(file); + expect(longName).eq('/tmp/prout.txt'); + }); + }); + }); diff --git a/packages/workspace/src/browser/workspace-uri-contribution.ts b/packages/workspace/src/browser/workspace-uri-contribution.ts index 32730c59a7014..ee371c83d5255 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { DefaultUriLabelProviderContribution } from '@theia/core/lib/browser/label-provider'; +import { DefaultUriLabelProviderContribution, FOLDER_ICON, FILE_ICON } from '@theia/core/lib/browser/label-provider'; import URI from '@theia/core/lib/common/uri'; import { injectable, inject, postConstruct } from 'inversify'; import { IWorkspaceService } from './workspace-service'; @@ -62,24 +62,16 @@ export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProvid async getIcon(element: URI | FileStat): Promise { if (FileStat.is(element) && element.isDirectory) { - return 'fa fa-folder'; + return FOLDER_ICON; } const uri = this.getUri(element); const icon = super.getFileIcon(uri); if (!icon) { try { const stat = await this.getStat(element); - if (stat) { - if (stat.isDirectory) { - return 'fa fa-folder'; - } else { - return 'fa fa-file'; - } - } else { - return 'fa fa-file'; - } + return stat && stat.isDirectory ? FOLDER_ICON : FILE_ICON; } catch (err) { - return 'fa fa-file'; + return FILE_ICON; } } return icon; diff --git a/packages/workspace/src/common/test/mock-workspace-service.ts b/packages/workspace/src/common/test/mock-workspace-service.ts new file mode 100644 index 0000000000000..bf14e77dee773 --- /dev/null +++ b/packages/workspace/src/common/test/mock-workspace-service.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { injectable } from 'inversify'; +import { IWorkspaceService } from '../../browser/workspace-service'; +import { FileStat } from '@theia/filesystem/lib/common/filesystem'; + +@injectable() +export class MockWorkspaceService implements IWorkspaceService { + get roots(): Promise { + const stat: FileStat = { + uri: 'file:///workspace', + lastModification: 0, + isDirectory: true + }; + return Promise.resolve([stat]); + } +} From 37c340e19a73158d9598926b372e67838514a90e Mon Sep 17 00:00:00 2001 From: Simon Marchi Date: Wed, 5 Sep 2018 07:49:47 -0400 Subject: [PATCH 08/49] Use ripgrep's --json output This patch replaces the current parsing of ripgrep's output, which uses ANSI color control characters to identify fields, with the new JSON output. Change-Id: Ia497ada84522d1c12e6fde746c9700875a283cff Signed-off-by: Simon Marchi --- packages/file-search/package.json | 2 +- packages/search-in-workspace/package.json | 2 +- .../ripgrep-search-in-workspace-server.ts | 204 ++++++------------ yarn.lock | 7 +- 4 files changed, 76 insertions(+), 139 deletions(-) diff --git a/packages/file-search/package.json b/packages/file-search/package.json index 7778f887274fd..4d97fa37384c6 100644 --- a/packages/file-search/package.json +++ b/packages/file-search/package.json @@ -9,7 +9,7 @@ "@theia/process": "^0.3.16", "@theia/workspace": "^0.3.16", "fuzzy": "^0.1.3", - "vscode-ripgrep": "^1.0.1" + "vscode-ripgrep": "^1.2.4" }, "publishConfig": { "access": "public" diff --git a/packages/search-in-workspace/package.json b/packages/search-in-workspace/package.json index 2f8700a8b25a7..634a16fb3040d 100644 --- a/packages/search-in-workspace/package.json +++ b/packages/search-in-workspace/package.json @@ -9,7 +9,7 @@ "@theia/navigator": "^0.3.16", "@theia/process": "^0.3.16", "@theia/workspace": "^0.3.16", - "vscode-ripgrep": "^1.0.1" + "vscode-ripgrep": "^1.2.4" }, "publishConfig": { "access": "public" diff --git a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts index 3be962924bdc7..903748212358f 100644 --- a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts +++ b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts @@ -21,6 +21,41 @@ import { RawProcess, RawProcessFactory, RawProcessOptions } from '@theia/process import { rgPath } from 'vscode-ripgrep'; import { FileUri } from '@theia/core/lib/node/file-uri'; +/** + * Typing for ripgrep's arbitrary data object: + * + * https://docs.rs/grep-printer/0.1.0/grep_printer/struct.JSON.html#object-arbitrary-data + */ +interface RipGrepArbitraryData { + text?: string; + bytes?: string; +} + +/** + * Convert the length of a range in `text` expressed in bytes to a number of + * characters (or more precisely, code points). The range starts at character + * `charStart` in `text`. + */ +function byteRangeLengthToCharacterLength(text: string, charStart: number, byteLength: number): number { + let char: number = charStart; + for (let byteIdx = 0; byteIdx < byteLength; char++) { + const codePoint: number = text.charCodeAt(char); + if (codePoint < 0x7F) { + byteIdx++; + } else if (codePoint < 0x7FF) { + byteIdx += 2; + } else if (codePoint < 0xFFFF) { + byteIdx += 3; + } else if (codePoint < 0x10FFFF) { + byteIdx += 4; + } else { + throw new Error('Invalid UTF-8 string'); + } + } + + return char - charStart; +} + @injectable() export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { @@ -32,19 +67,6 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { private client: SearchInWorkspaceClient | undefined; - // Highlighted red - private readonly FILENAME_START = '^\x1b\\[0?m\x1b\\[31m'; - private readonly FILENAME_END = '\x1b\\[0?m:'; - // Highlighted green - private readonly LINE_START = '^\x1b\\[0?m\x1b\\[32m'; - private readonly LINE_END = '\x1b\\[0?m:'; - // Highlighted yellow - private readonly CHARACTER_START = '^\x1b\\[0?m\x1b\\[33m'; - private readonly CHARACTER_END = '\x1b\\[0?m:'; - // Highlighted blue - private readonly MATCH_START = '\x1b\\[0?m\x1b\\[34m'; - private readonly MATCH_END = '\x1b\\[0?m'; - constructor( @inject(ILogger) protected readonly logger: ILogger, @inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory, @@ -55,18 +77,7 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { } protected getArgs(options?: SearchInWorkspaceOptions): string[] { - const args = ['--vimgrep', '--color=always', - '--colors=path:none', - '--colors=line:none', - '--colors=column:none', - '--colors=match:none', - '--colors=path:fg:red', - '--colors=line:fg:green', - '--colors=column:fg:yellow', - '--colors=match:fg:blue', - '--sort-files', - '--max-count=100', - '--max-columns=250']; + const args = ['--json', '--max-count=100']; args.push(options && options.matchCase ? '--case-sensitive' : '--ignore-case'); if (options && options.matchWholeWord) { args.push('--word-regexp'); @@ -128,12 +139,6 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { // Buffer to accumulate incoming output. let databuf: string = ''; - const lastMatch = { - file: '', - line: 0, - index: 0, - }; - process.output.on('data', (chunk: string) => { // We might have already reached the max number of // results, sent a TERM signal to rg, but we still get @@ -155,118 +160,49 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { } // Get and remove the line from the data buffer. - let lineBuf = databuf.slice(0, eolIdx); + const lineBuf = databuf.slice(0, eolIdx); databuf = databuf.slice(eolIdx + 1); // Extract the various fields using the ANSI // control characters for colors as guides. + const obj = JSON.parse(lineBuf); - // Extract filename (magenta). - const filenameRE = new RegExp(`${this.FILENAME_START}(.+?)${this.FILENAME_END}`); - let match = filenameRE.exec(lineBuf); - if (!match) { - continue; - } + if (obj['type'] === 'match') { + const data = obj['data']; + const file = (data['path']).text; + const line = data['line_number']; + const lineText = (data['lines']).text; - const filename = match[1]; - lineBuf = lineBuf.slice(match[0].length); - - // Extract line number (green). - const lineRE = new RegExp(`${this.LINE_START}(\\d+)${this.LINE_END}`); - match = lineRE.exec(lineBuf); - if (!match) { - continue; - } - - const line = +match[1]; - lineBuf = lineBuf.slice(match[0].length); - - // Extract character number (column), but don't - // do anything with it. ripgrep reports the - // offset in bytes, which is not good when - // dealing with multi-byte UTF-8 characters. - const characterNumRE = new RegExp(`${this.CHARACTER_START}(\\d+)${this.CHARACTER_END}`); - match = characterNumRE.exec(lineBuf); - if (!match) { - continue; - } - - lineBuf = lineBuf.slice(match[0].length); - - // If there are two matches in a line, - // --vimgrep will make rg output two lines, but - // both matches will be highlighted in both - // lines. If we have consecutive matches at - // the same file / line, make sure to pick the - // right highlighted match. - if (lastMatch.file === filename && lastMatch.line === line) { - lastMatch.index++; - } else { - lastMatch.file = filename; - lastMatch.line = line; - lastMatch.index = 0; - } - - // Extract the match text (red). - const matchRE = new RegExp(`${this.MATCH_START}(.*?)${this.MATCH_END}`); - - let characterNum = 0; - - let matchWeAreLookingFor: RegExpMatchArray | undefined = undefined; - for (let i = 0; ; i++) { - const nextMatch = lineBuf.match(matchRE); - - if (!nextMatch) { - break; + if (file === undefined || lineText === undefined) { + continue; } - // Just to make typescript happy. - if (nextMatch.index === undefined) { - break; + for (const submatch of data['submatches']) { + const startByte = submatch['start']; + const endByte = submatch['end']; + const character = byteRangeLengthToCharacterLength(lineText, 0, startByte); + const length = byteRangeLengthToCharacterLength(lineText, character, endByte - startByte); + + const result: SearchInWorkspaceResult = { + file, + line, + character: character + 1, + length, + lineText: lineText.replace(/[\r\n]+$/, ''), + }; + + numResults++; + if (this.client) { + this.client.onResult(searchId, result); + } + + // Did we reach the maximum number of results? + if (opts && opts.maxResults && numResults >= opts.maxResults) { + process.kill(); + this.wrapUpSearch(searchId); + break; + } } - - if (i === lastMatch.index) { - matchWeAreLookingFor = nextMatch; - characterNum = nextMatch.index + 1; - } - - // Remove the control characters around the match. This allows to: - - // - prepare the line text so it can be returned to the client without control characters - // - get the character index of subsequent matches right - - lineBuf = - lineBuf.slice(0, nextMatch.index) - + nextMatch[1] - + lineBuf.slice(nextMatch.index + nextMatch[0].length); - } - - if (!matchWeAreLookingFor || characterNum === 0) { - continue; - } - - if (matchWeAreLookingFor[1].length === 0) { - continue; - } - - const result: SearchInWorkspaceResult = { - file: filename, - line: line, - character: characterNum, - length: matchWeAreLookingFor[1].length, - lineText: lineBuf, - }; - - numResults++; - if (this.client) { - this.client.onResult(searchId, result); - } - - // Did we reach the maximum number of results? - if (opts && opts.maxResults && numResults >= opts.maxResults) { - process.kill(); - this.wrapUpSearch(searchId); - break; } } }); diff --git a/yarn.lock b/yarn.lock index 39ba42b850c1c..11351d10ca582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9974,9 +9974,10 @@ vscode-nsfw@^1.0.17: nan "^2.0.0" promisify-node "^0.3.0" -vscode-ripgrep@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vscode-ripgrep/-/vscode-ripgrep-1.1.0.tgz#93c1e39d88342ee1b15530a12898ce930d511948" +vscode-ripgrep@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/vscode-ripgrep/-/vscode-ripgrep-1.2.4.tgz#b3cfbe08ed13f6cf6b134147ea4d982970ab4f70" + integrity sha512-TysaK20aCSfsFIQGd0DfMshjkHof0fG6zx7DoO0tdWNAZgsvoqLtOWdqHcocICRZ3RSpdiMiEJRaMK+iOzx16w== vscode-textmate@^4.0.1: version "4.0.1" From 0a327a0fc3d3f4cc609f5fddeab237a8db22ee9c Mon Sep 17 00:00:00 2001 From: jbicker Date: Tue, 13 Nov 2018 13:37:47 +0100 Subject: [PATCH 09/49] =?UTF-8?q?Added=20=E2=80=98=E2=80=94=E2=80=98=20as?= =?UTF-8?q?=20argument=20to=20search=20args=20after=20all=20other=20ripgre?= =?UTF-8?q?p=20options.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That prevents a searchterm like ‘—theia’ to be interpreted as ripgrep arg, which ends up in an error and nothing is shown in UI. Signed-off-by: jbicker --- ...ep-search-in-workspace-server.slow-spec.ts | 111 ++++++++++++++++++ .../ripgrep-search-in-workspace-server.ts | 47 +++++--- 2 files changed, 141 insertions(+), 17 deletions(-) diff --git a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts index 62b2d70c14608..cbdbef7b60c5e 100644 --- a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts +++ b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts @@ -104,6 +104,14 @@ Var är jag? Varför är jag här? createTestFile('special shell characters', `\ If one uses \`salut";\' echo foo && echo bar; "\` as a search term it should not be a problem to find here. +`); + + createTestFile('glob.txt', `\ +test -glob patterns +`); + + createTestFile('glob', `\ +test --glob patterns `); let lotsOfMatchesText = ''; @@ -456,6 +464,109 @@ describe('ripgrep-search-in-workspace-server', function () { ripgrepServer.search(pattern, rootDir, { useRegExp: true }); }); + it('searches a pattern starting with a dash w/o regex', function (done) { + const pattern = '-foobar'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'file with spaces', line: 1, character: 28, length: 7, lineText: '' }, + ]; + + if (!isWindows) { + expected.push( + { file: 'file:with:some:colons', line: 1, character: 28, length: 7, lineText: '' } + ); + } + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir); + }); + + it('searches a pattern starting with two dashes w/o regex', function (done) { + const pattern = '--foobar'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'file with spaces', line: 1, character: 27, length: 8, lineText: '' }, + ]; + + if (!isWindows) { + expected.push( + { file: 'file:with:some:colons', line: 1, character: 27, length: 8, lineText: '' } + ); + } + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir); + }); + + it('searches a whole pattern starting with - w/o regex', function (done) { + const pattern = '-glob'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'glob', line: 1, character: 7, length: 5, lineText: '' }, + { file: 'glob.txt', line: 1, character: 6, length: 5, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { matchWholeWord: true }); + }); + + it('searches a whole pattern starting with -- w/o regex', function (done) { + const pattern = '--glob'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'glob', line: 1, character: 6, length: 6, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { matchWholeWord: true }); + }); + + it('searches a pattern in .txt file', function (done) { + const pattern = '-glob'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'glob.txt', line: 1, character: 6, length: 5, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { include: ['*.txt'] }); + }); + + it('searches a whole pattern in .txt file', function (done) { + const pattern = '-glob'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'glob.txt', line: 1, character: 6, length: 5, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { include: ['*.txt'], matchWholeWord: true }); + }); + // Try searching in an UTF-8 file. it('searches in a UTF-8 file', function (done) { const pattern = ' jag'; diff --git a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts index 903748212358f..8e5d7aad3b73a 100644 --- a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts +++ b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts @@ -79,13 +79,29 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { protected getArgs(options?: SearchInWorkspaceOptions): string[] { const args = ['--json', '--max-count=100']; args.push(options && options.matchCase ? '--case-sensitive' : '--ignore-case'); - if (options && options.matchWholeWord) { - args.push('--word-regexp'); - } if (options && options.includeIgnored) { args.push('-uu'); } - args.push(options && options.useRegExp ? '--regexp' : '--fixed-strings'); + if (options && options.include) { + for (const include of options.include) { + if (include !== '') { + args.push('--glob=**/' + include); + } + } + } + if (options && options.exclude) { + for (const exclude of options.exclude) { + if (exclude !== '') { + args.push('--glob=!**/' + exclude); + } + } + } + if (options && options.useRegExp || options && options.matchWholeWord) { + args.push('--regexp'); + } else { + args.push('--fixed-strings'); + args.push('--'); + } return args; } @@ -96,24 +112,21 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { // we'll use to parse the lines. const searchId = this.nextSearchId++; const args = this.getArgs(opts); - const globs = []; - if (opts && opts.include) { - for (const include of opts.include) { - if (include !== '') { - globs.push('--glob=**/' + include); - } + // if we use matchWholeWord we use regExp internally, + // so, we need to escape regexp characters if we actually not set regexp true in UI. + if (opts && opts.matchWholeWord && !opts.useRegExp) { + what = what.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&'); + if (!/\B/.test(what.charAt(0))) { + what = '\\b' + what; } - } - if (opts && opts.exclude) { - for (const exclude of opts.exclude) { - if (exclude !== '') { - globs.push('--glob=!**/' + exclude); - } + if (!/\B/.test(what.charAt(what.length - 1))) { + what = what + '\\b'; } } + const processOptions: RawProcessOptions = { command: rgPath, - args: [...args, what, ...globs, FileUri.fsPath(rootUri)] + args: [...args, what, FileUri.fsPath(rootUri)] }; const process: RawProcess = this.rawProcessFactory(processOptions); this.ongoingSearches.set(searchId, process); From d711f201af71e8039ab49a27d4d00310ac38c2da Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Wed, 14 Nov 2018 21:07:46 -0500 Subject: [PATCH 10/49] Improve notification-count styling - Updated notification count styling for better visibility - Moved the css to core so that all notifications are consistent Signed-off-by: Vincent Fugnitto --- packages/core/src/browser/style/index.css | 1 + .../core/src/browser/style/notification.css | 34 +++++++++++++++++++ packages/git/src/browser/git-widget.tsx | 4 ++- packages/git/src/browser/style/index.css | 11 ------ .../src/browser/problem/problem-widget.tsx | 4 ++- packages/markers/src/browser/style/index.css | 13 ------- ...search-in-workspace-result-tree-widget.tsx | 6 ++-- .../src/browser/styles/index.css | 16 +-------- 8 files changed, 46 insertions(+), 43 deletions(-) create mode 100644 packages/core/src/browser/style/notification.css diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index 7645387f8969e..7d319ee4fc1e9 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -183,3 +183,4 @@ textarea { @import './search-box.css'; @import './ansi.css'; @import './view-container.css'; +@import './notification.css'; diff --git a/packages/core/src/browser/style/notification.css b/packages/core/src/browser/style/notification.css new file mode 100644 index 0000000000000..cd77cf95a5204 --- /dev/null +++ b/packages/core/src/browser/style/notification.css @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (C) 2018 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 + ********************************************************************************/ + +.notification-count-container { + align-self: center; + background-color: var(--theia-ui-font-color3); + border-radius: 20px; + color: var(--theia-ui-font-color0); + display: flex; + font-size: calc(var(--theia-ui-font-size0) * 0.8); + font-weight: 500; + height: calc(var(--theia-private-horizontal-tab-height) * 0.7); + justify-content: center; + min-width: 6px; + padding: 0 5px; + text-align: center; +} + +.notification-count-container > .notification-count { + align-self: center; +} diff --git a/packages/git/src/browser/git-widget.tsx b/packages/git/src/browser/git-widget.tsx index 448ed02ebbeb6..ce646400e93e7 100644 --- a/packages/git/src/browser/git-widget.tsx +++ b/packages/git/src/browser/git-widget.tsx @@ -839,6 +839,8 @@ export class GitChangesListContainer extends React.Component{changes}; + return
+ {changes} +
; } } diff --git a/packages/git/src/browser/style/index.css b/packages/git/src/browser/style/index.css index 09e868f7a3cac..07bd8d98b2cdd 100644 --- a/packages/git/src/browser/style/index.css +++ b/packages/git/src/browser/style/index.css @@ -331,16 +331,5 @@ } .git-change-count { - align-self: center; - background-color: var(--theia-ui-font-color3); - border-radius: 20px; - color: var(--theia-ui-font-color0); float: right; - font-size: calc(var(--theia-ui-font-size0) * 0.8); - font-weight: 500; - height: calc(var(--theia-private-horizontal-tab-height) * 0.7); - line-height: calc(var(--theia-private-horizontal-tab-height) * 0.7); - min-width: 6px; - padding: 0 5px; - text-align: center; } diff --git a/packages/markers/src/browser/problem/problem-widget.tsx b/packages/markers/src/browser/problem/problem-widget.tsx index 0b4af2ab49ead..3eada27026327 100644 --- a/packages/markers/src/browser/problem/problem-widget.tsx +++ b/packages/markers/src/browser/problem/problem-widget.tsx @@ -133,7 +133,9 @@ export class ProblemWidget extends TreeWidget {
{node.name}
{node.description || ''}
-
{node.numberOfMarkers.toString()}
+
+ {node.numberOfMarkers.toString()} +
; } diff --git a/packages/markers/src/browser/style/index.css b/packages/markers/src/browser/style/index.css index db070e0115bef..46296ea67c565 100644 --- a/packages/markers/src/browser/style/index.css +++ b/packages/markers/src/browser/style/index.css @@ -82,19 +82,6 @@ text-overflow: ellipsis; } -.theia-marker-container .markerFileNode .counter { - background-color: var(--theia-ui-font-color2); - border-radius: 20px; - padding: 0 5px; - text-align: center; - font-size: calc(var(--theia-ui-font-size0) * 0.8); - color: var(--theia-inverse-ui-font-color0); - font-weight: 400; - min-width: 8px; - height: calc(var(--theia-private-horizontal-tab-height) * 0.8); - line-height: calc(var(--theia-private-horizontal-tab-height) * 0.8); -} - .problem-tab-icon::before { content: "\f06a" } diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx index 20fde42bd55cb..396c12a685ea1 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx +++ b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx @@ -396,8 +396,10 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { {node.path} - - {node.children.length.toString()} + + + {node.children.length.toString()} + ; diff --git a/packages/search-in-workspace/src/browser/styles/index.css b/packages/search-in-workspace/src/browser/styles/index.css index cde0948ca9308..25e7d687e6be6 100644 --- a/packages/search-in-workspace/src/browser/styles/index.css +++ b/packages/search-in-workspace/src/browser/styles/index.css @@ -313,24 +313,10 @@ align-self: center; } -.theia-TreeNode:hover .result-number { +.theia-TreeNode:hover .notification-count-container { display: none; } -.theia-TreeNode .result-number { - background-color: var(--theia-ui-font-color2); - border-radius: 10px; - padding: 0 5px; - text-align: center; - font-size: calc(var(--theia-ui-font-size0) * 0.8); - color: var(--theia-inverse-ui-font-color0); - font-weight: 400; - min-width: 7px; - height: 16px; - line-height: calc(var(--theia-private-horizontal-tab-height) * 0.8); - align-self: center; -} - .result-node-buttons .remove-node { background-image: var(--theia-icon-close); } From 316c760b68231257da77b55ba217f43b14c9bc51 Mon Sep 17 00:00:00 2001 From: marechal-p Date: Thu, 8 Nov 2018 12:01:42 -0500 Subject: [PATCH 11/49] [core] Add and use application name Add customizable application name. Display application name when no workspace is opened, instead of showing the URL. It was particulary bad on Electron. Remove native menus when creating a new Electron BrowserWindow, which will be re-added by the application when ready. Signed-off-by: marechal-p --- CHANGELOG.md | 1 + .../src/generator/frontend-generator.ts | 9 +++++++-- .../src/application-props.ts | 9 ++++++++- examples/browser/package.json | 7 +++++++ examples/electron/package.json | 7 ++++++- .../src/browser/preference-service.spec.ts | 9 +++++++-- .../src/browser/workspace-service.spec.ts | 18 ++++++++++++++++++ .../workspace/src/browser/workspace-service.ts | 18 +++++++++++++----- 8 files changed, 67 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61a33067b6b0..d8f933fb5be20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.3.17 - [plug-in] added `languages.registerCodeLensProvider` Plug-in API - [core] `ctrl+alt+a` and `ctrl+alt+d` to switch tabs left/right +- [core] added `theia.applicationName` to application `package.json` and improved window title ## v0.3.16 diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index d7bc317d916f7..b2f0ade84d733 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -104,12 +104,13 @@ process.env.LC_NUMERIC = 'C'; const { join } = require('path'); const { isMaster } = require('cluster'); const { fork } = require('child_process'); -const { app, BrowserWindow, ipcMain } = require('electron'); +const { app, BrowserWindow, ipcMain, Menu } = require('electron'); +const applicationName = \`${this.pck.props.frontend.config.applicationName}\`; const windows = []; function createNewWindow(theUrl) { - const newWindow = new BrowserWindow({ width: 1024, height: 728, show: !!theUrl }); + const newWindow = new BrowserWindow({ width: 1024, height: 728, show: !!theUrl, title: applicationName }); if (windows.length === 0) { newWindow.webContents.on('new-window', (event, url, frameName, disposition, options) => { // If the first electron window isn't visible, then all other new windows will remain invisible. @@ -117,6 +118,7 @@ function createNewWindow(theUrl) { options.show = true; options.width = 1024; options.height = 728; + options.title = applicationName; }); } windows.push(newWindow); @@ -147,6 +149,9 @@ if (isMaster) { createNewWindow(url); }); app.on('ready', () => { + // Remove the default electron menus, waiting for the application to set its own. + Menu.setApplicationMenu(Menu.buildFromTemplate([])); + // Check whether we are in bundled application or development mode. // @ts-ignore const devMode = process.defaultApp || /node_modules[\/]electron[\/]/.test(process.execPath); diff --git a/dev-packages/application-package/src/application-props.ts b/dev-packages/application-package/src/application-props.ts index 0b4aa6bd55d01..5502e1bc486c5 100644 --- a/dev-packages/application-package/src/application-props.ts +++ b/dev-packages/application-package/src/application-props.ts @@ -69,7 +69,9 @@ export namespace ApplicationProps { config: {} }, frontend: { - config: {} + config: { + applicationName: 'Theia' + } } }; @@ -93,6 +95,11 @@ export interface FrontendApplicationConfig extends ApplicationConfig { */ readonly defaultTheme?: string; + /** + * The name of the application. `Theia` by default. + */ + readonly applicationName: string; + } /** diff --git a/examples/browser/package.json b/examples/browser/package.json index 4c3e5e3ccd1b7..56820c5248533 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -2,6 +2,13 @@ "private": true, "name": "@theia/example-browser", "version": "0.3.16", + "theia": { + "frontend": { + "config": { + "applicationName": "Theia Browser Example" + } + } + }, "dependencies": { "@theia/callhierarchy": "^0.3.16", "@theia/console": "^0.3.16", diff --git a/examples/electron/package.json b/examples/electron/package.json index a57c90c67df97..cd63b89cd65fb 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -3,7 +3,12 @@ "name": "@theia/example-electron", "version": "0.3.16", "theia": { - "target": "electron" + "target": "electron", + "frontend": { + "config": { + "applicationName": "Theia Electron Example" + } + } }, "dependencies": { "@theia/callhierarchy": "^0.3.16", diff --git a/packages/preferences/src/browser/preference-service.spec.ts b/packages/preferences/src/browser/preference-service.spec.ts index 17ec79c8e74b4..c9f7b0c205820 100644 --- a/packages/preferences/src/browser/preference-service.spec.ts +++ b/packages/preferences/src/browser/preference-service.spec.ts @@ -46,6 +46,7 @@ import { MockWorkspaceServer } from '@theia/workspace/lib/common/test/mock-works import { MockWindowService } from '@theia/core/lib/browser/window/test/mock-window-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { WorkspacePreferences, createWorkspacePreferences } from '@theia/workspace/lib/browser/workspace-preferences'; +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; import * as sinon from 'sinon'; import URI from '@theia/core/lib/common/uri'; @@ -60,7 +61,7 @@ const tempPath = temp.track().openSync().path; const mockUserPreferenceEmitter = new Emitter(); const mockWorkspacePreferenceEmitter = new Emitter(); -before(async () => { +function testContainerSetup() { testContainer = new Container(); bindPreferenceSchemaProvider(testContainer.bind.bind(testContainer)); @@ -130,12 +131,16 @@ before(async () => { /* Logger mock */ testContainer.bind(ILogger).to(MockLogger); -}); +} describe('Preference Service', function () { before(() => { disableJSDOM = enableJSDOM(); + FrontendApplicationConfigProvider.set({ + 'applicationName': 'test', + }); + testContainerSetup(); }); after(() => { diff --git a/packages/workspace/src/browser/workspace-service.spec.ts b/packages/workspace/src/browser/workspace-service.spec.ts index 5ce7e8e01a763..1e3ff4056e28c 100644 --- a/packages/workspace/src/browser/workspace-service.spec.ts +++ b/packages/workspace/src/browser/workspace-service.spec.ts @@ -14,9 +14,13 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + import { Container } from 'inversify'; import { WorkspaceService } from './workspace-service'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; import { FileSystemNode } from '@theia/filesystem/lib/node/node-filesystem'; import { FileSystemWatcher, FileChangeEvent, FileChangeType } from '@theia/filesystem/lib/browser/filesystem-watcher'; import { DefaultWindowService, WindowService } from '@theia/core/lib/browser/window/window-service'; @@ -30,6 +34,8 @@ import * as chai from 'chai'; import URI from '@theia/core/lib/common/uri'; const expect = chai.expect; +disableJSDOM(); + const folderA = Object.freeze({ uri: 'file:///home/folderA', lastModification: 0, @@ -60,6 +66,17 @@ describe('WorkspaceService', () => { let mockILogger: ILogger; let mockPref: WorkspacePreferences; + before(() => { + disableJSDOM = enableJSDOM(); + FrontendApplicationConfigProvider.set({ + 'applicationName': 'test', + }); + }); + + after(() => { + disableJSDOM(); + }); + beforeEach(() => { mockPreferenceValues = {}; mockFilesystem = sinon.createStubInstance(FileSystemNode); @@ -88,6 +105,7 @@ describe('WorkspaceService', () => { wsService = testContainer.get(WorkspaceService); }); + afterEach(() => { wsService['toDisposeOnWorkspace'].dispose(); toRestore.forEach(res => { diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index e7f10d4590e29..f11bd4e4c8282 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -26,6 +26,7 @@ import { ILogger, Disposable, DisposableCollection, Emitter, Event } from '@thei import { WorkspacePreferences } from './workspace-preferences'; import * as jsoncparser from 'jsonc-parser'; import * as Ajv from 'ajv'; +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; export const THEIA_EXT = 'theia-workspace'; export const VSCODE_EXT = 'code-workspace'; @@ -64,6 +65,8 @@ export class WorkspaceService implements FrontendApplicationContribution { @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; + protected applicationName = FrontendApplicationConfigProvider.get().applicationName; + @postConstruct() protected async init(): Promise { const workspaceUri = await this.server.getMostRecentlyUsedWorkspace(); @@ -168,19 +171,24 @@ export class WorkspaceService implements FrontendApplicationContribution { } } - protected updateTitle(): void { + protected formatTitle(title?: string): string { + const name = this.applicationName; + return title ? `${title} — ${name}` : name; + } + + protected updateTitle() { + let title: string | undefined; if (this._workspace) { const uri = new URI(this._workspace.uri); const displayName = uri.displayName; if (!this._workspace.isDirectory && (displayName.endsWith(`.${THEIA_EXT}`) || displayName.endsWith(`.${VSCODE_EXT}`))) { - document.title = displayName.slice(0, displayName.lastIndexOf('.')); + title = displayName.slice(0, displayName.lastIndexOf('.')); } else { - document.title = displayName; + title = displayName; } - } else { - document.title = window.location.href; } + document.title = this.formatTitle(title); } /** From fb5ec9ac287ec4d42caf360d6c164c6286405ec4 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Fri, 16 Nov 2018 08:17:18 -0500 Subject: [PATCH 12/49] Use application name for 'getting-started' title - Set the title of the `getting-started` widget by getting the `application-name` which has been recently supported - Makes it much easier for theia applications to set their custom name, and reflect it in the `getting-started` widget - Minor css cleanup (remove unused class names, updated css to reflect updated name) Signed-off-by: Vincent Fugnitto --- .../getting-started/src/browser/getting-started-widget.tsx | 6 ++++-- packages/getting-started/src/browser/style/index.css | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/getting-started/src/browser/getting-started-widget.tsx b/packages/getting-started/src/browser/getting-started-widget.tsx index d3b7b032f93c2..09d6df8e5fa6a 100644 --- a/packages/getting-started/src/browser/getting-started-widget.tsx +++ b/packages/getting-started/src/browser/getting-started-widget.tsx @@ -25,6 +25,7 @@ import { FileSystemUtils } from '@theia/filesystem/lib/common/filesystem-utils'; import { KeymapsCommands } from '@theia/keymaps/lib/browser'; import { CommonCommands } from '@theia/core/lib/browser'; import { ApplicationInfo, ApplicationServer } from '@theia/core/lib/common/application-protocol'; +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; @injectable() export class GettingStartedWidget extends ReactWidget { @@ -33,6 +34,7 @@ export class GettingStartedWidget extends ReactWidget { static readonly LABEL = 'Getting Started'; protected applicationInfo: ApplicationInfo | undefined; + protected applicationName = FrontendApplicationConfigProvider.get().applicationName; protected stat: FileStat | undefined; protected home: string | undefined; @@ -104,7 +106,7 @@ export class GettingStartedWidget extends ReactWidget { protected renderHeader(): React.ReactNode { return
-

Theia Getting Started

+

{this.applicationName} Getting Started

; } @@ -182,7 +184,7 @@ export class GettingStartedWidget extends ReactWidget { protected renderVersion(): React.ReactNode { return
-

+

{this.applicationInfo ? 'Version ' + this.applicationInfo.version : ''}

diff --git a/packages/getting-started/src/browser/style/index.css b/packages/getting-started/src/browser/style/index.css index 1efd4d03b49e8..7a3bd1d289886 100644 --- a/packages/getting-started/src/browser/style/index.css +++ b/packages/getting-started/src/browser/style/index.css @@ -46,7 +46,6 @@ html, body { color: var(--theia-ui-font-color0); flex: 1; font-weight: 600; - text-transform: uppercase; } .gs-hr { @@ -84,7 +83,7 @@ html, body { } .gs-sub-header { - color: var(--theia-ui-font-color1); + color: var(--theia-ui-font-color2); text-transform: capitalize; font-weight: 400; } From a6d9eb065fcd731d867ff13f40740c915b489208 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Mon, 12 Nov 2018 15:21:57 -0500 Subject: [PATCH 13/49] [electron] Center window + dynamic size `electron.screen` is only defined once the electron `app` is ready, it is undefined before, hence the refactoring. Sets up the default new window size as `2/3` of the screen that is below the mouse when a new window is being created. Window is also centered on screen. Fixes #3418 Signed-off-by: Vincent Fugnitto --- .../src/generator/frontend-generator.ts | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index b2f0ade84d733..5e61b5559b80d 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -101,57 +101,74 @@ if (process.env.LC_ALL) { } process.env.LC_NUMERIC = 'C'; +const electron = require('electron'); const { join } = require('path'); const { isMaster } = require('cluster'); const { fork } = require('child_process'); -const { app, BrowserWindow, ipcMain, Menu } = require('electron'); +const { app, BrowserWindow, ipcMain, Menu } = electron; const applicationName = \`${this.pck.props.frontend.config.applicationName}\`; -const windows = []; - -function createNewWindow(theUrl) { - const newWindow = new BrowserWindow({ width: 1024, height: 728, show: !!theUrl, title: applicationName }); - if (windows.length === 0) { - newWindow.webContents.on('new-window', (event, url, frameName, disposition, options) => { - // If the first electron window isn't visible, then all other new windows will remain invisible. - // https://github.com/electron/electron/issues/3751 - options.show = true; - options.width = 1024; - options.height = 728; - options.title = applicationName; - }); - } - windows.push(newWindow); - if (!!theUrl) { - newWindow.loadURL(theUrl); - } else { - newWindow.on('ready-to-show', () => newWindow.show()); - } - newWindow.on('closed', () => { - const index = windows.indexOf(newWindow); - if (index !== -1) { - windows.splice(index, 1); - } - if (windows.length === 0) { - app.exit(0); - } - }); - return newWindow; -} if (isMaster) { - app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } - }); - ipcMain.on('create-new-window', (event, url) => { - createNewWindow(url); - }); app.on('ready', () => { + const { screen } = electron; + // Remove the default electron menus, waiting for the application to set its own. Menu.setApplicationMenu(Menu.buildFromTemplate([])); + // Window list tracker. + const windows = []; + + function createNewWindow(theUrl) { + + // We must center by hand because \`browserWindow.center()\` fails on multi-screen setups + // See: https://github.com/electron/electron/issues/3490 + const { bounds } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const height = Math.floor(bounds.height * (2/3)); + const width = Math.floor(bounds.width * (2/3)); + + const y = Math.floor(bounds.y + (bounds.height - height) / 2); + const x = Math.floor(bounds.x + (bounds.width - width) / 2); + + const newWindow = new BrowserWindow({ width, height, x, y, show: !!theUrl, title: applicationName }); + + if (windows.length === 0) { + newWindow.webContents.on('new-window', (event, url, frameName, disposition, options) => { + // If the first electron window isn't visible, then all other new windows will remain invisible. + // https://github.com/electron/electron/issues/3751 + options.show = true; + options.width = width; + options.height = height; + options.title = applicationName; + }); + } + windows.push(newWindow); + if (!!theUrl) { + newWindow.loadURL(theUrl); + } else { + newWindow.on('ready-to-show', () => newWindow.show()); + } + newWindow.on('closed', () => { + const index = windows.indexOf(newWindow); + if (index !== -1) { + windows.splice(index, 1); + } + if (windows.length === 0) { + app.exit(0); + } + }); + return newWindow; + } + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + ipcMain.on('create-new-window', (event, url) => { + createNewWindow(url); + }); + // Check whether we are in bundled application or development mode. // @ts-ignore const devMode = process.defaultApp || /node_modules[\/]electron[\/]/.test(process.execPath); From 00988ab68d2373355bbf289363d0e45b7186666f Mon Sep 17 00:00:00 2001 From: Uni Sayo Date: Thu, 15 Nov 2018 08:18:16 +0000 Subject: [PATCH 14/49] Update xterm.js to 3.8.0 Signed-off-by: Uni Sayo --- packages/terminal/package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 596ae2cc68bf3..e0003f021b87b 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -7,7 +7,7 @@ "@theia/filesystem": "^0.3.16", "@theia/process": "^0.3.16", "@theia/workspace": "^0.3.16", - "xterm": "~3.5.0" + "xterm": "~3.8.0" }, "publishConfig": { "access": "public" diff --git a/yarn.lock b/yarn.lock index 11351d10ca582..63c64671bb485 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10364,9 +10364,10 @@ xtend@~2.1.1: dependencies: object-keys "~0.4.0" -xterm@~3.5.0: - version "3.5.1" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.5.1.tgz#d2e62ab26108a771b7bd1b7be4f6578fb4aff922" +xterm@~3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.8.0.tgz#55d1de518bdc9c9793823f5e4e97d6898972938d" + integrity sha512-rS3HLryuMWbLsv98+jVVSUXCxmoyXPwqwJNC0ad0VSMdXgl65LefPztQVwfurkaF7kM7ZSgM8eJjnJ9kkdoR1w== y18n@^3.2.1: version "3.2.1" From e6977a73a1905cd3df91067506046d4868657811 Mon Sep 17 00:00:00 2001 From: elaihau Date: Fri, 16 Nov 2018 08:34:04 -0500 Subject: [PATCH 15/49] unit tests for git-repository-provider Signed-off-by: elaihau --- .../browser/git-repository-provider.spec.ts | 182 ++++++++++++++++++ .../src/browser/git-repository-provider.ts | 3 +- 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/git/src/browser/git-repository-provider.spec.ts diff --git a/packages/git/src/browser/git-repository-provider.spec.ts b/packages/git/src/browser/git-repository-provider.spec.ts new file mode 100644 index 0000000000000..deab9b50fe262 --- /dev/null +++ b/packages/git/src/browser/git-repository-provider.spec.ts @@ -0,0 +1,182 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + +import { Container } from 'inversify'; +import { Git, Repository } from '../common'; +import { DugiteGit } from '../node/dugite-git'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import { FileSystemNode } from '@theia/filesystem/lib/node/node-filesystem'; +import { Emitter } from '@theia/core'; +import { LocalStorageService } from '@theia/core/lib/browser'; +import { GitRepositoryProvider } from './git-repository-provider'; +import * as sinon from 'sinon'; +import * as chai from 'chai'; +const expect = chai.expect; + +disableJSDOM(); + +const folderA = { + uri: 'file:///home/repoA', + lastModification: 0, + isDirectory: true +}; +const repoA1 = { + localUri: `${folderA.uri}/1` +}; +const repoA2 = { + localUri: `${folderA.uri}/2` +}; + +const folderB = { + uri: 'file:///home/repoB', + lastModification: 0, + isDirectory: true +}; +const repoB = { + localUri: folderB.uri +}; + +// tslint:disable:no-any +describe('GitRepositoryProvider', () => { + let testContainer: Container; + + let mockGit: DugiteGit; + let mockWorkspaceService: WorkspaceService; + let mockFilesystem: FileSystem; + let mockStorageService: LocalStorageService; + + let gitRepositoryProvider: GitRepositoryProvider; + const mockRootChangeEmitter: Emitter = new Emitter(); + + before(() => { + disableJSDOM = enableJSDOM(); + }); + after(() => { + disableJSDOM(); + }); + + beforeEach(() => { + mockGit = sinon.createStubInstance(DugiteGit); + mockWorkspaceService = sinon.createStubInstance(WorkspaceService); + mockFilesystem = sinon.createStubInstance(FileSystemNode); + mockStorageService = sinon.createStubInstance(LocalStorageService); + + testContainer = new Container(); + testContainer.bind(GitRepositoryProvider).toSelf().inSingletonScope(); + testContainer.bind(Git).toConstantValue(mockGit); + testContainer.bind(WorkspaceService).toConstantValue(mockWorkspaceService); + testContainer.bind(FileSystem).toConstantValue(mockFilesystem); + testContainer.bind(LocalStorageService).toConstantValue(mockStorageService); + + sinon.stub(mockWorkspaceService, 'onWorkspaceChanged').value(mockRootChangeEmitter.event); + }); + + it('should adds all existing git repo(s) on theia loads', async () => { + const allRepos = [repoA1, repoA2]; + const roots = [folderA]; + (mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allRepos[0]); + (mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allRepos); + sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve()); + (mockWorkspaceService.tryGetRoots).returns(roots); + gitRepositoryProvider = testContainer.get(GitRepositoryProvider); + (mockFilesystem.exists).resolves(true); + (mockGit.repositories).withArgs(folderA.uri, {}).resolves(allRepos); + + await gitRepositoryProvider['initialize'](); + expect(gitRepositoryProvider.allRepositories.length).to.eq(allRepos.length); + expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allRepos[0].localUri); + expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allRepos[1].localUri); + expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allRepos[0].localUri); + }); + + it('should refresh git repo(s) on receiving a root change event from WorkspaceService', done => { + const allReposA = [repoA1, repoA2]; + const oldRoots = [folderA]; + const allReposB = [repoB]; + const newRoots = [folderA, folderB]; + (mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allReposA[0]); + (mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allReposA); + sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve()); + const stubWsRoots = mockWorkspaceService.tryGetRoots; + stubWsRoots.onCall(0).returns(oldRoots); + stubWsRoots.onCall(1).returns(oldRoots); + stubWsRoots.onCall(2).returns(newRoots); + gitRepositoryProvider = testContainer.get(GitRepositoryProvider); + (mockFilesystem.exists).resolves(true); + (mockGit.repositories).withArgs(folderA.uri, {}).resolves(allReposA); + (mockGit.repositories).withArgs(folderB.uri, {}).resolves(allReposB); + + let counter = 0; + gitRepositoryProvider.onDidChangeRepository(selected => { + counter++; + if (counter === 3) { + expect(gitRepositoryProvider.allRepositories.length).to.eq(allReposA.concat(allReposB).length); + expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allReposA[0].localUri); + expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allReposA[1].localUri); + expect(gitRepositoryProvider.allRepositories[2].localUri).to.eq(allReposB[0].localUri); + expect(selected && selected.localUri).to.eq(allReposA[0].localUri); + done(); + } + }); + gitRepositoryProvider['initialize']().then(() => + mockRootChangeEmitter.fire([folderA, folderB]) + ).catch(e => + done(new Error('gitRepositoryProvider.initialize() throws an error')) + ); + }).timeout(2000); + + it('should ignore the invalid or nonexistent root(s)', async () => { + const allReposA = [repoA1, repoA2]; + const roots = [folderA, folderB]; + (mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allReposA[0]); + (mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allReposA); + sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve()); + (mockWorkspaceService.tryGetRoots).returns(roots); + gitRepositoryProvider = testContainer.get(GitRepositoryProvider); + (mockFilesystem.exists).withArgs(folderA.uri).resolves(true); // folderA exists + (mockFilesystem.exists).withArgs(folderB.uri).resolves(false); // folderB does not exist + (mockGit.repositories).withArgs(folderA.uri, {}).resolves(allReposA); + + await gitRepositoryProvider['initialize'](); + expect(gitRepositoryProvider.allRepositories.length).to.eq(allReposA.length); + expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allReposA[0].localUri); + expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allReposA[1].localUri); + expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allReposA[0].localUri); + }); + + it('should mark the first repo in the first root as "selectedRepository", if the "selectedRepository" is unavailable in the first place', async () => { + const allReposA = [repoA1, repoA2]; + const roots = [folderA, folderB]; + const allReposB = [repoB]; + (mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(undefined); + (mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(undefined); + sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve()); + (mockWorkspaceService.tryGetRoots).returns(roots); + gitRepositoryProvider = testContainer.get(GitRepositoryProvider); + (mockFilesystem.exists).resolves(true); + (mockGit.repositories).withArgs(folderA.uri, {}).resolves(allReposA); + (mockGit.repositories).withArgs(folderA.uri, { maxCount: 1 }).resolves([allReposA[0]]); + (mockGit.repositories).withArgs(folderB.uri, {}).resolves(allReposB); + (mockGit.repositories).withArgs(folderB.uri, { maxCount: 1 }).resolves([allReposB[0]]); + + await gitRepositoryProvider['initialize'](); + expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allReposA[0].localUri); + }); +}); diff --git a/packages/git/src/browser/git-repository-provider.ts b/packages/git/src/browser/git-repository-provider.ts index 8b8018557eabb..4912887256a48 100644 --- a/packages/git/src/browser/git-repository-provider.ts +++ b/packages/git/src/browser/git-repository-provider.ts @@ -113,7 +113,8 @@ export class GitRepositoryProvider { async refresh(options?: GitRefreshOptions): Promise { const roots: FileStat[] = []; - for (const root of await this.workspaceService.roots) { + await this.workspaceService.roots; + for (const root of this.workspaceService.tryGetRoots()) { if (await this.fileSystem.exists(root.uri)) { roots.push(root); } From abcaea4cee5679fbb0484455c21e561fd147bafa Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Fri, 16 Nov 2018 10:43:28 -0500 Subject: [PATCH 16/49] [outline] align outline view 'no data' message with problems view Fixes #3520 Signed-off-by: Vincent Fugnitto --- packages/outline-view/src/browser/styles/index.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/outline-view/src/browser/styles/index.css b/packages/outline-view/src/browser/styles/index.css index ff35e708224af..3a680e34b2733 100644 --- a/packages/outline-view/src/browser/styles/index.css +++ b/packages/outline-view/src/browser/styles/index.css @@ -19,7 +19,7 @@ } .no-outline { - color: var(--theia-ui-font-color2); + color: var(--theia-ui-font-color0); padding: 10px; - text-align: center; + text-align: left; } From 68f919220fb1da3135986a8748af6e5f52a43745 Mon Sep 17 00:00:00 2001 From: Oleksandr Andriienko Date: Thu, 8 Nov 2018 14:42:40 +0200 Subject: [PATCH 17/49] theia-3437: Align terminal exit with Vscode. Signed-off-by: Oleksandr Andriienko --- .../terminal/src/browser/terminal-widget-impl.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 3237d1730a908..b099f92ba565a 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -57,7 +57,7 @@ interface TerminalCSSProperties { export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget { private readonly TERMINAL = 'Terminal'; - private readonly onTermDidClose = new Emitter(); + protected readonly onTermDidClose = new Emitter(); protected terminalId: number; protected term: Xterm.Terminal; protected restored = false; @@ -123,16 +123,15 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget this.toDispose.push(this.terminalWatcher.onTerminalError(({ terminalId, error }) => { if (terminalId === this.terminalId) { - if (!this.title.label.endsWith('')) { - this.title.label = `${this.title.label} `; + this.dispose(); + this.onTermDidClose.fire(this); + this.onTermDidClose.dispose(); + this.logger.error(`The terminal process terminated. Cause: ${error}`); } - } })); this.toDispose.push(this.terminalWatcher.onTerminalExit(({ terminalId }) => { if (terminalId === this.terminalId) { - if (!this.title.label.endsWith('')) { - this.title.label = `${this.title.label} `; - } + this.dispose(); this.onTermDidClose.fire(this); this.onTermDidClose.dispose(); } From 0846c07c3d5dce18315bf0b707e4d343481aa0d0 Mon Sep 17 00:00:00 2001 From: Anton Kosiakov Date: Fri, 16 Nov 2018 11:28:49 +0100 Subject: [PATCH 18/49] fix #3477: [debug] cache vscode schema attributes Signed-off-by: Anton Kosiakov --- .../src/node/vscode/vscode-debug-adapter-contribution.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts b/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts index fc264b5d42029..5a02b328906dc 100644 --- a/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts +++ b/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts @@ -105,7 +105,11 @@ export abstract class AbstractVSCodeDebugAdapterContribution implements DebugAda return debuggerContribution; } + protected schemaAttributes: Promise | undefined; async getSchemaAttributes(): Promise { + return this.schemaAttributes || (this.schemaAttributes = this.resolveSchemaAttributes()); + } + protected async resolveSchemaAttributes(): Promise { const debuggerContribution = await this.debuggerContribution; if (!debuggerContribution.configurationAttributes) { return []; From 367897b1bf013d9262b9b1d94b6809cfef8ec6bb Mon Sep 17 00:00:00 2001 From: Anton Kosiakov Date: Fri, 16 Nov 2018 16:09:07 +0100 Subject: [PATCH 19/49] =?UTF-8?q?[fs]=C2=A0remove=20disconnected=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Anton Kosiakov --- packages/filesystem/src/common/filesystem.ts | 18 ++++++++++ .../src/node/filesystem-backend-module.ts | 33 ++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/filesystem/src/common/filesystem.ts b/packages/filesystem/src/common/filesystem.ts index 4b99a2b1a74df..178c381aadb35 100644 --- a/packages/filesystem/src/common/filesystem.ts +++ b/packages/filesystem/src/common/filesystem.ts @@ -16,6 +16,7 @@ import { TextDocumentContentChangeEvent } from 'vscode-languageserver-types'; import { JsonRpcServer, ApplicationError } from '@theia/core/lib/common'; +import { injectable } from 'inversify'; export const fileSystemPath = '/services/filesystem'; export const FileSystem = Symbol('FileSystem'); @@ -192,6 +193,23 @@ export interface FileSystemClient { } +@injectable() +export class DispatchingFileSystemClient implements FileSystemClient { + + readonly clients = new Set(); + + shouldOverwrite(originalStat: FileStat, currentStat: FileStat): Promise { + return Promise.race([...this.clients].map(client => + client.shouldOverwrite(originalStat, currentStat)) + ); + } + + onDidMove(sourceUri: string, targetUri: string): void { + this.clients.forEach(client => client.onDidMove(sourceUri, targetUri)); + } + +} + /** * A file resource with meta information. */ diff --git a/packages/filesystem/src/node/filesystem-backend-module.ts b/packages/filesystem/src/node/filesystem-backend-module.ts index 3b1eab6442eef..96c1dc28b409c 100644 --- a/packages/filesystem/src/node/filesystem-backend-module.ts +++ b/packages/filesystem/src/node/filesystem-backend-module.ts @@ -18,13 +18,20 @@ import * as cluster from 'cluster'; import { ContainerModule, interfaces } from 'inversify'; import { ConnectionHandler, JsonRpcConnectionHandler, ILogger } from '@theia/core/lib/common'; import { FileSystemNode } from './node-filesystem'; -import { FileSystem, FileSystemClient, fileSystemPath } from '../common'; +import { FileSystem, FileSystemClient, fileSystemPath, DispatchingFileSystemClient } from '../common'; import { FileSystemWatcherServer, FileSystemWatcherClient, fileSystemWatcherPath } from '../common/filesystem-watcher-protocol'; import { FileSystemWatcherServerClient } from './filesystem-watcher-client'; import { NsfwFileSystemWatcherServer } from './nsfw-watcher/nsfw-filesystem-watcher'; -export function bindFileSystem(bind: interfaces.Bind): void { - bind(FileSystemNode).toSelf().inSingletonScope(); +export function bindFileSystem(bind: interfaces.Bind, props?: { + onFileSystemActivation: (context: interfaces.Context, fs: FileSystem) => void +}): void { + bind(FileSystemNode).toSelf().inSingletonScope().onActivation((context, fs) => { + if (props && props.onFileSystemActivation) { + props.onFileSystemActivation(context, fs); + } + return fs; + }); bind(FileSystem).toService(FileSystemNode); } @@ -44,13 +51,21 @@ export function bindFileSystemWatcherServer(bind: interfaces.Bind): void { } export default new ContainerModule(bind => { - bindFileSystem(bind); - bind(ConnectionHandler).toDynamicValue(ctx => + bind(DispatchingFileSystemClient).toSelf().inSingletonScope(); + bindFileSystem(bind, { + onFileSystemActivation: ({ container }, fs) => { + fs.setClient(container.get(DispatchingFileSystemClient)); + fs.setClient = () => { + throw new Error('use DispatchingFileSystemClient'); + }; + } + }); + bind(ConnectionHandler).toDynamicValue(({ container }) => new JsonRpcConnectionHandler(fileSystemPath, client => { - const server = ctx.container.get(FileSystem); - server.setClient(client); - client.onDidCloseConnection(() => server.dispose()); - return server; + const dispatching = container.get(DispatchingFileSystemClient); + dispatching.clients.add(client); + client.onDidCloseConnection(() => dispatching.clients.delete(client)); + return container.get(FileSystem); }) ).inSingletonScope(); From 3df7ea858570f0265b65fae05af941c22019f6fb Mon Sep 17 00:00:00 2001 From: Anton Kosiakov Date: Fri, 16 Nov 2018 16:09:27 +0100 Subject: [PATCH 20/49] [logger] remove disconnected clients Signed-off-by: Anton Kosiakov --- packages/core/src/common/logger-protocol.ts | 12 +++++++ .../core/src/node/logger-backend-module.ts | 32 ++++++++++++++----- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/core/src/common/logger-protocol.ts b/packages/core/src/common/logger-protocol.ts index b6c1e1c89dff2..2bdc0d7dbf42c 100644 --- a/packages/core/src/common/logger-protocol.ts +++ b/packages/core/src/common/logger-protocol.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { injectable } from 'inversify'; import { JsonRpcServer } from './messaging/proxy-factory'; export const ILoggerServer = Symbol('ILoggerServer'); @@ -39,6 +40,17 @@ export interface ILoggerClient { onLogLevelChanged(event: ILogLevelChangedEvent): void; } +@injectable() +export class DispatchingLoggerClient implements ILoggerClient { + + readonly clients = new Set(); + + onLogLevelChanged(event: ILogLevelChangedEvent): void { + this.clients.forEach(client => client.onLogLevelChanged(event)); + } + +} + export const rootLoggerName = 'root'; export enum LogLevel { diff --git a/packages/core/src/node/logger-backend-module.ts b/packages/core/src/node/logger-backend-module.ts index fb4a54f816178..640b1ccfe53c9 100644 --- a/packages/core/src/node/logger-backend-module.ts +++ b/packages/core/src/node/logger-backend-module.ts @@ -17,18 +17,25 @@ import { ContainerModule, Container, interfaces } from 'inversify'; import { ConnectionHandler, JsonRpcConnectionHandler } from '../common/messaging'; import { ILogger, LoggerFactory, Logger, setRootLogger, LoggerName, rootLoggerName } from '../common/logger'; -import { ILoggerServer, ILoggerClient, loggerPath } from '../common/logger-protocol'; +import { ILoggerServer, ILoggerClient, loggerPath, DispatchingLoggerClient } from '../common/logger-protocol'; import { ConsoleLoggerServer } from './console-logger-server'; import { LoggerWatcher } from '../common/logger-watcher'; import { BackendApplicationContribution } from './backend-application'; import { CliContribution } from './cli'; import { LogLevelCliContribution } from './logger-cli-contribution'; -export function bindLogger(bind: interfaces.Bind): void { +export function bindLogger(bind: interfaces.Bind, props?: { + onLoggerServerActivation?: (context: interfaces.Context, server: ILoggerServer) => void +}): void { bind(LoggerName).toConstantValue(rootLoggerName); bind(ILogger).to(Logger).inSingletonScope().whenTargetIsDefault(); bind(LoggerWatcher).toSelf().inSingletonScope(); - bind(ILoggerServer).to(ConsoleLoggerServer).inSingletonScope(); + bind(ILoggerServer).to(ConsoleLoggerServer).inSingletonScope().onActivation((context, server) => { + if (props && props.onLoggerServerActivation) { + props.onLoggerServerActivation(context, server); + } + return server; + }); bind(LogLevelCliContribution).toSelf().inSingletonScope(); bind(CliContribution).toService(LogLevelCliContribution); bind(LoggerFactory).toFactory(ctx => @@ -53,13 +60,22 @@ export const loggerBackendModule = new ContainerModule(bind => { } })); - bindLogger(bind); + bind(DispatchingLoggerClient).toSelf().inSingletonScope(); + bindLogger(bind, { + onLoggerServerActivation: ({ container }, server) => { + server.setClient(container.get(DispatchingLoggerClient)); + server.setClient = () => { + throw new Error('use DispatchingLoggerClient'); + }; + } + }); - bind(ConnectionHandler).toDynamicValue(ctx => + bind(ConnectionHandler).toDynamicValue(({ container }) => new JsonRpcConnectionHandler(loggerPath, client => { - const loggerServer = ctx.container.get(ILoggerServer); - loggerServer.setClient(client); - return loggerServer; + const dispatching = container.get(DispatchingLoggerClient); + dispatching.clients.add(client); + client.onDidCloseConnection(() => dispatching.clients.delete(client)); + return container.get(ILoggerServer); }) ).inSingletonScope(); }); From 54ce7f69f0880d83bd62b058bef2cf174bc1d5d5 Mon Sep 17 00:00:00 2001 From: Anton Kosiakov Date: Fri, 16 Nov 2018 16:10:16 +0100 Subject: [PATCH 21/49] [nsfw] release all references to NSFW module to let GC unload it Signed-off-by: Anton Kosiakov --- .../nsfw-watcher/nsfw-filesystem-watcher.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.ts b/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.ts index 33da33d1999b9..b26225e145442 100644 --- a/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.ts +++ b/packages/filesystem/src/node/nsfw-watcher/nsfw-filesystem-watcher.ts @@ -45,7 +45,9 @@ export class NsfwFileSystemWatcherServer implements FileSystemWatcherServer { protected readonly watchers = new Map(); protected readonly watcherOptions = new Map(); - protected readonly toDispose = new DisposableCollection(); + protected readonly toDispose = new DisposableCollection( + Disposable.create(() => this.setClient(undefined)) + ); protected changes = new FileChangeCollection(); @@ -106,7 +108,7 @@ export class NsfwFileSystemWatcherServer implements FileSystemWatcherServer { this.debug('Files ignored for watching', options.ignored); } - const watcher: nsfw.NSFW = await nsfw(fs.realpathSync(basePath), (events: nsfw.ChangeEvent[]) => { + let watcher: nsfw.NSFW | undefined = await nsfw(fs.realpathSync(basePath), (events: nsfw.ChangeEvent[]) => { for (const event of events) { if (event.action === nsfw.actions.CREATED) { this.pushAdded(watcherId, paths.join(event.directory, event.file!)); @@ -125,12 +127,24 @@ export class NsfwFileSystemWatcherServer implements FileSystemWatcherServer { }); await watcher.start(); this.options.info('Started watching:', basePath); - const disposable = Disposable.create(() => { - this.watcherOptions.delete(watcherId); - this.watchers.delete(watcherId); + if (this.toDispose.disposed) { this.debug('Stopping watching:', basePath); - watcher.stop(); + await watcher.stop(); + // remove a reference to nsfw otherwise GC cannot collect it + watcher = undefined; this.options.info('Stopped watching:', basePath); + return; + } + const disposable = Disposable.create(async () => { + this.watcherOptions.delete(watcherId); + this.watchers.delete(watcherId); + if (watcher) { + this.debug('Stopping watching:', basePath); + await watcher.stop(); + // remove a reference to nsfw otherwise GC cannot collect it + watcher = undefined; + this.options.info('Stopped watching:', basePath); + } }); this.watcherOptions.set(watcherId, { ignored: options.ignored.map(pattern => new Minimatch(pattern)) @@ -148,7 +162,10 @@ export class NsfwFileSystemWatcherServer implements FileSystemWatcherServer { return Promise.resolve(); } - setClient(client: FileSystemWatcherClient) { + setClient(client: FileSystemWatcherClient | undefined) { + if (client && this.toDispose.disposed) { + return; + } this.client = client; } From c548bfc4e12653ec2419d2ec1d845238157c51af Mon Sep 17 00:00:00 2001 From: Simon Marchi Date: Wed, 14 Nov 2018 10:22:37 -0500 Subject: [PATCH 22/49] Allow tracing of debug adapter communication Introduce the "debug.trace" preference. When true, log the communication between Theia and debug adapters to the output view. I chose not to delete the output channels when the debug sessions close. The reason being that if you are interested in what events led to the end of a debug session, deleting the channel would make the interesting output unavailable. This seems reasonnable to me because: - this feature is only meant to be used when debugging debug adapters and debug backends - the output is only stored in the frontend and wiped on reload Change-Id: I0f2a2557fa1ec0c61a21e7af3209c433bd9be755 Signed-off-by: Simon Marchi --- packages/debug/package.json | 1 + .../src/browser/debug-frontend-module.ts | 3 ++ .../debug/src/browser/debug-preferences.ts | 49 +++++++++++++++++++ .../src/browser/debug-session-connection.ts | 14 +++++- .../src/browser/debug-session-contribution.ts | 19 ++++++- packages/debug/src/browser/debug-session.tsx | 6 ++- 6 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 packages/debug/src/browser/debug-preferences.ts diff --git a/packages/debug/package.json b/packages/debug/package.json index c56ac258b2d4c..18274dfb1acc6 100644 --- a/packages/debug/package.json +++ b/packages/debug/package.json @@ -10,6 +10,7 @@ "@theia/markers": "^0.3.16", "@theia/monaco": "^0.3.16", "@theia/process": "^0.3.16", + "@theia/output": "^0.3.16", "@theia/terminal": "^0.3.16", "@theia/variable-resolver": "^0.3.16", "@theia/workspace": "^0.3.16", diff --git a/packages/debug/src/browser/debug-frontend-module.ts b/packages/debug/src/browser/debug-frontend-module.ts index 37bbf2999fbc6..91bce20b863d6 100644 --- a/packages/debug/src/browser/debug-frontend-module.ts +++ b/packages/debug/src/browser/debug-frontend-module.ts @@ -34,6 +34,7 @@ import { DebugSessionWidget, DebugSessionWidgetFactory } from './view/debug-sess import { InDebugModeContext } from './debug-keybinding-contexts'; import { DebugEditorModelFactory, DebugEditorModel } from './editor/debug-editor-model'; import './debug-monaco-contribution'; +import { bindDebugPreferences } from './debug-preferences'; export default new ContainerModule((bind: interfaces.Bind) => { bindContributionProvider(bind, DebugSessionContribution); @@ -64,4 +65,6 @@ export default new ContainerModule((bind: interfaces.Bind) => { bind(KeybindingContext).to(InDebugModeContext).inSingletonScope(); bindViewContribution(bind, DebugFrontendApplicationContribution); bind(FrontendApplicationContribution).toService(DebugFrontendApplicationContribution); + + bindDebugPreferences(bind); }); diff --git a/packages/debug/src/browser/debug-preferences.ts b/packages/debug/src/browser/debug-preferences.ts new file mode 100644 index 0000000000000..951774d6011fa --- /dev/null +++ b/packages/debug/src/browser/debug-preferences.ts @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { PreferenceSchema, PreferenceProxy, PreferenceService, createPreferenceProxy, PreferenceContribution } from '@theia/core/lib/browser/preferences'; +import { interfaces } from 'inversify'; + +export const debugPreferencesSchema: PreferenceSchema = { + type: 'object', + properties: { + 'debug.trace': { + type: 'boolean', + default: false, + description: 'Enable/disable tracing communications with debug adapters' + } + } +}; + +export class DebugConfiguration { + 'debug.trace': boolean; +} + +export const DebugPreferences = Symbol('DebugPreferences'); +export type DebugPreferences = PreferenceProxy; + +export function createDebugreferences(preferences: PreferenceService): DebugPreferences { + return createPreferenceProxy(preferences, debugPreferencesSchema); +} + +export function bindDebugPreferences(bind: interfaces.Bind): void { + bind(DebugPreferences).toDynamicValue(ctx => { + const preferences = ctx.container.get(PreferenceService); + return createDebugreferences(preferences); + }).inSingletonScope(); + + bind(PreferenceContribution).toConstantValue({ schema: debugPreferencesSchema }); +} diff --git a/packages/debug/src/browser/debug-session-connection.ts b/packages/debug/src/browser/debug-session-connection.ts index 2ec0d84204543..257776d7b3e85 100644 --- a/packages/debug/src/browser/debug-session-connection.ts +++ b/packages/debug/src/browser/debug-session-connection.ts @@ -22,6 +22,7 @@ import { Deferred } from '@theia/core/lib/common/promise-util'; import { Emitter, Event, DisposableCollection, Disposable } from '@theia/core'; import { WebSocketChannel } from '@theia/core/lib/common/messaging/web-socket-channel'; import { DebugAdapterPath } from '../common/debug-service'; +import { OutputChannel } from '@theia/output/lib/common/output-channel'; export interface DebugExitEvent { code?: number @@ -102,7 +103,8 @@ export class DebugSessionConnection implements Disposable { constructor( readonly sessionId: string, - protected readonly connectionProvider: WebSocketConnectionProvider + protected readonly connectionProvider: WebSocketConnectionProvider, + protected readonly traceOutputChannel: OutputChannel | undefined ) { this.connection = this.createConnection(); } @@ -181,10 +183,18 @@ export class DebugSessionConnection implements Disposable { protected async send(message: DebugProtocol.ProtocolMessage): Promise { const connection = await this.connection; - connection.send(JSON.stringify(message)); + const messageStr = JSON.stringify(message); + if (this.traceOutputChannel) { + this.traceOutputChannel.appendLine(`${this.sessionId.substring(0, 8)} theia -> adapter: ${messageStr}`); + } + connection.send(messageStr); } protected handleMessage(data: string) { + if (this.traceOutputChannel) { + this.traceOutputChannel.append(`${this.sessionId.substring(0, 8)} theia <- adapter: ${data}`); + this.traceOutputChannel.appendLine(data); + } const message: DebugProtocol.ProtocolMessage = JSON.parse(data); if (message.type === 'request') { this.handleRequest(message as DebugProtocol.Request); diff --git a/packages/debug/src/browser/debug-session-contribution.ts b/packages/debug/src/browser/debug-session-contribution.ts index abf5e61ff86cb..9ddde5c04ef3b 100644 --- a/packages/debug/src/browser/debug-session-contribution.ts +++ b/packages/debug/src/browser/debug-session-contribution.ts @@ -23,6 +23,8 @@ import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/w import { DebugSession } from './debug-session'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; import { DebugSessionOptions } from './debug-session-options'; +import { OutputChannelManager, OutputChannel } from '@theia/output/lib/common/output-channel'; +import { DebugPreferences } from './debug-preferences'; /** * DebugSessionContribution symbol for DI. @@ -76,7 +78,21 @@ export class DefaultDebugSessionFactory implements DebugSessionFactory { @inject(MessageClient) protected readonly messages: MessageClient; + @inject(OutputChannelManager) + protected readonly outputChannelManager: OutputChannelManager; + + @inject(DebugPreferences) + protected readonly debugPreferences: DebugPreferences; + + protected traceOutputChannel: OutputChannel | undefined; + get(sessionId: string, options: DebugSessionOptions): DebugSession { + let traceOutputChannel: OutputChannel | undefined; + + if (this.debugPreferences['debug.trace']) { + traceOutputChannel = this.outputChannelManager.getChannel('Debug adapters'); + } + return new DebugSession( sessionId, options, @@ -85,7 +101,8 @@ export class DefaultDebugSessionFactory implements DebugSessionFactory { this.editorManager, this.breakpoints, this.labelProvider, - this.messages + this.messages, + traceOutputChannel, ); } } diff --git a/packages/debug/src/browser/debug-session.tsx b/packages/debug/src/browser/debug-session.tsx index 9ef1ca4e5978b..4a51ce2eba8ae 100644 --- a/packages/debug/src/browser/debug-session.tsx +++ b/packages/debug/src/browser/debug-session.tsx @@ -34,6 +34,7 @@ import URI from '@theia/core/lib/common/uri'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; import { DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; import { DebugConfiguration } from '../common/debug-common'; +import { OutputChannel } from '@theia/output/lib/common/output-channel'; export enum DebugState { Inactive, @@ -69,9 +70,10 @@ export class DebugSession implements CompositeTreeElement { protected readonly editorManager: EditorManager, protected readonly breakpoints: BreakpointManager, protected readonly labelProvider: LabelProvider, - protected readonly messages: MessageClient + protected readonly messages: MessageClient, + protected readonly traceOutputChannel: OutputChannel | undefined, ) { - this.connection = new DebugSessionConnection(id, connectionProvider); + this.connection = new DebugSessionConnection(id, connectionProvider, traceOutputChannel); this.connection.onRequest('runInTerminal', (request: DebugProtocol.RunInTerminalRequest) => this.runInTerminal(request)); this.toDispose.pushAll([ this.onDidChangeEmitter, From 75f3bc0a42b7e46069ccec04fe4ccc7d1d754e1f Mon Sep 17 00:00:00 2001 From: Rob Moran Date: Thu, 15 Nov 2018 13:43:59 +0000 Subject: [PATCH 23/49] Ensure css is swapped only for core themes Signed-off-by: Rob Moran --- .../src/browser/debug-frontend-application-contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/debug/src/browser/debug-frontend-application-contribution.ts b/packages/debug/src/browser/debug-frontend-application-contribution.ts index e8bca2aec219f..45334e137f418 100644 --- a/packages/debug/src/browser/debug-frontend-application-contribution.ts +++ b/packages/debug/src/browser/debug-frontend-application-contribution.ts @@ -256,7 +256,7 @@ function updateTheme(): void { if (theme === 'dark') { lightCss.unuse(); darkCss.use(); - } else { + } else if (theme === 'light') { darkCss.unuse(); lightCss.use(); } From 07a449af4a85e8505a4269d3acca5074d9b7c86f Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Fri, 16 Nov 2018 12:58:08 -0500 Subject: [PATCH 24/49] Fix 'Find Command' command label - Fixed `Find Command` label to align with the remainder of Theia Signed-off-by: Vincent Fugnitto --- .../core/src/browser/quick-open/quick-command-contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/browser/quick-open/quick-command-contribution.ts b/packages/core/src/browser/quick-open/quick-command-contribution.ts index a88edf1d9ffb9..f26e61b0c3a61 100644 --- a/packages/core/src/browser/quick-open/quick-command-contribution.ts +++ b/packages/core/src/browser/quick-open/quick-command-contribution.ts @@ -22,7 +22,7 @@ import { CommonMenus } from '../common-frontend-contribution'; export const quickCommand: Command = { id: 'quickCommand', - label: 'Find Command ...' + label: 'Find Command...' }; @injectable() From b2c0c494d04ef8da04475bd1719456aee4172338 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Fri, 9 Nov 2018 13:39:31 +0000 Subject: [PATCH 25/49] [tab-bar-toolbar] reference a command by an id Signed-off-by: Anton Kosyakov --- packages/core/src/browser/shell/tab-bar-toolbar.ts | 8 ++++---- packages/preview/src/browser/preview-contribution.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.ts b/packages/core/src/browser/shell/tab-bar-toolbar.ts index 3f7bc97ecdd7e..64bec345db06b 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar.ts @@ -20,7 +20,7 @@ import { LabelParser, LabelIcon } from '../label-parser'; import { ContributionProvider } from '../../common/contribution-provider'; import { FrontendApplicationContribution } from '../frontend-application'; import { Disposable, DisposableCollection } from '../../common/disposable'; -import { Command, CommandRegistry, CommandService } from '../../common/command'; +import { CommandRegistry, CommandService } from '../../common/command'; /** * Factory for instantiating tab-bar toolbars. @@ -71,7 +71,7 @@ export class TabBarToolbar extends BaseWidget { itemContainer.classList.add(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM); for (const labelPart of this.labelParser.parse(item.text)) { const child = document.createElement('div'); - const listener = () => this.commandService.executeCommand(item.command.id); + const listener = () => this.commandService.executeCommand(item.command); child.addEventListener('click', listener); this.toDisposeOnUpdate.push(Disposable.create(() => itemContainer.removeEventListener('click', listener))); if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { @@ -148,7 +148,7 @@ export interface TabBarToolbarItem { /** * The command to execute. */ - readonly command: Command; + readonly command: string; /** * Text of the item. @@ -238,7 +238,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { */ visibleItems(widget: Widget): TabBarToolbarItem[] { return Array.from(this.items.values()) - .filter(item => this.commandRegistry.isEnabled(item.id)) + .filter(item => this.commandRegistry.isEnabled(item.command)) .filter(item => item.isVisible(widget)); } diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts index 2484103dfaf00..8310fb1a7a479 100644 --- a/packages/preview/src/browser/preview-contribution.ts +++ b/packages/preview/src/browser/preview-contribution.ts @@ -222,7 +222,7 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler widget instanceof EditorWidget && this.canHandleEditorUri(), text: '$(columns)', tooltip: 'Open Preview to the Side' From d64bd498cd94164490cd322eac375195796f353d Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Fri, 9 Nov 2018 13:40:55 +0000 Subject: [PATCH 26/49] fix #3448: add open file/changes commands to the tab tool bar Signed-off-by: Anton Kosyakov --- .../git/src/browser/diff/git-diff-widget.tsx | 2 +- .../git/src/browser/git-frontend-module.ts | 2 + .../git/src/browser/git-view-contribution.ts | 64 +++++++++++++++---- packages/git/src/browser/git-widget.tsx | 2 +- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/git/src/browser/diff/git-diff-widget.tsx b/packages/git/src/browser/diff/git-diff-widget.tsx index ca4d18e821729..ebea88e5db55a 100644 --- a/packages/git/src/browser/diff/git-diff-widget.tsx +++ b/packages/git/src/browser/diff/git-diff-widget.tsx @@ -337,7 +337,7 @@ export class GitDiffWidget extends GitNavigableListWidget imp } } - protected getUriToOpen(change: GitFileChange): URI { + getUriToOpen(change: GitFileChange): URI { const uri: URI = new URI(change.uri); let fromURI = uri; diff --git a/packages/git/src/browser/git-frontend-module.ts b/packages/git/src/browser/git-frontend-module.ts index a387d6280b810..b64cb2bc1187f 100644 --- a/packages/git/src/browser/git-frontend-module.ts +++ b/packages/git/src/browser/git-frontend-module.ts @@ -17,6 +17,7 @@ import { ContainerModule } from 'inversify'; import { ResourceResolver } from '@theia/core/lib/common'; import { WebSocketConnectionProvider, WidgetFactory, bindViewContribution, LabelProviderContribution, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NavigatorTreeDecorator } from '@theia/navigator/lib/browser'; import { Git, GitPath, GitWatcher, GitWatcherPath, GitWatcherServer, GitWatcherServerProxy, ReconnectingGitWatcherServer } from '../common'; import { GitViewContribution, GIT_WIDGET_FACTORY_ID } from './git-view-contribution'; @@ -52,6 +53,7 @@ export default new ContainerModule(bind => { bindViewContribution(bind, GitViewContribution); bind(FrontendApplicationContribution).toService(GitViewContribution); + bind(TabBarToolbarContribution).toService(GitViewContribution); bind(GitWidget).toSelf(); bind(WidgetFactory).toDynamicValue(context => ({ diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index ad0b195689bb9..302182d4339b3 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -15,8 +15,12 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { DisposableCollection, CommandRegistry, MenuModelRegistry } from '@theia/core'; -import { AbstractViewContribution, StatusBar, StatusBarAlignment, DiffUris, StatusBarEntry, FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser'; +import { DisposableCollection, CommandRegistry, MenuModelRegistry, CommandContribution, MenuContribution, Command } from '@theia/core'; +import { + AbstractViewContribution, StatusBar, StatusBarAlignment, DiffUris, StatusBarEntry, + FrontendApplicationContribution, FrontendApplication, Widget +} from '@theia/core/lib/browser'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { EditorManager, EditorWidget, EditorOpenerOptions, EditorContextMenu, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; import { GitFileChange, GitFileStatus } from '../common'; import { GitWidget } from './git-widget'; @@ -63,13 +67,15 @@ export namespace GIT_COMMANDS { id: 'git.change.repository', label: 'Git: Change Repository...' }; - export const OPEN_FILE = { + export const OPEN_FILE: Command = { id: 'git.open.file', - label: 'Git: Open File' + category: 'Git', + label: 'Open File' }; - export const OPEN_CHANGES = { + export const OPEN_CHANGES: Command = { id: 'git.open.changes', - label: 'Git: Open Changes' + category: 'Git', + label: 'Open Changes' }; export const SYNC = { id: 'git.sync', @@ -82,7 +88,8 @@ export namespace GIT_COMMANDS { } @injectable() -export class GitViewContribution extends AbstractViewContribution implements FrontendApplicationContribution { +export class GitViewContribution extends AbstractViewContribution + implements FrontendApplicationContribution, CommandContribution, MenuContribution, TabBarToolbarContribution { static GIT_SELECTED_REPOSITORY = 'git-selected-repository'; static GIT_REPOSITORY_STATUS = 'git-repository-status'; @@ -267,6 +274,23 @@ export class GitViewContribution extends AbstractViewContribution imp }); } + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: GIT_COMMANDS.OPEN_FILE.id, + command: GIT_COMMANDS.OPEN_FILE.id, + text: '$(file-o)', + isVisible: widget => !!this.getOpenFileOptions(widget), + tooltip: GIT_COMMANDS.OPEN_FILE.label + }); + registry.registerItem({ + id: GIT_COMMANDS.OPEN_CHANGES.id, + command: GIT_COMMANDS.OPEN_CHANGES.id, + text: '$(files-o)', + isVisible: widget => !!this.getOpenChangesOptions(widget), + tooltip: GIT_COMMANDS.OPEN_CHANGES.label + }); + } + protected hasConflicts(changes: GitFileChange[]): boolean { return changes.some(c => c.status === GitFileStatus.Conflicted); } @@ -280,9 +304,11 @@ export class GitViewContribution extends AbstractViewContribution imp return options && this.editorManager.open(options.uri, options.options); } - protected get openFileOptions(): { uri: URI, options?: EditorOpenerOptions } | undefined { - const widget = this.editorManager.currentEditor; - if (widget && DiffUris.isDiffUri(widget.editor.uri)) { + protected get openFileOptions(): GitOpenFileOptions | undefined { + return this.getOpenFileOptions(this.editorManager.currentEditor); + } + protected getOpenFileOptions(widget: Widget | undefined): GitOpenFileOptions | undefined { + if (widget instanceof EditorWidget && DiffUris.isDiffUri(widget.editor.uri)) { const [, right] = DiffUris.decode(widget.editor.uri); const uri = right.withScheme('file'); const selection = widget.editor.selection; @@ -300,16 +326,18 @@ export class GitViewContribution extends AbstractViewContribution imp return undefined; } - protected get openChangesOptions(): { change: GitFileChange, options?: EditorOpenerOptions } | undefined { + protected get openChangesOptions(): GitOpenChangesOptions | undefined { + return this.getOpenChangesOptions(this.editorManager.currentEditor); + } + protected getOpenChangesOptions(widget: Widget | undefined): GitOpenChangesOptions | undefined { const view = this.tryGetWidget(); if (!view) { return undefined; } - const widget = this.editorManager.currentEditor; - if (widget && !DiffUris.isDiffUri(widget.editor.uri)) { + if (widget instanceof EditorWidget && !DiffUris.isDiffUri(widget.editor.uri)) { const uri = widget.editor.uri; const change = view.findChange(uri); - if (change) { + if (change && view.getUriToOpen(change).toString() !== uri.toString()) { const selection = widget.editor.selection; return { change, options: { selection } }; } @@ -359,3 +387,11 @@ export class GitViewContribution extends AbstractViewContribution imp }; } } +export interface GitOpenFileOptions { + readonly uri: URI + readonly options?: EditorOpenerOptions +} +export interface GitOpenChangesOptions { + readonly change: GitFileChange + readonly options?: EditorOpenerOptions +} diff --git a/packages/git/src/browser/git-widget.tsx b/packages/git/src/browser/git-widget.tsx index ce646400e93e7..1fe49488e0723 100644 --- a/packages/git/src/browser/git-widget.tsx +++ b/packages/git/src/browser/git-widget.tsx @@ -599,7 +599,7 @@ export class GitWidget extends GitDiffWidget implements StatefulWidget { handleOpenChange = async (change: GitFileChange, options?: EditorOpenerOptions) => this.openChange(change, options); - protected getUriToOpen(change: GitFileChange): URI { + getUriToOpen(change: GitFileChange): URI { const changeUri: URI = new URI(change.uri); if (change.status !== GitFileStatus.New) { if (change.staged) { From 9aef8994a1ffd2862e2e0ffd67fd21b28bb37e25 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 13 Nov 2018 08:37:11 +0000 Subject: [PATCH 27/49] [tab-bar-toolbar] fix #3453: better styling for multiple items Signed-off-by: Anton Kosyakov --- packages/core/src/browser/style/tabs.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index b61b80b42620b..36e2d155c5801 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -162,8 +162,8 @@ z-index: 1001; /* Due to the scrollbar (`z-index: 1000;`) it has a greater `z-index`. */ display: flex; flex-direction: row-reverse; - flex-grow: 1; padding: 4px; + padding-left: 0px; margin-right: 4px; } @@ -173,7 +173,7 @@ .p-TabBar-content-container { display: flex; - background: var(--theia-layout-color0); + flex: 1; } .p-TabBar-toolbar .item { From 5bcc8c327080ed430f3c46183bc76ab3e029d273 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 13 Nov 2018 15:36:31 +0000 Subject: [PATCH 28/49] [shell] make widget options optional Signed-off-by: Anton Kosyakov --- packages/core/src/browser/shell/application-shell.ts | 6 +++--- .../mini-browser/src/browser/mini-browser-open-handler.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 86ee2f1da84fb..1aef4f863291a 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -634,7 +634,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { * * Widgets added to the top area are not tracked regarding the _current_ and _active_ states. */ - addWidget(widget: Widget, options: ApplicationShell.WidgetOptions) { + addWidget(widget: Widget, options: Readonly = {}) { if (!widget.id) { console.error('Widgets added to the application shell must have a unique id property.'); return; @@ -656,7 +656,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { this.rightPanelHandler.addWidget(widget, options); break; default: - throw new Error('Illegal argument: ' + options.area); + this.mainPanel.addWidget(widget, options); } if (options.area !== 'top') { this.track(widget); @@ -1319,7 +1319,7 @@ export namespace ApplicationShell { /** * The area of the application shell where the widget will reside. */ - area: Area; + area?: Area; } /** diff --git a/packages/mini-browser/src/browser/mini-browser-open-handler.ts b/packages/mini-browser/src/browser/mini-browser-open-handler.ts index b521e09907f3c..f9b15684fe3da 100644 --- a/packages/mini-browser/src/browser/mini-browser-open-handler.ts +++ b/packages/mini-browser/src/browser/mini-browser-open-handler.ts @@ -81,8 +81,8 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple const mergedOptions = await this.options(uri, options); const widget = await this.widgetManager.getOrCreateWidget(MiniBrowser.Factory.ID, mergedOptions); await this.doOpen(widget, mergedOptions); - const { area } = mergedOptions.widgetOptions; - if (area !== 'main') { + const area = this.shell.getAreaFor(widget); + if (area && area !== 'main') { this.shell.resize(this.shell.mainPanel.node.offsetWidth / 2, area); } return widget; From 944ddac7e4754c1f21e6436cb9f0b1529014cda3 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 13 Nov 2018 15:57:06 +0000 Subject: [PATCH 29/49] make tab-bar toolbar commands aware of a tab-bar widget Signed-off-by: Anton Kosyakov --- .../core/src/browser/shell/tab-bar-toolbar.ts | 21 +++----- packages/core/src/browser/shell/tab-bars.ts | 53 ++++--------------- packages/core/src/browser/widgets/widget.ts | 2 + .../git/src/browser/git-view-contribution.ts | 52 ++++++++---------- .../src/browser/preview-contribution.ts | 39 +++++++------- 5 files changed, 63 insertions(+), 104 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.ts b/packages/core/src/browser/shell/tab-bar-toolbar.ts index 64bec345db06b..ca3020a8bd7bf 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar.ts @@ -35,7 +35,8 @@ export interface TabBarToolbarFactory { */ export class TabBarToolbar extends BaseWidget { - protected readonly items: Map = new Map(); + protected current: Widget | undefined; + protected readonly items = new Map(); protected readonly toDisposeOnUpdate: DisposableCollection = new DisposableCollection(); constructor(protected readonly commandService: CommandService, protected readonly labelParser: LabelParser) { @@ -45,14 +46,15 @@ export class TabBarToolbar extends BaseWidget { this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_HIDDEN); } - updateItems(...items: TabBarToolbarItem[]): void { + updateItems(items: TabBarToolbarItem[], current: Widget | undefined): void { + this.current = current; const copy = items.slice().sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse(); if (this.areSame(copy, Array.from(this.items.keys()))) { return; } this.toDisposeOnUpdate.dispose(); this.removeItems(); - this.createItems(...copy); + this.createItems(copy); } protected removeItems(): void { @@ -65,13 +67,13 @@ export class TabBarToolbar extends BaseWidget { this.items.clear(); } - protected createItems(...items: TabBarToolbarItem[]): void { + protected createItems(items: TabBarToolbarItem[]): void { for (const item of items) { const itemContainer = document.createElement('div'); itemContainer.classList.add(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM); for (const labelPart of this.labelParser.parse(item.text)) { const child = document.createElement('div'); - const listener = () => this.commandService.executeCommand(item.command); + const listener = () => this.commandService.executeCommand(item.command, this.current); child.addEventListener('click', listener); this.toDisposeOnUpdate.push(Disposable.create(() => itemContainer.removeEventListener('click', listener))); if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { @@ -170,11 +172,6 @@ export interface TabBarToolbarItem { */ readonly text: string; - /** - * Function that evaluates to `true` if the toolbar item is visible for the given widget. Otherwise, `false`. - */ - readonly isVisible: (widget: Widget) => boolean; - /** * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. */ @@ -237,9 +234,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { * By default returns with all items where the command is enabled and `item.isVisible` is `true`. */ visibleItems(widget: Widget): TabBarToolbarItem[] { - return Array.from(this.items.values()) - .filter(item => this.commandRegistry.isEnabled(item.command)) - .filter(item => item.isVisible(widget)); + return [...this.items.values()].filter(item => this.commandRegistry.isVisible(item.command, widget)); } } diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 3c3656abcd5e1..38d4083aa2964 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -301,6 +301,7 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { constructor( protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, + // TODO: WidgetTracker is not needed anymore: remove? or make sure that noone can access it from the main container? protected readonly widgetTracker: WidgetTracker, protected readonly tabBarToolbarFactory: () => TabBarToolbar, protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options) { @@ -327,38 +328,7 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { return this.node.getElementsByClassName(ToolbarAwareTabBar.Styles.TAB_BAR_CONTENT_CONTAINER)[0] as HTMLElement; } - // -----------------------------------------------------------------------------------------------------+ - // Overridden to be able to update the toolbar when the tabs come and go. Does not change the behavior. | - // -----------------------------------------------------------------------------------------------------+ - - addTab(value: Title | Title.IOptions): Title { - const result = super.addTab(value); - this.updateToolbar(); - return result; - } - - insertTab(index: number, value: Title | Title.IOptions): Title { - const result = super.insertTab(index, value); - this.updateToolbar(); - return result; - } - - removeTab(title: Title): void { - super.removeTab(title); - this.updateToolbar(); - } - - removeTabAt(index: number): void { - super.removeTabAt(index); - this.updateToolbar(); - } - - // ----------------------+ - // End of customization. | - // ----------------------+ - protected onAfterAttach(msg: Message): void { - this.widgetTracker.currentChanged.connect(this.updateToolbar, this); if (this.toolbar) { if (this.toolbar.isAttached) { Widget.detach(this.toolbar); @@ -375,22 +345,21 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { if (this.toolbar && this.toolbar.isAttached) { Widget.detach(this.toolbar); } - this.widgetTracker.currentChanged.disconnect(this.updateToolbar, this); super.onBeforeDetach(msg); } + protected onUpdateRequest(msg: Message): void { + super.onUpdateRequest(msg); + this.updateToolbar(); + } protected updateToolbar(): void { - const { currentWidget } = this.widgetTracker; - if (this.toolbar && currentWidget) { - // If the active widget does not belong to the current tab-bar, do nothing. - if (this.titles.map(title => title.owner).some(owner => owner === currentWidget)) { - const items = this.tabBarToolbarRegistry.visibleItems(currentWidget); - this.toolbar.updateItems(...items); - } else { - // Otherwise, discard the state. - this.toolbar.updateItems(...[]); - } + if (!this.toolbar) { + return; } + const current = this.currentTitle; + const widget = current && current.owner || undefined; + const items = widget ? this.tabBarToolbarRegistry.visibleItems(widget) : []; + this.toolbar.updateItems(items, widget); } /** diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index 7cb4f20c9e350..5cc3a51948a44 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -219,6 +219,8 @@ export function addClipboardListener(element /** * Tracks the current and active widgets in the application. Also provides access to the currently active and current widgets. + * + * FIXME: remove it from Widget public API, this file should be about BaseWidget, not shell internals */ export const WidgetTracker = Symbol('WidgetTracker'); export interface WidgetTracker { diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 302182d4339b3..6f81413036915 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -241,14 +241,14 @@ export class GitViewContribution extends AbstractViewContribution isEnabled: () => this.hasMultipleRepositories() }); registry.registerCommand(GIT_COMMANDS.OPEN_FILE, { - execute: () => this.openFile(), - isEnabled: () => !!this.openFileOptions, - isVisible: () => !!this.openFileOptions + execute: widget => this.openFile(widget), + isEnabled: widget => !!this.getOpenFileOptions(widget), + isVisible: widget => !!this.getOpenFileOptions(widget) }); registry.registerCommand(GIT_COMMANDS.OPEN_CHANGES, { - execute: () => this.openChanges(), - isEnabled: () => !!this.openChangesOptions, - isVisible: () => !!this.openChangesOptions + execute: widget => this.openChanges(widget), + isEnabled: widget => !!this.getOpenChangesOptions(widget), + isVisible: widget => !!this.getOpenChangesOptions(widget) }); registry.registerCommand(GIT_COMMANDS.SYNC, { execute: () => this.syncService.sync(), @@ -279,14 +279,12 @@ export class GitViewContribution extends AbstractViewContribution id: GIT_COMMANDS.OPEN_FILE.id, command: GIT_COMMANDS.OPEN_FILE.id, text: '$(file-o)', - isVisible: widget => !!this.getOpenFileOptions(widget), tooltip: GIT_COMMANDS.OPEN_FILE.label }); registry.registerItem({ id: GIT_COMMANDS.OPEN_CHANGES.id, command: GIT_COMMANDS.OPEN_CHANGES.id, text: '$(files-o)', - isVisible: widget => !!this.getOpenChangesOptions(widget), tooltip: GIT_COMMANDS.OPEN_CHANGES.label }); } @@ -299,47 +297,41 @@ export class GitViewContribution extends AbstractViewContribution return !changes.some(c => !c.staged); } - protected async openFile(): Promise { - const options = this.openFileOptions; + protected async openFile(widget?: Widget): Promise { + const options = this.getOpenFileOptions(widget); return options && this.editorManager.open(options.uri, options.options); } - - protected get openFileOptions(): GitOpenFileOptions | undefined { - return this.getOpenFileOptions(this.editorManager.currentEditor); - } - protected getOpenFileOptions(widget: Widget | undefined): GitOpenFileOptions | undefined { - if (widget instanceof EditorWidget && DiffUris.isDiffUri(widget.editor.uri)) { - const [, right] = DiffUris.decode(widget.editor.uri); + protected getOpenFileOptions(widget?: Widget): GitOpenFileOptions | undefined { + const ref = widget ? widget : this.editorManager.currentEditor; + if (ref instanceof EditorWidget && DiffUris.isDiffUri(ref.editor.uri)) { + const [, right] = DiffUris.decode(ref.editor.uri); const uri = right.withScheme('file'); - const selection = widget.editor.selection; - return { uri, options: { selection } }; + const selection = ref.editor.selection; + return { uri, options: { selection, widgetOptions: { ref } } }; } return undefined; } - async openChanges(): Promise { - const options = this.openChangesOptions; + async openChanges(widget?: Widget): Promise { + const options = this.getOpenChangesOptions(widget); if (options) { const view = await this.widget; return view.openChange(options.change, options.options); } return undefined; } - - protected get openChangesOptions(): GitOpenChangesOptions | undefined { - return this.getOpenChangesOptions(this.editorManager.currentEditor); - } - protected getOpenChangesOptions(widget: Widget | undefined): GitOpenChangesOptions | undefined { + protected getOpenChangesOptions(widget?: Widget): GitOpenChangesOptions | undefined { const view = this.tryGetWidget(); if (!view) { return undefined; } - if (widget instanceof EditorWidget && !DiffUris.isDiffUri(widget.editor.uri)) { - const uri = widget.editor.uri; + const ref = widget ? widget : this.editorManager.currentEditor; + if (ref instanceof EditorWidget && !DiffUris.isDiffUri(ref.editor.uri)) { + const uri = ref.editor.uri; const change = view.findChange(uri); if (change && view.getUriToOpen(change).toString() !== uri.toString()) { - const selection = widget.editor.selection; - return { change, options: { selection } }; + const selection = ref.editor.selection; + return { change, options: { selection, widgetOptions: { ref } } }; } } return undefined; diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts index 8310fb1a7a479..46383ff1c05b5 100644 --- a/packages/preview/src/browser/preview-contribution.ts +++ b/packages/preview/src/browser/preview-contribution.ts @@ -18,7 +18,7 @@ import { injectable, inject } from 'inversify'; import { Widget } from '@phosphor/widgets'; import { FrontendApplicationContribution, WidgetOpenerOptions, NavigatableWidgetOpenHandler } from '@theia/core/lib/browser'; import { EditorManager, TextEditor, EditorWidget, EditorContextMenu } from '@theia/editor/lib/browser'; -import { DisposableCollection, CommandContribution, CommandRegistry, Command, MenuContribution, MenuModelRegistry, CommandHandler, Disposable } from '@theia/core/lib/common'; +import { DisposableCollection, CommandContribution, CommandRegistry, Command, MenuContribution, MenuModelRegistry, Disposable } from '@theia/core/lib/common'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import URI from '@theia/core/lib/common/uri'; import { Position } from 'vscode-languageserver-types'; @@ -194,6 +194,7 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler 1 && defaultTabBar) { + // FIXME: what if this.shell.currentTabBar does not belong to mainTabBars? const currentTabBar = this.shell.currentTabBar || defaultTabBar; const currentIndex = currentTabBar.currentIndex; const currentTitle = currentTabBar.titles[currentIndex]; @@ -205,10 +206,10 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler{ - execute: () => this.openForEditor(), - isEnabled: () => this.canHandleEditorUri(), - isVisible: () => this.canHandleEditorUri(), + registry.registerCommand(PreviewCommands.OPEN, { + execute: widget => this.openForEditor(widget), + isEnabled: widget => this.canHandleEditorUri(widget), + isVisible: widget => this.canHandleEditorUri(widget), }); } @@ -223,32 +224,32 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler widget instanceof EditorWidget && this.canHandleEditorUri(), text: '$(columns)', tooltip: 'Open Preview to the Side' }); } - protected canHandleEditorUri(): boolean { - const uri = this.getCurrentEditorUri(); + protected canHandleEditorUri(widget?: Widget): boolean { + const uri = this.getCurrentEditorUri(widget); return !!uri && this.previewHandlerProvider.canHandle(uri); } - protected getCurrentEditorUri(): URI | undefined { - const current = this.editorManager.currentEditor; + protected getCurrentEditorUri(widget?: Widget): URI | undefined { + const current = this.getCurrentEditor(widget); return current && current.editor.uri; } + protected getCurrentEditor(widget?: Widget): EditorWidget | undefined { + const current = widget ? widget : this.editorManager.currentEditor; + return current instanceof EditorWidget && current || undefined; + } - protected async openForEditor(): Promise { - const uri = this.getCurrentEditorUri(); - if (!uri) { + protected async openForEditor(widget?: Widget): Promise { + const ref = this.getCurrentEditor(widget); + if (!ref) { return; } - const ref = this.findWidgetInMainAreaToAddAfter(); - await this.open(uri, { - ... this.defaultOpenFromEditorOptions, - widgetOptions: ref ? - { area: 'main', mode: 'tab-after', ref } : - { area: 'main', mode: 'split-right' } + await this.open(ref.editor.uri, { + mode: 'reveal', + widgetOptions: { ref, mode: 'split-right' } }); } From 5578db880f09e07e77d8073fa247018174d3f633 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 14 Nov 2018 11:24:00 +0000 Subject: [PATCH 30/49] [shell] open to the side insertion mode Signed-off-by: Anton Kosyakov --- .../src/browser/shell/application-shell.ts | 108 ++++++++++++++--- .../src/browser/shell/side-panel-handler.ts | 41 +------ .../src/browser/shell/theia-dock-panel.ts | 112 ++++++++++++++++++ .../src/browser/preview-contribution.ts | 56 ++------- 4 files changed, 213 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/browser/shell/theia-dock-panel.ts diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 1aef4f863291a..ac64732c12d8a 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -26,7 +26,8 @@ import { IDragEvent } from '@phosphor/dragdrop'; import { RecursivePartial, MaybePromise } from '../../common'; import { Saveable } from '../saveable'; import { StatusBarImpl, StatusBarEntry, StatusBarAlignment } from '../status-bar/status-bar'; -import { SidePanelHandler, SidePanel, SidePanelHandlerFactory, TheiaDockPanel } from './side-panel-handler'; +import { TheiaDockPanel } from './theia-dock-panel'; +import { SidePanelHandler, SidePanel, SidePanelHandlerFactory } from './side-panel-handler'; import { TabBarRendererFactory, TabBarRenderer, SHELL_TABBAR_CONTEXT_MENU, ScrollableTabBar, ToolbarAwareTabBar } from './tab-bars'; import { SplitPositionHandler, SplitPositionOptions } from './split-panels'; import { FrontendApplicationStateService } from '../frontend-application-state'; @@ -117,13 +118,13 @@ export class ApplicationShell extends Widget implements WidgetTracker { /** * The dock panel in the main shell area. This is where editors usually go to. */ - readonly mainPanel: DockPanel; + readonly mainPanel: TheiaDockPanel; /** * The dock panel in the bottom shell area. In contrast to the main panel, the bottom panel * can be collapsed and expanded. */ - readonly bottomPanel: DockPanel; + readonly bottomPanel: TheiaDockPanel; /** * Handler for the left side panel. The primary application views go here, such as the @@ -362,7 +363,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { /** * Create the dock panel in the main shell area. */ - protected createMainPanel(): DockPanel { + protected createMainPanel(): TheiaDockPanel { const renderer = this.dockPanelRendererFactory(this); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(MAIN_AREA_CLASS); @@ -378,7 +379,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { /** * Create the dock panel in the bottom shell area. */ - protected createBottomPanel(): DockPanel { + protected createBottomPanel(): TheiaDockPanel { const renderer = this.dockPanelRendererFactory(this); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(BOTTOM_AREA_CLASS); @@ -639,26 +640,51 @@ export class ApplicationShell extends Widget implements WidgetTracker { console.error('Widgets added to the application shell must have a unique id property.'); return; } - switch (options.area) { + let ref: Widget | undefined = options.ref; + let area: ApplicationShell.Area = options.area || 'main'; + if (!ref && (area === 'main' || area === 'bottom')) { + const tabBar = this.getTabBarFor(area); + ref = tabBar && tabBar.currentTitle && tabBar.currentTitle.owner || undefined; + } + // make sure that ref belongs to area + area = ref && this.getAreaFor(ref) || area; + const addOptions: DockPanel.IAddOptions = {}; + if (ApplicationShell.isOpenToSideMode(options.mode)) { + const areaPanel = area === 'main' ? this.mainPanel : area === 'bottom' ? this.bottomPanel : undefined; + const sideRef = areaPanel && ref && (options.mode === 'open-to-left' ? + areaPanel.previousTabBarWidget(ref) : + areaPanel.nextTabBarWidget(ref)); + if (sideRef) { + addOptions.ref = sideRef; + } else { + addOptions.ref = ref; + addOptions.mode = options.mode === 'open-to-left' ? 'split-left' : 'split-right'; + } + } else { + addOptions.ref = ref; + addOptions.mode = options.mode; + } + const sidePanelOptions: SidePanel.WidgetOptions = { rank: options.rank }; + switch (area) { case 'main': - this.mainPanel.addWidget(widget, options); + this.mainPanel.addWidget(widget, addOptions); break; case 'top': this.topPanel.addWidget(widget); break; case 'bottom': - this.bottomPanel.addWidget(widget, options); + this.bottomPanel.addWidget(widget, addOptions); break; case 'left': - this.leftPanelHandler.addWidget(widget, options); + this.leftPanelHandler.addWidget(widget, sidePanelOptions); break; case 'right': - this.rightPanelHandler.addWidget(widget, options); + this.rightPanelHandler.addWidget(widget, sidePanelOptions); break; default: - this.mainPanel.addWidget(widget, options); + throw new Error('Unexpected area: ' + options.area); } - if (options.area !== 'top') { + if (area !== 'top') { this.track(widget); } } @@ -733,6 +759,10 @@ export class ApplicationShell extends Widget implements WidgetTracker { tabBar.revealTab(index); } } + const panel = this.getAreaPanelFor(newValue); + if (panel instanceof TheiaDockPanel) { + panel.markAsCurrent(newValue.title); + } // Set the z-index so elements with `position: fixed` contained in the active widget are displayed correctly this.setZIndex(newValue.node, '1'); } @@ -1048,11 +1078,11 @@ export class ApplicationShell extends Widget implements WidgetTracker { */ getAreaFor(widget: Widget): ApplicationShell.Area | undefined { const title = widget.title; - const mainPanelTabBar = find(this.mainPanel.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, title) > -1); + const mainPanelTabBar = this.mainPanel.findTabBar(title); if (mainPanelTabBar) { return 'main'; } - const bottomPanelTabBar = find(this.bottomPanel.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, title) > -1); + const bottomPanelTabBar = this.bottomPanel.findTabBar(title); if (bottomPanelTabBar) { return 'bottom'; } @@ -1062,6 +1092,25 @@ export class ApplicationShell extends Widget implements WidgetTracker { if (ArrayExt.firstIndexOf(this.rightPanelHandler.tabBar.titles, title) > -1) { return 'right'; } + return undefined; + } + protected getAreaPanelFor(widget: Widget): DockPanel | undefined { + const title = widget.title; + const mainPanelTabBar = this.mainPanel.findTabBar(title); + if (mainPanelTabBar) { + return this.mainPanel; + } + const bottomPanelTabBar = this.bottomPanel.findTabBar(title); + if (bottomPanelTabBar) { + return this.bottomPanel; + } + if (ArrayExt.firstIndexOf(this.leftPanelHandler.tabBar.titles, title) > -1) { + return this.leftPanelHandler.dockPanel; + } + if (ArrayExt.firstIndexOf(this.rightPanelHandler.tabBar.titles, title) > -1) { + return this.rightPanelHandler.dockPanel; + } + return undefined; } /** @@ -1081,9 +1130,9 @@ export class ApplicationShell extends Widget implements WidgetTracker { if (typeof widgetOrArea === 'string') { switch (widgetOrArea) { case 'main': - return this.mainPanel.tabBars().next(); + return this.mainPanel.currentTabBar; case 'bottom': - return this.bottomPanel.tabBars().next(); + return this.bottomPanel.currentTabBar; case 'left': return this.leftPanelHandler.tabBar; case 'right': @@ -1093,11 +1142,11 @@ export class ApplicationShell extends Widget implements WidgetTracker { } } else if (widgetOrArea && widgetOrArea.isAttached) { const widgetTitle = widgetOrArea.title; - const mainPanelTabBar = find(this.mainPanel.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, widgetTitle) > -1); + const mainPanelTabBar = this.mainPanel.findTabBar(widgetTitle); if (mainPanelTabBar) { return mainPanelTabBar; } - const bottomPanelTabBar = find(this.bottomPanel.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, widgetTitle) > -1); + const bottomPanelTabBar = this.bottomPanel.findTabBar(widgetTitle); if (bottomPanelTabBar) { return bottomPanelTabBar; } @@ -1312,14 +1361,35 @@ export namespace ApplicationShell { }) }); + /** + * Whether a widget should be opened to the side tab bar relatively to the reference widget. + */ + export type OpenToSideMode = 'open-to-left' | 'open-to-right'; + // tslint:disable-next-line:no-any + export function isOpenToSideMode(mode: OpenToSideMode | any): mode is OpenToSideMode { + return mode === 'open-to-left' || mode === 'open-to-right'; + } + /** * Options for adding a widget to the application shell. */ - export interface WidgetOptions extends DockLayout.IAddOptions, SidePanel.WidgetOptions { + export interface WidgetOptions extends SidePanel.WidgetOptions { /** * The area of the application shell where the widget will reside. */ area?: Area; + /** + * The insertion mode for adding the widget. + * + * The default is `'tab-after'`. + */ + mode?: DockLayout.InsertMode | OpenToSideMode + /** + * The reference widget for the insert location. + * + * The default is `undefined`. + */ + ref?: Widget; } /** diff --git a/packages/core/src/browser/shell/side-panel-handler.ts b/packages/core/src/browser/shell/side-panel-handler.ts index 554bc4d9edaae..29bd26ad3530a 100644 --- a/packages/core/src/browser/shell/side-panel-handler.ts +++ b/packages/core/src/browser/shell/side-panel-handler.ts @@ -17,13 +17,13 @@ import { injectable, inject } from 'inversify'; import { find, map, toArray, some } from '@phosphor/algorithm'; import { TabBar, Widget, DockPanel, Title, Panel, BoxPanel, BoxLayout, SplitPanel } from '@phosphor/widgets'; -import { Signal } from '@phosphor/signaling'; import { MimeData } from '@phosphor/coreutils'; import { Drag } from '@phosphor/dragdrop'; import { AttachedProperty } from '@phosphor/properties'; import { TabBarRendererFactory, TabBarRenderer, SHELL_TABBAR_CONTEXT_MENU, SideTabBar } from './tab-bars'; import { SplitPositionHandler, SplitPositionOptions } from './split-panels'; import { FrontendApplicationStateService } from '../frontend-application-state'; +import { TheiaDockPanel } from './theia-dock-panel'; /** The class name added to the left and right area panels. */ export const LEFT_RIGHT_AREA_CLASS = 'theia-app-sides'; @@ -608,42 +608,3 @@ export namespace SidePanel { collapsing = 'collapsing' } } - -/** - * This specialization of DockPanel adds various events that are used for implementing the - * side panels of the application shell. - */ -export class TheiaDockPanel extends DockPanel { - - /** - * Emitted when a widget is added to the panel. - */ - readonly widgetAdded = new Signal(this); - /** - * Emitted when a widget is activated by calling `activateWidget`. - */ - readonly widgetActivated = new Signal(this); - /** - * Emitted when a widget is removed from the panel. - */ - readonly widgetRemoved = new Signal(this); - - addWidget(widget: Widget, options?: DockPanel.IAddOptions): void { - if (this.mode === 'single-document' && widget.parent === this) { - return; - } - super.addWidget(widget, options); - this.widgetAdded.emit(widget); - } - - activateWidget(widget: Widget): void { - super.activateWidget(widget); - this.widgetActivated.emit(widget); - } - - protected onChildRemoved(msg: Widget.ChildMessage): void { - super.onChildRemoved(msg); - this.widgetRemoved.emit(msg.child); - } - -} diff --git a/packages/core/src/browser/shell/theia-dock-panel.ts b/packages/core/src/browser/shell/theia-dock-panel.ts new file mode 100644 index 0000000000000..c047f309220d2 --- /dev/null +++ b/packages/core/src/browser/shell/theia-dock-panel.ts @@ -0,0 +1,112 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { find, toArray, ArrayExt } from '@phosphor/algorithm'; +import { TabBar, Widget, DockPanel, Title } from '@phosphor/widgets'; +import { Signal } from '@phosphor/signaling'; + +/** + * This specialization of DockPanel adds various events that are used for implementing the + * side panels of the application shell. + */ +export class TheiaDockPanel extends DockPanel { + + /** + * Emitted when a widget is added to the panel. + */ + readonly widgetAdded = new Signal(this); + /** + * Emitted when a widget is activated by calling `activateWidget`. + */ + readonly widgetActivated = new Signal(this); + /** + * Emitted when a widget is removed from the panel. + */ + readonly widgetRemoved = new Signal(this); + + constructor(options?: DockPanel.IOptions) { + super(options); + this['_onCurrentChanged'] = (sender: TabBar, args: TabBar.ICurrentChangedArgs) => { + this.markAsCurrent(args.currentTitle || undefined); + super['_onCurrentChanged'](sender, args); + }; + this['_onTabActivateRequested'] = (sender: TabBar, args: TabBar.ITabActivateRequestedArgs) => { + this.markAsCurrent(args.title); + super['_onTabActivateRequested'](sender, args); + }; + } + + protected _currentTitle: Title | undefined; + get currentTitle(): Title | undefined { + return this._currentTitle; + } + get currentTabBar(): TabBar | undefined { + return this._currentTitle && this.findTabBar(this._currentTitle); + } + findTabBar(title: Title): TabBar | undefined { + return find(this.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, title) > -1); + } + markAsCurrent(title: Title | undefined): void { + this._currentTitle = title; + } + + addWidget(widget: Widget, options?: DockPanel.IAddOptions): void { + if (this.mode === 'single-document' && widget.parent === this) { + return; + } + super.addWidget(widget, options); + this.widgetAdded.emit(widget); + } + + activateWidget(widget: Widget): void { + super.activateWidget(widget); + this.widgetActivated.emit(widget); + } + + protected onChildRemoved(msg: Widget.ChildMessage): void { + super.onChildRemoved(msg); + this.widgetRemoved.emit(msg.child); + } + + nextTabBarWidget(widget: Widget): Widget | undefined { + const current = this.findTabBar(widget.title); + const next = current && this.nextTabBarInPanel(current); + return next && next.currentTitle && next.currentTitle.owner || undefined; + } + nextTabBarInPanel(tabBar: TabBar): TabBar | undefined { + const tabBars = toArray(this.tabBars()); + const index = tabBars.indexOf(tabBar); + if (index !== -1) { + return tabBars[index + 1]; + } + return undefined; + } + + previousTabBarWidget(widget: Widget): Widget | undefined { + const current = this.findTabBar(widget.title); + const previous = current && this.previousTabBarInPanel(current); + return previous && previous.currentTitle && previous.currentTitle.owner || undefined; + } + previousTabBarInPanel(tabBar: TabBar): TabBar | undefined { + const tabBars = toArray(this.tabBars()); + const index = tabBars.indexOf(tabBar); + if (index !== -1) { + return tabBars[index - 1]; + } + return undefined; + } + +} diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts index 46383ff1c05b5..138ffd88dc6a5 100644 --- a/packages/preview/src/browser/preview-contribution.ts +++ b/packages/preview/src/browser/preview-contribution.ts @@ -61,16 +61,6 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler(); - protected readonly defaultOpenFromEditorOptions: PreviewOpenerOptions = { - widgetOptions: { area: 'main', mode: 'split-right' }, - mode: 'reveal' - }; - - protected readonly defaultOpenOptions: PreviewOpenerOptions = { - widgetOptions: { area: 'main', mode: 'tab-after' }, - mode: 'activate' - }; - onStart() { this.onCreated(previewWidget => { this.registerOpenOnDoubleClick(previewWidget); @@ -132,19 +122,16 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler { - const ref = this.findWidgetInMainAreaToAddAfter(); + protected registerOpenOnDoubleClick(ref: PreviewWidget): void { + const disposable = ref.onDidDoubleClick(async location => { const { editor } = await this.editorManager.open(new URI(location.uri), { - widgetOptions: ref ? - { area: 'main', mode: 'tab-after', ref } : - { area: 'main', mode: 'split-left' } + widgetOptions: { ref, mode: 'open-to-left' } }); editor.revealPosition(location.range.start); editor.selection = location.range; - previewWidget.revealForSourceLine(location.range.start.line); + ref.revealForSourceLine(location.range.start.line); }); - previewWidget.disposed.connect(() => disposable.dispose()); + ref.disposed.connect(() => disposable.dispose()); } canHandle(uri: URI): number { @@ -174,35 +161,14 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler { - if (!options) { - const ref = this.findWidgetInMainAreaToAddAfter(); + const resolved: PreviewOpenerOptions = { mode: 'activate', ...options }; + if (resolved.originUri) { + const ref = await this.getWidget(resolved.originUri); if (ref) { - return { ...this.defaultOpenOptions, widgetOptions: { area: 'main', mode: 'tab-after', ref } }; - } - return this.defaultOpenOptions; - } - if (options.originUri) { - const ref = await this.getWidget(options.originUri); - if (ref) { - return { ...this.defaultOpenOptions, widgetOptions: { area: 'main', mode: 'tab-after', ref } }; - } - } - return { ...this.defaultOpenOptions, ...options }; - } - - protected findWidgetInMainAreaToAddAfter(): Widget | undefined { - const mainTabBars = this.shell.mainAreaTabBars; - const defaultTabBar = this.shell.getTabBarFor('main'); - if (mainTabBars.length > 1 && defaultTabBar) { - // FIXME: what if this.shell.currentTabBar does not belong to mainTabBars? - const currentTabBar = this.shell.currentTabBar || defaultTabBar; - const currentIndex = currentTabBar.currentIndex; - const currentTitle = currentTabBar.titles[currentIndex]; - if (currentTitle) { - return currentTitle.owner; + resolved.widgetOptions = { ...resolved.widgetOptions, ref }; } } - return undefined; + return resolved; } registerCommands(registry: CommandRegistry): void { @@ -249,7 +215,7 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler Date: Wed, 14 Nov 2018 11:46:45 +0000 Subject: [PATCH 31/49] [preview] open source toolbar item Signed-off-by: Anton Kosyakov --- .../src/browser/preview-contribution.ts | 31 ++++++++++++++++--- .../preview/src/browser/preview-widget.ts | 2 +- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts index 138ffd88dc6a5..bbd1adb49ca4e 100644 --- a/packages/preview/src/browser/preview-contribution.ts +++ b/packages/preview/src/browser/preview-contribution.ts @@ -37,6 +37,9 @@ export namespace PreviewCommands { export const OPEN: Command = { id: 'preview:open' }; + export const OPEN_SOURCE: Command = { + id: 'preview.open.source' + }; } export interface PreviewOpenerOptions extends WidgetOpenerOptions { @@ -124,9 +127,7 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler { - const { editor } = await this.editorManager.open(new URI(location.uri), { - widgetOptions: { ref, mode: 'open-to-left' } - }); + const { editor } = await this.openSource(ref); editor.revealPosition(location.range.start); editor.selection = location.range; ref.revealForSourceLine(location.range.start.line); @@ -175,7 +176,12 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler this.openForEditor(widget), isEnabled: widget => this.canHandleEditorUri(widget), - isVisible: widget => this.canHandleEditorUri(widget), + isVisible: widget => this.canHandleEditorUri(widget) + }); + registry.registerCommand(PreviewCommands.OPEN_SOURCE, { + execute: widget => this.openSource(widget), + isEnabled: widget => widget instanceof PreviewWidget, + isVisible: widget => widget instanceof PreviewWidget }); } @@ -190,9 +196,15 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler; + protected async openSource(ref?: Widget): Promise { + if (ref instanceof PreviewWidget) { + return this.editorManager.open(ref.uri, { + widgetOptions: { ref, mode: 'open-to-left' } + }); + } + } + } diff --git a/packages/preview/src/browser/preview-widget.ts b/packages/preview/src/browser/preview-widget.ts index b0b009d55ed89..c60c71ec8338a 100644 --- a/packages/preview/src/browser/preview-widget.ts +++ b/packages/preview/src/browser/preview-widget.ts @@ -41,7 +41,7 @@ export interface PreviewWidgetOptions { @injectable() export class PreviewWidget extends BaseWidget implements Navigatable { - protected readonly uri: URI; + readonly uri: URI; protected readonly resource: Resource; protected previewHandler: PreviewHandler | undefined; protected firstUpdate: (() => void) | undefined = undefined; From 4ba7b01c44fa0dde854bcfc699346982cc263051 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 14 Nov 2018 14:53:35 +0000 Subject: [PATCH 32/49] [mini-browser] one widget per a URI + aligning behaviour with the preview widget Signed-off-by: Anton Kosyakov --- .../src/browser/mini-browser-content.ts | 649 +++++++++++++++++ .../browser/mini-browser-frontend-module.ts | 24 +- .../src/browser/mini-browser-open-handler.ts | 127 +++- .../mini-browser/src/browser/mini-browser.ts | 669 ++---------------- .../mini-browser/src/browser/style/index.css | 11 +- .../src/node/mini-browser-endpoint.ts | 2 +- 6 files changed, 847 insertions(+), 635 deletions(-) create mode 100644 packages/mini-browser/src/browser/mini-browser-content.ts diff --git a/packages/mini-browser/src/browser/mini-browser-content.ts b/packages/mini-browser/src/browser/mini-browser-content.ts new file mode 100644 index 0000000000000..9bfb28546ea54 --- /dev/null +++ b/packages/mini-browser/src/browser/mini-browser-content.ts @@ -0,0 +1,649 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import * as PDFObject from 'pdfobject'; +import { inject, injectable, postConstruct } from 'inversify'; +import { Message } from '@phosphor/messaging'; +import { PanelLayout, SplitPanel } from '@phosphor/widgets'; +import URI from '@theia/core/lib/common/uri'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { Key, KeyCode } from '@theia/core/lib/browser/keys'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; +import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { FrontendApplicationContribution, ApplicationShell } from '@theia/core/lib/browser'; +import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; +import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; +import { BaseWidget, addEventListener, FocusTracker, Widget } from '@theia/core/lib/browser/widgets/widget'; +import { LocationMapperService } from './location-mapper-service'; + +import debounce = require('lodash.debounce'); + +/** + * Initializer properties for the embedded browser widget. + */ +@injectable() +export class MiniBrowserProps { + + /** + * `show` if the toolbar should be visible. If `read-only`, the toolbar is visible but the address cannot be changed and it acts as a link instead.\ + * `hide` if the toolbar should be hidden. `show` by default. If the `startPage` is not defined, this property is always `show`. + */ + readonly toolbar?: 'show' | 'hide' | 'read-only'; + + /** + * If defined, the browser will load this page on startup. Otherwise, it show a blank page. + */ + readonly startPage?: string; + + /** + * Sandbox options for the underlying `iframe`. Defaults to `SandboxOptions#DEFAULT` if not provided. + */ + readonly sandbox?: MiniBrowserProps.SandboxOptions[]; + + /** + * The optional icon class for the widget. + */ + readonly iconClass?: string; + + /** + * The desired name of the widget. + */ + readonly name?: string; + + /** + * `true` if the `iFrame`'s background has to be reset to the default white color. Otherwise, `false`. `false` is the default. + */ + readonly resetBackground?: boolean; + +} + +export namespace MiniBrowserProps { + + /** + * Enumeration of the supported `sandbox` options for the `iframe`. + */ + export enum SandboxOptions { + + /** + * Allows form submissions. + */ + 'allow-forms', + + /** + * Allows popups, such as `window.open()`, `showModalDialog()`, `target=”_blank”`, etc. + */ + 'allow-popups', + + /** + * Allows pointer lock. + */ + 'allow-pointer-lock', + + /** + * Allows the document to maintain its origin. Pages loaded from https://example.com/ will retain access to that origin’s data. + */ + 'allow-same-origin', + + /** + * Allows JavaScript execution. Also allows features to trigger automatically (as they’d be trivial to implement via JavaScript). + */ + 'allow-scripts', + + /** + * Allows the document to break out of the frame by navigating the top-level `window`. + */ + 'allow-top-navigation', + + /** + * Allows the embedded browsing context to open modal windows. + */ + 'allow-modals', + + /** + * Allows the embedded browsing context to disable the ability to lock the screen orientation. + */ + 'allow-orientation-lock', + + /** + * Allows a sandboxed document to open new windows without forcing the sandboxing flags upon them. + * This will allow, for example, a third-party advertisement to be safely sandboxed without forcing the same restrictions upon a landing page. + */ + 'allow-popups-to-escape-sandbox', + + /** + * Allows embedders to have control over whether an iframe can start a presentation session. + */ + 'allow-presentation', + + /** + * Allows the embedded browsing context to navigate (load) content to the top-level browsing context only when initiated by a user gesture. + * If this keyword is not used, this operation is not allowed. + */ + 'allow-top-navigation-by-user-activation' + } + + export namespace SandboxOptions { + + /** + * The default `sandbox` options, if other is not provided. + * + * See: https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ + */ + export const DEFAULT: SandboxOptions[] = [ + SandboxOptions['allow-same-origin'], + SandboxOptions['allow-scripts'], + SandboxOptions['allow-popups'], + SandboxOptions['allow-forms'], + SandboxOptions['allow-modals'] + ]; + + } + +} + +/** + * Contribution that tracks `mouseup` and `mousedown` events. + * + * This is required to be able to track the `TabBar`, `DockPanel`, and `SidePanel` resizing and drag and drop events correctly + * all over the application. By default, when the mouse is over an `iframe` we lose the mouse tracking ability, so whenever + * we click (`mousedown`), we overlay a transparent `div` over the `iframe` in the Mini Browser, then we set the `display` of + * the transparent `div` to `none` on `mouseup` events. + */ +@injectable() +export class MiniBrowserMouseClickTracker implements FrontendApplicationContribution { + + @inject(ApplicationShell) + protected readonly applicationShell: ApplicationShell; + + protected readonly toDispose = new DisposableCollection(); + protected readonly toDisposeOnActiveChange = new DisposableCollection(); + + protected readonly mouseupEmitter = new Emitter(); + protected readonly mousedownEmitter = new Emitter(); + protected readonly mouseupListener: (e: MouseEvent) => void = e => this.mouseupEmitter.fire(e); + protected readonly mousedownListener: (e: MouseEvent) => void = e => this.mousedownEmitter.fire(e); + + onStart(): void { + // Here we need to attach a `mousedown` listener to the `TabBar`s, `DockPanel`s and the `SidePanel`s. Otherwise, Phosphor handles the event and stops the propagation. + // Track the `mousedown` on the `TabBar` for the currently active widget. + this.applicationShell.activeChanged.connect((shell: ApplicationShell, args: FocusTracker.IChangedArgs) => { + this.toDisposeOnActiveChange.dispose(); + if (args.newValue) { + const tabBar = shell.getTabBarFor(args.newValue); + if (tabBar) { + this.toDisposeOnActiveChange.push(addEventListener(tabBar.node, 'mousedown', this.mousedownListener, true)); + } + } + }); + + // Track the `mousedown` events for the `SplitPanel`s, if any. + const { layout } = this.applicationShell; + if (layout instanceof PanelLayout) { + this.toDispose.pushAll( + layout.widgets.filter(MiniBrowserMouseClickTracker.isSplitPanel).map(splitPanel => addEventListener(splitPanel.node, 'mousedown', this.mousedownListener, true)) + ); + } + // Track the `mousedown` on each `DockPanel`. + const { mainPanel, bottomPanel, leftPanelHandler, rightPanelHandler } = this.applicationShell; + this.toDispose.pushAll([mainPanel, bottomPanel, leftPanelHandler.dockPanel, rightPanelHandler.dockPanel] + .map(panel => addEventListener(panel.node, 'mousedown', this.mousedownListener, true))); + + // The `mouseup` event has to be tracked on the `document`. Phosphor attaches to there. + document.addEventListener('mouseup', this.mouseupListener, true); + + // Make sure it is disposed in the end. + this.toDispose.pushAll([ + this.mousedownEmitter, + this.mouseupEmitter, + Disposable.create(() => document.removeEventListener('mouseup', this.mouseupListener, true)) + ]); + } + + onStop(): void { + this.toDispose.dispose(); + this.toDisposeOnActiveChange.dispose(); + } + + get onMouseup(): Event { + return this.mouseupEmitter.event; + } + + get onMousedown(): Event { + return this.mousedownEmitter.event; + } + +} + +export namespace MiniBrowserMouseClickTracker { + + export function isSplitPanel(arg: Widget): arg is SplitPanel { + return arg instanceof SplitPanel; + } + +} + +export const MiniBrowserContentFactory = Symbol('MiniBrowserContentFactory'); +export type MiniBrowserContentFactory = (props: MiniBrowserProps) => MiniBrowserContent; + +@injectable() +export class MiniBrowserContent extends BaseWidget { + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(WindowService) + protected readonly windowService: WindowService; + + @inject(LocationMapperService) + protected readonly locationMapper: LocationMapperService; + + @inject(KeybindingRegistry) + protected readonly keybindings: KeybindingRegistry; + + @inject(MiniBrowserMouseClickTracker) + protected readonly mouseTracker: MiniBrowserMouseClickTracker; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + @inject(FileSystemWatcher) + protected readonly fileSystemWatcher: FileSystemWatcher; + + protected readonly submitInputEmitter = new Emitter(); + protected readonly navigateBackEmitter = new Emitter(); + protected readonly navigateForwardEmitter = new Emitter(); + protected readonly refreshEmitter = new Emitter(); + protected readonly openEmitter = new Emitter(); + + protected readonly input: HTMLInputElement; + protected readonly loadIndicator: HTMLElement; + protected readonly frame: HTMLIFrameElement; + // tslint:disable-next-line:max-line-length + // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. + protected readonly transparentOverlay: HTMLElement; + // XXX It is a hack. Instead of loading the PDF in an iframe we use `PDFObject` to render it in a div. + protected readonly pdfContainer: HTMLElement; + + protected readonly initialHistoryLength: number; + protected readonly toDisposeOnGo = new DisposableCollection(); + + constructor(@inject(MiniBrowserProps) protected readonly props: MiniBrowserProps) { + super(); + this.addClass(MiniBrowserContent.Styles.MINI_BROWSER); + this.input = this.createToolbar(this.node).input; + const contentArea = this.createContentArea(this.node); + this.frame = contentArea.frame; + this.transparentOverlay = contentArea.transparentOverlay; + this.loadIndicator = contentArea.loadIndicator; + this.pdfContainer = contentArea.pdfContainer; + this.initialHistoryLength = history.length; + this.toDispose.pushAll([ + this.submitInputEmitter, + this.navigateBackEmitter, + this.navigateForwardEmitter, + this.refreshEmitter, + this.openEmitter + ]); + } + + @postConstruct() + protected init(): void { + this.toDispose.push(this.mouseTracker.onMousedown(e => { + if (this.frame.style.display !== 'none') { + this.transparentOverlay.style.display = 'block'; + } + })); + this.toDispose.push(this.mouseTracker.onMouseup(e => { + if (this.frame.style.display !== 'none') { + this.transparentOverlay.style.display = 'none'; + } + })); + const { startPage } = this.props; + if (startPage) { + setTimeout(() => this.go(startPage, true), 500); + this.listenOnContentChange(startPage); + } + } + + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + (this.getToolbarProps() !== 'hide' ? this.input : this.frame).focus(); + this.update(); + } + + protected async listenOnContentChange(location: string): Promise { + if (location.startsWith('file://')) { + if (await this.fileSystem.exists(location)) { + const fileUri = new URI(location); + const watcher = await this.fileSystemWatcher.watchFileChanges(fileUri); + this.toDispose.push(watcher); + const onFileChange = (event: FileChangeEvent) => { + if (FileChangeEvent.isChanged(event, fileUri)) { + this.go(location, false, false); + } + }; + this.toDispose.push(this.fileSystemWatcher.onFilesChanged(debounce(onFileChange, 500))); + } + } + } + + protected createToolbar(parent: HTMLElement): HTMLDivElement & Readonly<{ input: HTMLInputElement }> { + const toolbar = document.createElement('div'); + toolbar.classList.add(this.getToolbarProps() === 'read-only' ? MiniBrowserContent.Styles.TOOLBAR_READ_ONLY : MiniBrowserContent.Styles.TOOLBAR); + parent.appendChild(toolbar); + this.createPrevious(toolbar); + this.createNext(toolbar); + this.createRefresh(toolbar); + const input = this.createInput(toolbar); + input.readOnly = this.getToolbarProps() === 'read-only'; + this.createOpen(toolbar); + if (this.getToolbarProps() === 'hide') { + toolbar.style.display = 'none'; + } + return Object.assign(toolbar, { input }); + } + + protected getToolbarProps(): 'show' | 'hide' | 'read-only' { + return !this.props.startPage ? 'show' : this.props.toolbar || 'show'; + } + + // tslint:disable-next-line:max-line-length + protected createContentArea(parent: HTMLElement): HTMLElement & Readonly<{ frame: HTMLIFrameElement, loadIndicator: HTMLElement, pdfContainer: HTMLElement, transparentOverlay: HTMLElement }> { + const contentArea = document.createElement('div'); + contentArea.classList.add(MiniBrowserContent.Styles.CONTENT_AREA); + + const loadIndicator = document.createElement('div'); + loadIndicator.classList.add(MiniBrowserContent.Styles.PRE_LOAD); + loadIndicator.style.display = 'none'; + + const frame = this.createIFrame(); + this.submitInputEmitter.event(input => this.go(input, true)); + this.navigateBackEmitter.event(this.handleBack.bind(this)); + this.navigateForwardEmitter.event(this.handleForward.bind(this)); + this.refreshEmitter.event(this.handleRefresh.bind(this)); + this.openEmitter.event(this.handleOpen.bind(this)); + + const transparentOverlay = document.createElement('div'); + transparentOverlay.classList.add(MiniBrowserContent.Styles.TRANSPARENT_OVERLAY); + transparentOverlay.style.display = 'none'; + + const pdfContainer = document.createElement('div'); + pdfContainer.classList.add(MiniBrowserContent.Styles.PDF_CONTAINER); + pdfContainer.id = `${this.id}-pdf-container`; + pdfContainer.style.display = 'none'; + + contentArea.appendChild(transparentOverlay); + contentArea.appendChild(pdfContainer); + contentArea.appendChild(loadIndicator); + contentArea.appendChild(frame); + + parent.appendChild(contentArea); + return Object.assign(contentArea, { frame, loadIndicator, pdfContainer, transparentOverlay }); + } + + protected createIFrame(): HTMLIFrameElement { + const frame = document.createElement('iframe'); + const sandbox = (this.props.sandbox || MiniBrowserProps.SandboxOptions.DEFAULT).map(name => MiniBrowserProps.SandboxOptions[name]); + frame.sandbox.add(...sandbox); + this.toDispose.push(addEventListener(frame, 'load', this.onFrameLoad.bind(this))); + return frame; + } + + protected onFrameLoad(): void { + this.hideLoadIndicator(); + this.focus(); + } + + protected focus(): void { + const contentDocument = this.contentDocument(); + if (contentDocument !== null && contentDocument.body) { + contentDocument.body.focus(); + } else if (this.pdfContainer.style.display !== 'none') { + this.pdfContainer.focus(); + } else if (this.getToolbarProps() !== 'hide') { + this.input.focus(); + } else if (this.frame.parentElement) { + this.frame.parentElement.focus(); + } + } + + protected showLoadIndicator(): void { + this.loadIndicator.style.display = 'block'; + } + + protected hideLoadIndicator(): void { + this.loadIndicator.style.display = 'none'; + } + + protected handleBack(): void { + if (history.length - this.initialHistoryLength > 0) { + history.back(); + } + } + + protected handleForward(): void { + if (history.length > this.initialHistoryLength) { + history.forward(); + } + } + + protected handleRefresh(): void { + // Initial pessimism; use the location of the input. + let location: string | undefined = this.props.startPage; + // Use the the location from the `input`. + if (this.input && this.input.value) { + location = this.input.value; + } + try { + const { contentDocument } = this.frame; + if (contentDocument && contentDocument.location) { + location = contentDocument.location.href; + } + } catch { + // Security exception due to CORS when trying to access the `location.href` of the content document. + } + if (location) { + this.go(location, false, true); + } + } + + protected handleOpen(): void { + const location = this.frameSrc() || this.input.value; + if (location) { + this.windowService.openNewWindow(location); + } + } + + protected createInput(parent: HTMLElement): HTMLInputElement { + const input = document.createElement('input'); + input.type = 'text'; + this.toDispose.pushAll([ + addEventListener(input, 'keydown', this.handleInputChange.bind(this)), + addEventListener(input, 'click', () => { + if (this.getToolbarProps() === 'read-only') { + this.handleOpen(); + } else { + if (input.value) { + input.select(); + } + } + }) + ]); + parent.appendChild(input); + return input; + } + + protected handleInputChange(e: KeyboardEvent): void { + const { key } = KeyCode.createKeyCode(e); + if (key && Key.ENTER.keyCode === key.keyCode && this.getToolbarProps() === 'show') { + const { srcElement } = e; + if (srcElement instanceof HTMLInputElement) { + this.mapLocation(srcElement.value).then(location => this.submitInputEmitter.fire(location)); + } + } + } + + protected createPrevious(parent: HTMLElement): HTMLElement { + return this.onClick(this.createButton(parent, 'Show The Previous Page', MiniBrowserContent.Styles.PREVIOUS), this.navigateBackEmitter); + } + + protected createNext(parent: HTMLElement): HTMLElement { + return this.onClick(this.createButton(parent, 'Show The Next Page', MiniBrowserContent.Styles.NEXT), this.navigateForwardEmitter); + } + + protected createRefresh(parent: HTMLElement): HTMLElement { + return this.onClick(this.createButton(parent, 'Reload This Page', MiniBrowserContent.Styles.REFRESH), this.refreshEmitter); + } + + protected createOpen(parent: HTMLElement): HTMLElement { + const button = this.onClick(this.createButton(parent, 'Open In A New Window', MiniBrowserContent.Styles.OPEN), this.openEmitter); + return button; + } + + protected createButton(parent: HTMLElement, title: string, ...className: string[]): HTMLElement { + const button = document.createElement('div'); + button.title = title; + button.classList.add(...className, MiniBrowserContent.Styles.BUTTON); + parent.appendChild(button); + return button; + } + + // tslint:disable-next-line:no-any + protected onClick(element: HTMLElement, emitter: Emitter): HTMLElement { + this.toDispose.push(addEventListener(element, 'click', () => { + if (!element.classList.contains(MiniBrowserContent.Styles.DISABLED)) { + emitter.fire(undefined); + } + })); + return element; + } + + protected mapLocation(location: string): Promise { + return this.locationMapper.map(location); + } + + protected setInput(value: string) { + if (this.input.value !== value) { + this.input.value = value; + } + } + + protected frameSrc() { + let src = this.frame.src; + try { + const { contentWindow } = this.frame; + if (contentWindow) { + src = contentWindow.location.href; + } + } catch { + // CORS issue. Ignored. + } + if (src === 'about:blank') { + src = ''; + } + return src; + } + + protected contentDocument(): Document | null { + try { + let { contentDocument } = this.frame; + if (contentDocument === null) { + const { contentWindow } = this.frame; + if (contentWindow) { + contentDocument = contentWindow.document; + } + } + return contentDocument; + } catch { + // tslint:disable-next-line:no-null-keyword + return null; + } + } + + protected async go(location: string, register: boolean = false, showLoadIndicator: boolean = true): Promise { + if (location) { + try { + this.toDisposeOnGo.dispose(); + const url = await this.mapLocation(location); + this.setInput(url); + if (this.getToolbarProps() === 'read-only') { + this.input.title = `Open ${url} In A New Window`; + } + if (showLoadIndicator) { + this.showLoadIndicator(); + } + if (url.endsWith('.pdf')) { + this.pdfContainer.style.display = 'block'; + this.frame.style.display = 'none'; + PDFObject.embed(url, this.pdfContainer, { + // tslint:disable-next-line:max-line-length + fallbackLink: `

Your browser does not support inline PDFs. Click on this link to open the PDF in a new tab.

` + }); + this.hideLoadIndicator(); + } else { + if (this.props.resetBackground === true) { + this.frame.addEventListener('load', () => this.frame.style.backgroundColor = 'white', { once: true }); + } + this.pdfContainer.style.display = 'none'; + this.frame.style.display = 'block'; + this.frame.src = url; + // The load indicator will hide itself if the content of the iframe was loaded. + } + // Delegate all the `keypress` events from the `iframe` to the application. + this.toDisposeOnGo.push(addEventListener(this.frame, 'load', () => { + try { + const { contentDocument } = this.frame; + if (contentDocument) { + const keypressHandler = (e: KeyboardEvent) => this.keybindings.run(e); + contentDocument.addEventListener('keypress', keypressHandler, true); + this.toDisposeOnDetach.push(Disposable.create(() => contentDocument.removeEventListener('keypress', keypressHandler))); + } + } catch { + // There is not much we could do with the security exceptions due to CORS. + } + })); + } catch (e) { + this.hideLoadIndicator(); + console.log(e); + } + } + } + +} + +export namespace MiniBrowserContent { + + export namespace Styles { + + export const MINI_BROWSER = 'theia-mini-browser'; + export const TOOLBAR = 'theia-mini-browser-toolbar'; + export const TOOLBAR_READ_ONLY = 'theia-mini-browser-toolbar-read-only'; + export const PRE_LOAD = 'theia-mini-browser-load-indicator'; + export const CONTENT_AREA = 'theia-mini-browser-content-area'; + export const PDF_CONTAINER = 'theia-mini-browser-pdf-container'; + export const PREVIOUS = 'theia-mini-browser-previous'; + export const NEXT = 'theia-mini-browser-next'; + export const REFRESH = 'theia-mini-browser-refresh'; + export const OPEN = 'theia-mini-browser-open'; + export const BUTTON = 'theia-mini-browser-button'; + export const DISABLED = 'theia-mini-browser-button-disabled'; + export const TRANSPARENT_OVERLAY = 'theia-mini-browser-transparent-overlay'; + + } + +} diff --git a/packages/mini-browser/src/browser/mini-browser-frontend-module.ts b/packages/mini-browser/src/browser/mini-browser-frontend-module.ts index 684f3d47ddc10..32c409af42176 100644 --- a/packages/mini-browser/src/browser/mini-browser-frontend-module.ts +++ b/packages/mini-browser/src/browser/mini-browser-frontend-module.ts @@ -15,14 +15,19 @@ ********************************************************************************/ import { ContainerModule } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; import { OpenHandler } from '@theia/core/lib/browser/opener-service'; import { WidgetFactory } from '@theia/core/lib/browser/widget-manager'; import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { CommandContribution } from '@theia/core/lib/common/command'; +import { NavigatableWidgetOptions } from '@theia/core/lib/browser/navigatable'; import { MiniBrowserOpenHandler } from './mini-browser-open-handler'; import { MiniBrowserService, MiniBrowserServicePath } from '../common/mini-browser-service'; -import { MiniBrowser, MiniBrowserProps, MiniBrowserMouseClickTracker } from './mini-browser'; +import { MiniBrowser, MiniBrowserOptions } from './mini-browser'; +import { MiniBrowserProps, MiniBrowserContentFactory, MiniBrowserMouseClickTracker, MiniBrowserContent } from './mini-browser-content'; import { LocationMapperService, FileLocationMapper, HttpLocationMapper, HttpsLocationMapper, LocationMapper } from './location-mapper-service'; import '../../src/browser/style/index.css'; @@ -31,13 +36,22 @@ export default new ContainerModule(bind => { bind(MiniBrowserMouseClickTracker).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(MiniBrowserMouseClickTracker); + bind(MiniBrowserContent).toSelf(); + bind(MiniBrowserContentFactory).toFactory(context => (props: MiniBrowserProps) => { + const { container } = context; + const child = container.createChild(); + child.bind(MiniBrowserProps).toConstantValue(props); + return child.get(MiniBrowserContent); + }); + bind(MiniBrowser).toSelf(); bind(WidgetFactory).toDynamicValue(context => ({ - id: MiniBrowser.Factory.ID, - async createWidget(props: MiniBrowserProps): Promise { + id: MiniBrowser.ID, + async createWidget(options: NavigatableWidgetOptions): Promise { const { container } = context; const child = container.createChild(); - child.bind(MiniBrowserProps).toConstantValue(props); + const uri = new URI(options.uri); + child.bind(MiniBrowserOptions).toConstantValue({ uri }); return child.get(MiniBrowser); } })).inSingletonScope(); @@ -45,6 +59,8 @@ export default new ContainerModule(bind => { bind(MiniBrowserOpenHandler).toSelf().inSingletonScope(); bind(OpenHandler).toService(MiniBrowserOpenHandler); bind(FrontendApplicationContribution).toService(MiniBrowserOpenHandler); + bind(CommandContribution).toService(MiniBrowserOpenHandler); + bind(TabBarToolbarContribution).toService(MiniBrowserOpenHandler); bindContributionProvider(bind, LocationMapper); bind(LocationMapper).to(FileLocationMapper).inSingletonScope(); diff --git a/packages/mini-browser/src/browser/mini-browser-open-handler.ts b/packages/mini-browser/src/browser/mini-browser-open-handler.ts index f9b15684fe3da..8538a82769182 100644 --- a/packages/mini-browser/src/browser/mini-browser-open-handler.ts +++ b/packages/mini-browser/src/browser/mini-browser-open-handler.ts @@ -14,17 +14,30 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Widget } from '@phosphor/widgets'; import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { MaybePromise } from '@theia/core/lib/common/types'; import { ApplicationShell } from '@theia/core/lib/browser/shell'; -import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { NavigatableWidget, NavigatableWidgetOpenHandler } from '@theia/core/lib/browser/navigatable'; +import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; -import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser/widget-open-handler'; +import { WidgetOpenerOptions } from '@theia/core/lib/browser/widget-open-handler'; import { MiniBrowserService } from '../common/mini-browser-service'; import { MiniBrowser, MiniBrowserProps } from './mini-browser'; +export namespace MiniBrowserCommands { + export const PREVIEW: Command = { + id: 'mini-browser.preview' + }; + export const OPEN_SOURCE: Command = { + id: 'mini-browser.open.source' + }; +} + /** * Further options for opening a new `Mini Browser` widget. */ @@ -33,7 +46,8 @@ export interface MiniBrowserOpenerOptions extends WidgetOpenerOptions, MiniBrows } @injectable() -export class MiniBrowserOpenHandler extends WidgetOpenHandler implements FrontendApplicationContribution { +export class MiniBrowserOpenHandler extends NavigatableWidgetOpenHandler + implements FrontendApplicationContribution, CommandContribution, TabBarToolbarContribution { /** * Instead of going to the backend with each file URI to ask whether it can handle the current file or not, @@ -45,14 +59,11 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple */ protected readonly supportedExtensions: Map = new Map(); - readonly id = 'mini-browser-open-handler'; - readonly label = 'Mini Browser'; + readonly id = MiniBrowser.ID; + readonly label = 'Preview'; - @inject(ApplicationShell) - protected readonly shell: ApplicationShell; - - @inject(WidgetManager) - protected readonly widgetManager: WidgetManager; + @inject(OpenerService) + protected readonly openerService: OpenerService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @@ -60,11 +71,11 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple @inject(MiniBrowserService) protected readonly miniBrowserService: MiniBrowserService; - async onStart(): Promise { - (await this.miniBrowserService.supportedFileExtensions()).forEach(entry => { + onStart(): void { + (async () => (await this.miniBrowserService.supportedFileExtensions()).forEach(entry => { const { extension, priority } = entry; this.supportedExtensions.set(extension, priority); - }); + }))(); } canHandle(uri: URI): number { @@ -77,10 +88,8 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple return 0; } - async open(uri?: URI, options?: MiniBrowserOpenerOptions): Promise { - const mergedOptions = await this.options(uri, options); - const widget = await this.widgetManager.getOrCreateWidget(MiniBrowser.Factory.ID, mergedOptions); - await this.doOpen(widget, mergedOptions); + async open(uri: URI = MiniBrowser.URI, options?: MiniBrowserOpenerOptions): Promise { + const widget = await super.open(uri, options); const area = this.shell.getAreaFor(widget); if (area && area !== 'main') { this.shell.resize(this.shell.mainPanel.node.offsetWidth / 2, area); @@ -88,13 +97,19 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple return widget; } + protected async getOrCreateWidget(uri: URI, options?: MiniBrowserOpenerOptions): Promise { + const props = await this.options(uri, options); + const widget = await super.getOrCreateWidget(uri, props); + widget.setProps(props); + return widget; + } protected async options(uri?: URI, options?: MiniBrowserOpenerOptions): Promise { // Get the default options. let result = await this.defaultOptions(); if (uri) { // Decorate it with a few properties inferred from the URI. const startPage = uri.toString(); - const name = await this.labelProvider.getName(uri); + const name = this.labelProvider.getName(uri); const iconClass = `${await this.labelProvider.getIcon(uri)} file-icon`; // The background has to be reset to white only for "real" web-pages but not for images, for instance. const resetBackground = await this.resetBackground(uri); @@ -132,8 +147,80 @@ export class MiniBrowserOpenHandler extends WidgetOpenHandler imple }; } - protected createWidgetOptions(uri: URI, options?: WidgetOpenerOptions | undefined): Object { - throw new Error('Method not supported.'); + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(MiniBrowserCommands.PREVIEW, { + execute: widget => this.preview(widget), + isEnabled: widget => this.canPreviewWidget(widget), + isVisible: widget => this.canPreviewWidget(widget) + }); + commands.registerCommand(MiniBrowserCommands.OPEN_SOURCE, { + execute: widget => this.openSource(widget), + isEnabled: widget => !!this.getSourceUri(widget), + isVisible: widget => !!this.getSourceUri(widget) + }); + } + + registerToolbarItems(toolbar: TabBarToolbarRegistry): void { + toolbar.registerItem({ + id: MiniBrowserCommands.PREVIEW.id, + command: MiniBrowserCommands.PREVIEW.id, + text: '$(eye)', + tooltip: 'Open Preview to the Side' + }); + toolbar.registerItem({ + id: MiniBrowserCommands.OPEN_SOURCE.id, + command: MiniBrowserCommands.OPEN_SOURCE.id, + text: '$(file-o)', + tooltip: 'Open Source' + }); + } + + protected canPreviewWidget(widget?: Widget): boolean { + const uri = this.getUriToPreview(widget); + return !!uri && !!this.canHandle(uri); + } + + protected getUriToPreview(widget?: Widget): URI | undefined { + const current = this.getWidgetToPreview(widget); + return current && current.getResourceUri(); + } + + protected getWidgetToPreview(widget?: Widget): NavigatableWidget | undefined { + const current = widget ? widget : this.shell.currentWidget; + // MiniBrowser is NavigatableWidget and should be excluded from widgets to preview + return !(current instanceof MiniBrowser) && NavigatableWidget.is(current) && current || undefined; + } + + protected async preview(widget?: Widget): Promise { + const ref = this.getWidgetToPreview(widget); + if (!ref) { + return; + } + const uri = ref.getResourceUri(); + if (!uri) { + return; + } + await this.open(uri, { + mode: 'reveal', + widgetOptions: { ref, mode: 'open-to-right' } + }); + } + + protected async openSource(ref?: Widget): Promise { + const uri = this.getSourceUri(ref); + if (uri) { + await open(this.openerService, uri, { + widgetOptions: { ref, mode: 'open-to-left' } + }); + } + } + + protected getSourceUri(ref?: Widget): URI | undefined { + const uri = ref instanceof MiniBrowser && ref.getResourceUri() || undefined; + if (!uri || uri.scheme === 'http' || uri.scheme === 'https') { + return undefined; + } + return uri; } } diff --git a/packages/mini-browser/src/browser/mini-browser.ts b/packages/mini-browser/src/browser/mini-browser.ts index 351004382fa1d..203fd7f8c573f 100644 --- a/packages/mini-browser/src/browser/mini-browser.ts +++ b/packages/mini-browser/src/browser/mini-browser.ts @@ -14,646 +14,105 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import * as PDFObject from 'pdfobject'; import { inject, injectable, postConstruct } from 'inversify'; import { Message } from '@phosphor/messaging'; -import { PanelLayout, SplitPanel } from '@phosphor/widgets'; import URI from '@theia/core/lib/common/uri'; -import { ILogger } from '@theia/core/lib/common/logger'; -import { Key, KeyCode } from '@theia/core/lib/browser/keys'; -import { Emitter, Event } from '@theia/core/lib/common/event'; -import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; -import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; -import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { FrontendApplicationContribution, ApplicationShell } from '@theia/core/lib/browser'; -import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; -import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; -import { BaseWidget, addEventListener, FocusTracker, Widget } from '@theia/core/lib/browser/widgets/widget'; -import { LocationMapperService } from './location-mapper-service'; +import { NavigatableWidget, StatefulWidget } from '@theia/core/lib/browser'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { BaseWidget, PanelLayout } from '@theia/core/lib/browser/widgets/widget'; +import { MiniBrowserProps, MiniBrowserContentFactory } from './mini-browser-content'; -import debounce = require('lodash.debounce'); +export { MiniBrowserProps }; -/** - * Initializer properties for the embedded browser widget. - */ @injectable() -export class MiniBrowserProps { - - /** - * `show` if the toolbar should be visible. If `read-only`, the toolbar is visible but the address cannot be changed and it acts as a link instead.\ - * `hide` if the toolbar should be hidden. `show` by default. If the `startPage` is not defined, this property is always `show`. - */ - readonly toolbar?: 'show' | 'hide' | 'read-only'; - - /** - * If defined, the browser will load this page on startup. Otherwise, it show a blank page. - */ - readonly startPage?: string; - - /** - * Sandbox options for the underlying `iframe`. Defaults to `SandboxOptions#DEFAULT` if not provided. - */ - readonly sandbox?: MiniBrowserProps.SandboxOptions[]; - - /** - * The optional icon class for the widget. - */ - readonly iconClass?: string; - - /** - * The desired name of the widget. - */ - readonly name?: string; - - /** - * `true` if the `iFrame`'s background has to be reset to the default white color. Otherwise, `false`. `false` is the default. - */ - readonly resetBackground?: boolean; - +export class MiniBrowserOptions { + uri: URI; } -export namespace MiniBrowserProps { +@injectable() +export class MiniBrowser extends BaseWidget implements NavigatableWidget, StatefulWidget { + static ID = 'mini-browser'; + static ICON = 'fa fa-globe'; /** - * Enumeration of the supported `sandbox` options for the `iframe`. + * A default URI to open the mini browser view (singleton widget). + * In order to create a new mini browser widget, a custom URI should be passed, e.g. a file URI. */ - export enum SandboxOptions { - - /** - * Allows form submissions. - */ - 'allow-forms', - - /** - * Allows popups, such as `window.open()`, `showModalDialog()`, `target=”_blank”`, etc. - */ - 'allow-popups', - - /** - * Allows pointer lock. - */ - 'allow-pointer-lock', - - /** - * Allows the document to maintain its origin. Pages loaded from https://example.com/ will retain access to that origin’s data. - */ - 'allow-same-origin', - - /** - * Allows JavaScript execution. Also allows features to trigger automatically (as they’d be trivial to implement via JavaScript). - */ - 'allow-scripts', - - /** - * Allows the document to break out of the frame by navigating the top-level `window`. - */ - 'allow-top-navigation', - - /** - * Allows the embedded browsing context to open modal windows. - */ - 'allow-modals', - - /** - * Allows the embedded browsing context to disable the ability to lock the screen orientation. - */ - 'allow-orientation-lock', - - /** - * Allows a sandboxed document to open new windows without forcing the sandboxing flags upon them. - * This will allow, for example, a third-party advertisement to be safely sandboxed without forcing the same restrictions upon a landing page. - */ - 'allow-popups-to-escape-sandbox', - - /** - * Allows embedders to have control over whether an iframe can start a presentation session. - */ - 'allow-presentation', - - /** - * Allows the embedded browsing context to navigate (load) content to the top-level browsing context only when initiated by a user gesture. - * If this keyword is not used, this operation is not allowed. - */ - 'allow-top-navigation-by-user-activation' - } - - export namespace SandboxOptions { - - /** - * The default `sandbox` options, if other is not provided. - * - * See: https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ - */ - export const DEFAULT: SandboxOptions[] = [ - SandboxOptions['allow-same-origin'], - SandboxOptions['allow-scripts'], - SandboxOptions['allow-popups'], - SandboxOptions['allow-forms'], - SandboxOptions['allow-modals'] - ]; - - } - -} - -/** - * Contribution that tracks `mouseup` and `mousedown` events. - * - * This is required to be able to track the `TabBar`, `DockPanel`, and `SidePanel` resizing and drag and drop events correctly - * all over the application. By default, when the mouse is over an `iframe` we lose the mouse tracking ability, so whenever - * we click (`mousedown`), we overlay a transparent `div` over the `iframe` in the Mini Browser, then we set the `display` of - * the transparent `div` to `none` on `mouseup` events. - */ -@injectable() -export class MiniBrowserMouseClickTracker implements FrontendApplicationContribution { - - @inject(ApplicationShell) - protected readonly applicationShell: ApplicationShell; - - protected readonly toDispose = new DisposableCollection(); - protected readonly toDisposeOnActiveChange = new DisposableCollection(); - - protected readonly mouseupEmitter = new Emitter(); - protected readonly mousedownEmitter = new Emitter(); - protected readonly mouseupListener: (e: MouseEvent) => void = e => this.mouseupEmitter.fire(e); - protected readonly mousedownListener: (e: MouseEvent) => void = e => this.mousedownEmitter.fire(e); - - onStart(): void { - // Here we need to attach a `mousedown` listener to the `TabBar`s, `DockPanel`s and the `SidePanel`s. Otherwise, Phosphor handles the event and stops the propagation. - // Track the `mousedown` on the `TabBar` for the currently active widget. - this.applicationShell.activeChanged.connect((shell: ApplicationShell, args: FocusTracker.IChangedArgs) => { - this.toDisposeOnActiveChange.dispose(); - if (args.newValue) { - const tabBar = shell.getTabBarFor(args.newValue); - if (tabBar) { - this.toDisposeOnActiveChange.push(addEventListener(tabBar.node, 'mousedown', this.mousedownListener, true)); - } - } - }); - - // Track the `mousedown` events for the `SplitPanel`s, if any. - const { layout } = this.applicationShell; - if (layout instanceof PanelLayout) { - this.toDispose.pushAll( - layout.widgets.filter(MiniBrowserMouseClickTracker.isSplitPanel).map(splitPanel => addEventListener(splitPanel.node, 'mousedown', this.mousedownListener, true)) - ); - } - // Track the `mousedown` on each `DockPanel`. - const { mainPanel, bottomPanel, leftPanelHandler, rightPanelHandler } = this.applicationShell; - this.toDispose.pushAll([mainPanel, bottomPanel, leftPanelHandler.dockPanel, rightPanelHandler.dockPanel] - .map(panel => addEventListener(panel.node, 'mousedown', this.mousedownListener, true))); - - // The `mouseup` event has to be tracked on the `document`. Phosphor attaches to there. - document.addEventListener('mouseup', this.mouseupListener, true); - - // Make sure it is disposed in the end. - this.toDispose.pushAll([ - this.mousedownEmitter, - this.mouseupEmitter, - Disposable.create(() => document.removeEventListener('mouseup', this.mouseupListener, true)) - ]); - } - - onStop(): void { - this.toDispose.dispose(); - this.toDisposeOnActiveChange.dispose(); - } - - get onMouseup(): Event { - return this.mouseupEmitter.event; - } - - get onMousedown(): Event { - return this.mousedownEmitter.event; - } - -} - -export namespace MiniBrowserMouseClickTracker { - - export function isSplitPanel(arg: Widget): arg is SplitPanel { - return arg instanceof SplitPanel; - } - -} - -@injectable() -export class MiniBrowser extends BaseWidget { - - private static ID = 0; - private static ICON = 'fa fa-globe'; - - @inject(ILogger) - protected readonly logger: ILogger; - - @inject(WindowService) - protected readonly windowService: WindowService; - - @inject(LocationMapperService) - protected readonly locationMapper: LocationMapperService; - - @inject(KeybindingRegistry) - protected readonly keybindings: KeybindingRegistry; - - @inject(MiniBrowserMouseClickTracker) - protected readonly mouseTracker: MiniBrowserMouseClickTracker; - - @inject(FileSystem) - protected readonly fileSystem: FileSystem; - - @inject(FileSystemWatcher) - protected readonly fileSystemWatcher: FileSystemWatcher; - - protected readonly submitInputEmitter = new Emitter(); - protected readonly navigateBackEmitter = new Emitter(); - protected readonly navigateForwardEmitter = new Emitter(); - protected readonly refreshEmitter = new Emitter(); - protected readonly openEmitter = new Emitter(); + static URI = new URI().withScheme('__minibrowser'); - protected readonly input: HTMLInputElement; - protected readonly loadIndicator: HTMLElement; - protected readonly frame: HTMLIFrameElement; - // tslint:disable-next-line:max-line-length - // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. - protected readonly transparentOverlay: HTMLElement; - // XXX It is a hack. Instead of loading the PDF in an iframe we use `PDFObject` to render it in a div. - protected readonly pdfContainer: HTMLElement; + @inject(MiniBrowserOptions) + protected readonly options: MiniBrowserOptions; - protected readonly initialHistoryLength: number; - protected readonly toDisposeOnGo = new DisposableCollection(); - - constructor(@inject(MiniBrowserProps) protected readonly props: MiniBrowserProps) { - super(); - this.id = `theia-mini-browser-${MiniBrowser.ID++}`; - this.title.closable = true; - this.title.caption = this.title.label = this.props.name || 'Browser'; - this.title.iconClass = this.props.iconClass || MiniBrowser.ICON; - this.addClass(MiniBrowser.Styles.MINI_BROWSER); - this.input = this.createToolbar(this.node).input; - const contentArea = this.createContentArea(this.node); - this.frame = contentArea.frame; - this.transparentOverlay = contentArea.transparentOverlay; - this.loadIndicator = contentArea.loadIndicator; - this.pdfContainer = contentArea.pdfContainer; - this.initialHistoryLength = history.length; - this.toDispose.pushAll([ - this.submitInputEmitter, - this.navigateBackEmitter, - this.navigateForwardEmitter, - this.refreshEmitter, - this.openEmitter - ]); - } + @inject(MiniBrowserContentFactory) + protected readonly createContent: MiniBrowserContentFactory; @postConstruct() protected init(): void { - this.toDispose.push(this.mouseTracker.onMousedown(e => { - if (this.frame.style.display !== 'none') { - this.transparentOverlay.style.display = 'block'; - } - })); - this.toDispose.push(this.mouseTracker.onMouseup(e => { - if (this.frame.style.display !== 'none') { - this.transparentOverlay.style.display = 'none'; - } - })); - const { startPage } = this.props; - if (startPage) { - setTimeout(() => this.go(startPage, true), 500); - this.listenOnContentChange(startPage); - } - } - - onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - (this.getToolbarProps() !== 'hide' ? this.input : this.frame).focus(); - this.update(); - } - - protected async listenOnContentChange(location: string): Promise { - if (location.startsWith('file://')) { - if (await this.fileSystem.exists(location)) { - const fileUri = new URI(location); - const watcher = await this.fileSystemWatcher.watchFileChanges(fileUri); - this.toDispose.push(watcher); - const onFileChange = (event: FileChangeEvent) => { - if (FileChangeEvent.isChanged(event, fileUri)) { - this.go(location, false, false); - } - }; - this.toDispose.push(this.fileSystemWatcher.onFilesChanged(debounce(onFileChange, 500))); - } - } - } - - protected createToolbar(parent: HTMLElement): HTMLDivElement & Readonly<{ input: HTMLInputElement }> { - const toolbar = document.createElement('div'); - toolbar.classList.add(this.getToolbarProps() === 'read-only' ? MiniBrowser.Styles.TOOLBAR_READ_ONLY : MiniBrowser.Styles.TOOLBAR); - parent.appendChild(toolbar); - this.createPrevious(toolbar); - this.createNext(toolbar); - this.createRefresh(toolbar); - const input = this.createInput(toolbar); - input.readOnly = this.getToolbarProps() === 'read-only'; - this.createOpen(toolbar); - if (this.getToolbarProps() === 'hide') { - toolbar.style.display = 'none'; - } - return Object.assign(toolbar, { input }); - } - - protected getToolbarProps(): 'show' | 'hide' | 'read-only' { - return !this.props.startPage ? 'show' : this.props.toolbar || 'show'; - } - - // tslint:disable-next-line:max-line-length - protected createContentArea(parent: HTMLElement): HTMLElement & Readonly<{ frame: HTMLIFrameElement, loadIndicator: HTMLElement, pdfContainer: HTMLElement, transparentOverlay: HTMLElement }> { - const contentArea = document.createElement('div'); - contentArea.classList.add(MiniBrowser.Styles.CONTENT_AREA); - - const loadIndicator = document.createElement('div'); - loadIndicator.classList.add(MiniBrowser.Styles.PRE_LOAD); - loadIndicator.style.display = 'none'; - - const frame = this.createIFrame(); - this.submitInputEmitter.event(input => this.go(input, true)); - this.navigateBackEmitter.event(this.handleBack.bind(this)); - this.navigateForwardEmitter.event(this.handleForward.bind(this)); - this.refreshEmitter.event(this.handleRefresh.bind(this)); - this.openEmitter.event(this.handleOpen.bind(this)); - - const transparentOverlay = document.createElement('div'); - transparentOverlay.classList.add(MiniBrowser.Styles.TRANSPARENT_OVERLAY); - transparentOverlay.style.display = 'none'; - - const pdfContainer = document.createElement('div'); - pdfContainer.classList.add(MiniBrowser.Styles.PDF_CONTAINER); - pdfContainer.id = `${this.id}-pdf-container`; - pdfContainer.style.display = 'none'; - - contentArea.appendChild(transparentOverlay); - contentArea.appendChild(pdfContainer); - contentArea.appendChild(loadIndicator); - contentArea.appendChild(frame); - - parent.appendChild(contentArea); - return Object.assign(contentArea, { frame, loadIndicator, pdfContainer, transparentOverlay }); - } - - protected createIFrame(): HTMLIFrameElement { - const frame = document.createElement('iframe'); - const sandbox = (this.props.sandbox || MiniBrowserProps.SandboxOptions.DEFAULT).map(name => MiniBrowserProps.SandboxOptions[name]); - frame.sandbox.add(...sandbox); - this.toDispose.push(addEventListener(frame, 'load', this.onFrameLoad.bind(this))); - return frame; - } - - protected onFrameLoad(): void { - this.hideLoadIndicator(); - this.focus(); - } - - protected focus(): void { - const contentDocument = this.contentDocument(); - if (contentDocument !== null && contentDocument.body) { - contentDocument.body.focus(); - } else if (this.pdfContainer.style.display !== 'none') { - this.pdfContainer.focus(); - } else if (this.getToolbarProps() !== 'hide') { - this.input.focus(); - } else if (this.frame.parentElement) { - this.frame.parentElement.focus(); - } - } - - protected showLoadIndicator(): void { - this.loadIndicator.style.display = 'block'; - } - - protected hideLoadIndicator(): void { - this.loadIndicator.style.display = 'none'; - } - - protected handleBack(): void { - if (history.length - this.initialHistoryLength > 0) { - history.back(); - } - } - - protected handleForward(): void { - if (history.length > this.initialHistoryLength) { - history.forward(); - } - } - - protected handleRefresh(): void { - // Initial pessimism; use the location of the input. - let location: string | undefined = this.props.startPage; - // Use the the location from the `input`. - if (this.input && this.input.value) { - location = this.input.value; - } - try { - const { contentDocument } = this.frame; - if (contentDocument && contentDocument.location) { - location = contentDocument.location.href; - } - } catch { - // Security exception due to CORS when trying to access the `location.href` of the content document. - } - if (location) { - this.go(location, false, true); + const { uri } = this.options; + if (uri.toString() === MiniBrowser.URI.toString()) { + this.id = MiniBrowser.ID; + } else { + this.id = `${MiniBrowser.ID}:${uri.toString()}`; } + this.title.closable = true; + this.layout = new PanelLayout(); } - protected handleOpen(): void { - const location = this.frameSrc() || this.input.value; - if (location) { - this.windowService.openNewWindow(location); - } + getResourceUri(): URI | undefined { + return this.options.uri; } - protected createInput(parent: HTMLElement): HTMLInputElement { - const input = document.createElement('input'); - input.type = 'text'; - this.toDispose.pushAll([ - addEventListener(input, 'keydown', this.handleInputChange.bind(this)), - addEventListener(input, 'click', () => { - if (this.getToolbarProps() === 'read-only') { - this.handleOpen(); - } else { - if (input.value) { - input.select(); - } - } - }) - ]); - parent.appendChild(input); - return input; + createMoveToUri(resourceUri: URI): URI | undefined { + return this.options.uri && this.options.uri.withPath(resourceUri.path); } - protected handleInputChange(e: KeyboardEvent): void { - const { key } = KeyCode.createKeyCode(e); - if (key && Key.ENTER.keyCode === key.keyCode && this.getToolbarProps() === 'show') { - const { srcElement } = e; - if (srcElement instanceof HTMLInputElement) { - this.mapLocation(srcElement.value).then(location => this.submitInputEmitter.fire(location)); - } + protected props: MiniBrowserProps | undefined; + protected readonly toDisposeOnProps = new DisposableCollection(); + setProps(raw: MiniBrowserProps): void { + const props: MiniBrowserProps = { + toolbar: raw.toolbar, + startPage: raw.startPage, + sandbox: raw.sandbox, + iconClass: raw.iconClass, + name: raw.name, + resetBackground: raw.resetBackground + }; + if (JSON.stringify(props) === JSON.stringify(this.props)) { + return; } - } + this.toDisposeOnProps.dispose(); + this.toDispose.push(this.toDisposeOnProps); + this.props = props; - protected createPrevious(parent: HTMLElement): HTMLElement { - return this.onClick(this.createButton(parent, 'Show The Previous Page', MiniBrowser.Styles.PREVIOUS), this.navigateBackEmitter); - } - - protected createNext(parent: HTMLElement): HTMLElement { - return this.onClick(this.createButton(parent, 'Show The Next Page', MiniBrowser.Styles.NEXT), this.navigateForwardEmitter); - } - - protected createRefresh(parent: HTMLElement): HTMLElement { - return this.onClick(this.createButton(parent, 'Reload This Page', MiniBrowser.Styles.REFRESH), this.refreshEmitter); - } - - protected createOpen(parent: HTMLElement): HTMLElement { - const button = this.onClick(this.createButton(parent, 'Open In A New Window', MiniBrowser.Styles.OPEN), this.openEmitter); - return button; - } - - protected createButton(parent: HTMLElement, title: string, ...className: string[]): HTMLElement { - const button = document.createElement('div'); - button.title = title; - button.classList.add(...className, MiniBrowser.Styles.BUTTON); - parent.appendChild(button); - return button; - } + this.title.caption = this.title.label = props.name || 'Browser'; + this.title.iconClass = props.iconClass || MiniBrowser.ICON; - // tslint:disable-next-line:no-any - protected onClick(element: HTMLElement, emitter: Emitter): HTMLElement { - this.toDispose.push(addEventListener(element, 'click', () => { - if (!element.classList.contains(MiniBrowser.Styles.DISABLED)) { - emitter.fire(undefined); - } - })); - return element; + const content = this.createContent(props); + (this.layout as PanelLayout).addWidget(content); + this.toDisposeOnProps.push(content); } - protected mapLocation(location: string): Promise { - return this.locationMapper.map(location); - } - - protected setInput(value: string) { - if (this.input.value !== value) { - this.input.value = value; + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + const widget = (this.layout as PanelLayout).widgets[0]; + if (widget) { + widget.activate(); } } - protected frameSrc() { - let src = this.frame.src; - try { - const { contentWindow } = this.frame; - if (contentWindow) { - src = contentWindow.location.href; - } - } catch { - // CORS issue. Ignored. - } - if (src === 'about:blank') { - src = ''; - } - return src; + storeState(): object { + const { props } = this; + return { props }; } - protected contentDocument(): Document | null { - try { - let { contentDocument } = this.frame; - if (contentDocument === null) { - const { contentWindow } = this.frame; - if (contentWindow) { - contentDocument = contentWindow.document; - } - } - return contentDocument; - } catch { - // tslint:disable-next-line:no-null-keyword - return null; + restoreState(oldState: object): void { + if (!this.toDisposeOnProps.disposed) { + return; } - } - - protected async go(location: string, register: boolean = false, showLoadIndicator: boolean = true): Promise { - if (location) { - try { - this.toDisposeOnGo.dispose(); - const url = await this.mapLocation(location); - this.setInput(url); - if (this.getToolbarProps() === 'read-only') { - this.input.title = `Open ${url} In A New Window`; - } - if (showLoadIndicator) { - this.showLoadIndicator(); - } - if (url.endsWith('.pdf')) { - this.pdfContainer.style.display = 'block'; - this.frame.style.display = 'none'; - PDFObject.embed(url, this.pdfContainer, { - // tslint:disable-next-line:max-line-length - fallbackLink: `

Your browser does not support inline PDFs. Click on this link to open the PDF in a new tab.

` - }); - this.hideLoadIndicator(); - } else { - if (this.props.resetBackground === true) { - this.frame.addEventListener('load', () => this.frame.style.backgroundColor = 'white', { once: true }); - } - this.pdfContainer.style.display = 'none'; - this.frame.style.display = 'block'; - this.frame.src = url; - // The load indicator will hide itself if the content of the iframe was loaded. - } - // Delegate all the `keypress` events from the `iframe` to the application. - this.toDisposeOnGo.push(addEventListener(this.frame, 'load', () => { - try { - const { contentDocument } = this.frame; - if (contentDocument) { - const keypressHandler = (e: KeyboardEvent) => this.keybindings.run(e); - contentDocument.addEventListener('keypress', keypressHandler, true); - this.toDisposeOnDetach.push(Disposable.create(() => contentDocument.removeEventListener('keypress', keypressHandler))); - } - } catch { - // There is not much we could do with the security exceptions due to CORS. - } - })); - } catch (e) { - this.hideLoadIndicator(); - console.log(e); - } + if ('props' in oldState) { + // tslint:disable-next-line:no-any + this.setProps((oldState)['props']); } } } - -export namespace MiniBrowser { - - export namespace Styles { - - export const MINI_BROWSER = 'theia-mini-browser'; - export const TOOLBAR = 'theia-mini-browser-toolbar'; - export const TOOLBAR_READ_ONLY = 'theia-mini-browser-toolbar-read-only'; - export const PRE_LOAD = 'theia-mini-browser-load-indicator'; - export const CONTENT_AREA = 'theia-mini-browser-content-area'; - export const PDF_CONTAINER = 'theia-mini-browser-pdf-container'; - export const PREVIOUS = 'theia-mini-browser-previous'; - export const NEXT = 'theia-mini-browser-next'; - export const REFRESH = 'theia-mini-browser-refresh'; - export const OPEN = 'theia-mini-browser-open'; - export const BUTTON = 'theia-mini-browser-button'; - export const DISABLED = 'theia-mini-browser-button-disabled'; - export const TRANSPARENT_OVERLAY = 'theia-mini-browser-transparent-overlay'; - - } - - export namespace Factory { - - export const ID = 'mini-browser-factory'; - - } - -} diff --git a/packages/mini-browser/src/browser/style/index.css b/packages/mini-browser/src/browser/style/index.css index 4e00e1e4db767..794ef048416de 100644 --- a/packages/mini-browser/src/browser/style/index.css +++ b/packages/mini-browser/src/browser/style/index.css @@ -17,6 +17,7 @@ .theia-mini-browser { display: flex; flex-direction: column; + height: 100%; } .theia-mini-browser-toolbar { @@ -81,22 +82,22 @@ .theia-mini-browser-previous::before { content: "\f053"; } - + .theia-mini-browser-next::before { content: "\f054"; } - + .theia-mini-browser-refresh::before { content: "\f021"; } - + .theia-mini-browser-open::before { content: "\f08e"; } .theia-mini-browser-content-area { position: relative; - display: flex; + display: flex; height: 100%; width: 100%; flex-direction: column; @@ -106,7 +107,7 @@ .theia-mini-browser-pdf-container { width: 100%; - height: 100%; + height: 100%; } .theia-mini-browser-load-indicator { diff --git a/packages/mini-browser/src/node/mini-browser-endpoint.ts b/packages/mini-browser/src/node/mini-browser-endpoint.ts index 78577c38f383c..37fd47138683d 100644 --- a/packages/mini-browser/src/node/mini-browser-endpoint.ts +++ b/packages/mini-browser/src/node/mini-browser-endpoint.ts @@ -292,7 +292,7 @@ export class SvgHandler implements MiniBrowserEndpointHandler { } priority(): number { - return CODE_EDITOR_PRIORITY + 1; + return 1; } respond(statWithContent: FileStatWithContent, response: Response): MaybePromise { From 79b92b65952b403cd0845b8db263f59d404dc9ec Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 15 Nov 2018 09:47:07 +0000 Subject: [PATCH 33/49] open handlers should not contribute visible commands Signed-off-by: Anton Kosyakov --- .../src/browser/mini-browser-frontend-module.ts | 2 ++ .../src/browser/mini-browser-open-handler.ts | 12 ++++++++++-- .../navigator/src/browser/navigator-contribution.ts | 4 +++- packages/preview/src/browser/preview-contribution.ts | 6 +++--- packages/workspace/src/browser/workspace-commands.ts | 5 +---- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/mini-browser/src/browser/mini-browser-frontend-module.ts b/packages/mini-browser/src/browser/mini-browser-frontend-module.ts index 32c409af42176..66fb06c85e321 100644 --- a/packages/mini-browser/src/browser/mini-browser-frontend-module.ts +++ b/packages/mini-browser/src/browser/mini-browser-frontend-module.ts @@ -23,6 +23,7 @@ import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/w import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { CommandContribution } from '@theia/core/lib/common/command'; +import { MenuContribution } from '@theia/core/lib/common/menu'; import { NavigatableWidgetOptions } from '@theia/core/lib/browser/navigatable'; import { MiniBrowserOpenHandler } from './mini-browser-open-handler'; import { MiniBrowserService, MiniBrowserServicePath } from '../common/mini-browser-service'; @@ -60,6 +61,7 @@ export default new ContainerModule(bind => { bind(OpenHandler).toService(MiniBrowserOpenHandler); bind(FrontendApplicationContribution).toService(MiniBrowserOpenHandler); bind(CommandContribution).toService(MiniBrowserOpenHandler); + bind(MenuContribution).toService(MiniBrowserOpenHandler); bind(TabBarToolbarContribution).toService(MiniBrowserOpenHandler); bindContributionProvider(bind, LocationMapper); diff --git a/packages/mini-browser/src/browser/mini-browser-open-handler.ts b/packages/mini-browser/src/browser/mini-browser-open-handler.ts index 8538a82769182..05d4b2abf6aeb 100644 --- a/packages/mini-browser/src/browser/mini-browser-open-handler.ts +++ b/packages/mini-browser/src/browser/mini-browser-open-handler.ts @@ -20,6 +20,7 @@ import URI from '@theia/core/lib/common/uri'; import { MaybePromise } from '@theia/core/lib/common/types'; import { ApplicationShell } from '@theia/core/lib/browser/shell'; import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NavigatableWidget, NavigatableWidgetOpenHandler } from '@theia/core/lib/browser/navigatable'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; @@ -31,7 +32,8 @@ import { MiniBrowser, MiniBrowserProps } from './mini-browser'; export namespace MiniBrowserCommands { export const PREVIEW: Command = { - id: 'mini-browser.preview' + id: 'mini-browser.preview', + label: 'Open Preview' }; export const OPEN_SOURCE: Command = { id: 'mini-browser.open.source' @@ -47,7 +49,7 @@ export interface MiniBrowserOpenerOptions extends WidgetOpenerOptions, MiniBrows @injectable() export class MiniBrowserOpenHandler extends NavigatableWidgetOpenHandler - implements FrontendApplicationContribution, CommandContribution, TabBarToolbarContribution { + implements FrontendApplicationContribution, CommandContribution, MenuContribution, TabBarToolbarContribution { /** * Instead of going to the backend with each file URI to ask whether it can handle the current file or not, @@ -160,6 +162,12 @@ export class MiniBrowserOpenHandler extends NavigatableWidgetOpenHandler ({ - id: `file.openWith.${opener.id}`, - category: FILE_CATEGORY, - label: opener.label, - iconClass: opener.iconClass + id: `file.openWith.${opener.id}` }); export const FILE_RENAME: Command = { id: 'file.rename', From c335410b778a69104a08c984ed06fffa103cb3e2 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 15 Nov 2018 11:10:43 +0000 Subject: [PATCH 34/49] fix #3506: split terminal command Signed-off-by: Anton Kosyakov --- .../browser/terminal-frontend-contribution.ts | 59 +++++++++++++++---- .../src/browser/terminal-frontend-module.ts | 3 +- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index d261be7c4a64d..e36f0a659d187 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -26,8 +26,9 @@ import { } from '@theia/core/lib/common'; import { ApplicationShell, KeybindingContribution, KeyCode, Key, - KeyModifier, KeybindingRegistry + KeyModifier, KeybindingRegistry, Widget } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { WidgetManager } from '@theia/core/lib/browser'; import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions } from './terminal-widget-impl'; import { TerminalKeybindingContexts } from './terminal-keybinding-contexts'; @@ -62,10 +63,15 @@ export namespace TerminalCommands { category: TERMINAL_CATEGORY, label: 'Open in Terminal' }; + export const SPLIT: Command = { + id: 'terminal:split', + category: TERMINAL_CATEGORY, + label: 'Split Terminal' + }; } @injectable() -export class TerminalFrontendContribution implements TerminalService, CommandContribution, MenuContribution, KeybindingContribution { +export class TerminalFrontendContribution implements TerminalService, CommandContribution, MenuContribution, KeybindingContribution, TabBarToolbarContribution { constructor( @inject(ApplicationShell) protected readonly shell: ApplicationShell, @@ -75,14 +81,13 @@ export class TerminalFrontendContribution implements TerminalService, CommandCon ) { } registerCommands(commands: CommandRegistry): void { - commands.registerCommand(TerminalCommands.NEW); - commands.registerHandler(TerminalCommands.NEW.id, { - isEnabled: () => true, - execute: async () => { - const termWidget = await this.newTerminal({}); - termWidget.start(); - this.activateTerminal(termWidget); - } + commands.registerCommand(TerminalCommands.NEW, { + execute: () => this.openTerminal() + }); + commands.registerCommand(TerminalCommands.SPLIT, { + execute: widget => this.splitTerminal(widget), + isEnabled: widget => !!this.getTerminalRef(widget), + isVisible: widget => !!this.getTerminalRef(widget) }); commands.registerCommand(TerminalCommands.TERMINAL_CLEAR); @@ -117,11 +122,24 @@ export class TerminalFrontendContribution implements TerminalService, CommandCon label: 'New Terminal', order: '0' }); + menus.registerMenuAction(TerminalMenus.TERMINAL_NEW, { + commandId: TerminalCommands.SPLIT.id, + order: '1' + }); menus.registerMenuAction(TerminalMenus.TERMINAL_NAVIGATOR_CONTEXT_MENU, { commandId: TerminalCommands.TERMINAL_CONTEXT.id }); } + registerToolbarItems(toolbar: TabBarToolbarRegistry): void { + toolbar.registerItem({ + id: TerminalCommands.SPLIT.id, + command: TerminalCommands.SPLIT.id, + text: '$(columns)', + tooltip: TerminalCommands.SPLIT.label + }); + } + registerKeybindings(keybindings: KeybindingRegistry): void { keybindings.registerKeybinding({ command: TerminalCommands.NEW.id, @@ -225,11 +243,28 @@ export class TerminalFrontendContribution implements TerminalService, CommandCon return widget; } - activateTerminal(widget: TerminalWidget): void { + activateTerminal(widget: TerminalWidget, options: ApplicationShell.WidgetOptions = { area: 'bottom' }): void { const tabBar = this.shell.getTabBarFor(widget); if (!tabBar) { - this.shell.addWidget(widget, { area: 'bottom' }); + this.shell.addWidget(widget, options); } this.shell.activateWidget(widget.id); } + + protected async splitTerminal(widget?: Widget): Promise { + const ref = this.getTerminalRef(widget); + if (ref) { + await this.openTerminal({ ref, mode: 'split-right' }); + } + } + protected getTerminalRef(widget?: Widget): TerminalWidget | undefined { + const ref = widget ? widget : this.shell.currentWidget; + return ref instanceof TerminalWidget ? ref : undefined; + } + + protected async openTerminal(options?: ApplicationShell.WidgetOptions): Promise { + const termWidget = await this.newTerminal({}); + termWidget.start(); + this.activateTerminal(termWidget, options); + } } diff --git a/packages/terminal/src/browser/terminal-frontend-module.ts b/packages/terminal/src/browser/terminal-frontend-module.ts index 0a8a22c260fa5..370315dcf8966 100644 --- a/packages/terminal/src/browser/terminal-frontend-module.ts +++ b/packages/terminal/src/browser/terminal-frontend-module.ts @@ -17,6 +17,7 @@ import { ContainerModule, Container } from 'inversify'; import { CommandContribution, MenuContribution } from '@theia/core/lib/common'; import { KeybindingContribution, WebSocketConnectionProvider, WidgetFactory, KeybindingContext } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { TerminalFrontendContribution } from './terminal-frontend-contribution'; import { TerminalWidgetImpl, TERMINAL_WIDGET_FACTORY_ID } from './terminal-widget-impl'; import { TerminalWidget, TerminalWidgetOptions } from './base/terminal-widget'; @@ -59,7 +60,7 @@ export default new ContainerModule(bind => { bind(TerminalFrontendContribution).toSelf().inSingletonScope(); bind(TerminalService).toService(TerminalFrontendContribution); - for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution]) { + for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution, TabBarToolbarContribution]) { bind(identifier).toService(TerminalFrontendContribution); } From 103c95e495f5a0739e70ea6ef14fe2489e1ddfe8 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 15 Nov 2018 16:24:15 +0000 Subject: [PATCH 35/49] [shell] get rid of WidgetTracker Signed-off-by: Anton Kosyakov --- .../browser/frontend-application-module.ts | 6 +-- .../src/browser/shell/application-shell.ts | 33 ++++++++++++---- packages/core/src/browser/shell/tab-bars.ts | 4 +- .../src/browser/shell/theia-dock-panel.ts | 7 +++- packages/core/src/browser/widgets/widget.ts | 39 +------------------ .../git/src/browser/git-view-contribution.ts | 2 + .../src/browser/preview-contribution.ts | 2 + .../browser/terminal-frontend-contribution.ts | 1 + 8 files changed, 40 insertions(+), 54 deletions(-) diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 51d5695be4e9b..b8af912340a86 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -62,7 +62,6 @@ 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'; export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => { const themeService = ThemeService.get(); @@ -76,7 +75,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(ApplicationShellOptions).toConstantValue({}); bind(ApplicationShell).toSelf().inSingletonScope(); - bind(WidgetTracker).toService(ApplicationShell); bind(SidePanelHandlerFactory).toAutoFactory(SidePanelHandler); bind(SidePanelHandler).toSelf(); bind(SplitPositionHandler).toSelf().inSingletonScope(); @@ -91,12 +89,12 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo return new TabBarToolbar(commandRegistry, labelParser); }); - bind(DockPanelRendererFactory).toFactory(context => (widgetTracker: WidgetTracker) => { + bind(DockPanelRendererFactory).toFactory(context => () => { const { container } = context; const tabBarToolbarRegistry = container.get(TabBarToolbarRegistry); const tabBarRendererFactory: () => TabBarRenderer = container.get(TabBarRendererFactory); const tabBarToolbarFactory: () => TabBarToolbar = container.get(TabBarToolbarFactory); - return new DockPanelRenderer(tabBarRendererFactory, tabBarToolbarRegistry, widgetTracker, tabBarToolbarFactory); + return new DockPanelRenderer(tabBarRendererFactory, tabBarToolbarRegistry, tabBarToolbarFactory); }); bind(DockPanelRenderer).toSelf(); bind(TabBarRendererFactory).toFactory(context => () => { diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index ac64732c12d8a..c7effdadf06d0 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -32,7 +32,6 @@ import { TabBarRendererFactory, TabBarRenderer, SHELL_TABBAR_CONTEXT_MENU, Scrol import { SplitPositionHandler, SplitPositionOptions } from './split-panels'; import { FrontendApplicationStateService } from '../frontend-application-state'; import { TabBarToolbarRegistry, TabBarToolbarFactory, TabBarToolbar } from './tab-bar-toolbar'; -import { WidgetTracker } from '../widgets'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -50,7 +49,7 @@ const LAYOUT_DATA_VERSION = '2.0'; export const ApplicationShellOptions = Symbol('ApplicationShellOptions'); export const DockPanelRendererFactory = Symbol('DockPanelRendererFactory'); export interface DockPanelRendererFactory { - (widgetTracker: WidgetTracker): DockPanelRenderer + (): DockPanelRenderer } /** @@ -64,13 +63,12 @@ export class DockPanelRenderer implements DockLayout.IRenderer { constructor( @inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: () => TabBarRenderer, @inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, - @inject(WidgetTracker) protected readonly widgetTracker: WidgetTracker, @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: () => TabBarToolbar ) { } createTabBar(): TabBar { const renderer = this.tabBarRendererFactory(); - const tabBar = new ToolbarAwareTabBar(this.tabBarToolbarRegistry, this.widgetTracker, this.tabBarToolbarFactory, { + const tabBar = new ToolbarAwareTabBar(this.tabBarToolbarRegistry, this.tabBarToolbarFactory, { renderer, // Scroll bar options handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], @@ -113,7 +111,7 @@ interface WidgetDragState { * add, remove, or activate a widget. */ @injectable() -export class ApplicationShell extends Widget implements WidgetTracker { +export class ApplicationShell extends Widget { /** * The dock panel in the main shell area. This is where editors usually go to. @@ -164,7 +162,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { * Construct a new application shell. */ constructor( - @inject(DockPanelRendererFactory) protected dockPanelRendererFactory: (widgetTracker: WidgetTracker) => DockPanelRenderer, + @inject(DockPanelRendererFactory) protected dockPanelRendererFactory: () => DockPanelRenderer, @inject(StatusBarImpl) protected readonly statusBar: StatusBarImpl, @inject(SidePanelHandlerFactory) sidePanelHandlerFactory: () => SidePanelHandler, @inject(SplitPositionHandler) protected splitPositionHandler: SplitPositionHandler, @@ -364,7 +362,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { * Create the dock panel in the main shell area. */ protected createMainPanel(): TheiaDockPanel { - const renderer = this.dockPanelRendererFactory(this); + const renderer = this.dockPanelRendererFactory(); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(MAIN_AREA_CLASS); const dockPanel = new TheiaDockPanel({ @@ -380,7 +378,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { * Create the dock panel in the bottom shell area. */ protected createBottomPanel(): TheiaDockPanel { - const renderer = this.dockPanelRendererFactory(this); + const renderer = this.dockPanelRendererFactory(); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(BOTTOM_AREA_CLASS); const dockPanel = new TheiaDockPanel({ @@ -709,14 +707,29 @@ export class ApplicationShell extends Widget implements WidgetTracker { } } + /** + * 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. + */ get currentWidget(): Widget | undefined { return this.tracker.currentWidget || undefined; } + /** + * The active widget in the application shell. The active widget is the one that has focus + * (either the widget itself or any of its contents). + * + * _Note:_ Focus is taken by a widget through the `onActivateRequest` method. It is up to the + * widget implementation which DOM element will get the focus. The default implementation + * does not take any focus; in that case the widget is never returned by this property. + */ get activeWidget(): Widget | undefined { return this.tracker.activeWidget || undefined; } + /** + * A signal emitted whenever the `currentWidget` property is changed. + */ readonly currentChanged = new Signal>(this); /** @@ -726,6 +739,9 @@ export class ApplicationShell extends Widget implements WidgetTracker { this.currentChanged.emit(args); } + /** + * A signal emitted whenever the `activeWidget` property is changed. + */ readonly activeChanged = new Signal>(this); /** @@ -1094,6 +1110,7 @@ export class ApplicationShell extends Widget implements WidgetTracker { } return undefined; } + protected getAreaPanelFor(widget: Widget): DockPanel | undefined { const title = widget.title; const mainPanelTabBar = this.mainPanel.findTabBar(title); diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 38d4083aa2964..63bb8eed1cedb 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -24,7 +24,6 @@ import { Message } from '@phosphor/messaging'; import { ArrayExt } from '@phosphor/algorithm'; import { ElementExt } from '@phosphor/domutils'; import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar'; -import { WidgetTracker } from '../widgets'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -301,8 +300,6 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { constructor( protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, - // TODO: WidgetTracker is not needed anymore: remove? or make sure that noone can access it from the main container? - protected readonly widgetTracker: WidgetTracker, protected readonly tabBarToolbarFactory: () => TabBarToolbar, protected readonly options?: TabBar.IOptions & PerfectScrollbar.Options) { @@ -352,6 +349,7 @@ export class ToolbarAwareTabBar extends ScrollableTabBar { super.onUpdateRequest(msg); this.updateToolbar(); } + protected updateToolbar(): void { if (!this.toolbar) { return; diff --git a/packages/core/src/browser/shell/theia-dock-panel.ts b/packages/core/src/browser/shell/theia-dock-panel.ts index c047f309220d2..4de56d1e58b11 100644 --- a/packages/core/src/browser/shell/theia-dock-panel.ts +++ b/packages/core/src/browser/shell/theia-dock-panel.ts @@ -53,13 +53,16 @@ export class TheiaDockPanel extends DockPanel { get currentTitle(): Title | undefined { return this._currentTitle; } + get currentTabBar(): TabBar | undefined { return this._currentTitle && this.findTabBar(this._currentTitle); } + findTabBar(title: Title): TabBar | undefined { return find(this.tabBars(), bar => ArrayExt.firstIndexOf(bar.titles, title) > -1); } - markAsCurrent(title: Title | undefined): void { + + markAsCurrent(title: Title | undefined): void { this._currentTitle = title; } @@ -86,6 +89,7 @@ export class TheiaDockPanel extends DockPanel { const next = current && this.nextTabBarInPanel(current); return next && next.currentTitle && next.currentTitle.owner || undefined; } + nextTabBarInPanel(tabBar: TabBar): TabBar | undefined { const tabBars = toArray(this.tabBars()); const index = tabBars.indexOf(tabBar); @@ -100,6 +104,7 @@ export class TheiaDockPanel extends DockPanel { const previous = current && this.previousTabBarInPanel(current); return previous && previous.currentTitle && previous.currentTitle.owner || undefined; } + previousTabBarInPanel(tabBar: TabBar): TabBar | undefined { const tabBars = toArray(this.tabBars()); const index = tabBars.indexOf(tabBar); diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index 5cc3a51948a44..08819d5ffdd5f 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -15,9 +15,8 @@ ********************************************************************************/ import { injectable, decorate, unmanaged } from 'inversify'; -import { Widget, FocusTracker } from '@phosphor/widgets'; +import { Widget } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; -import { Signal } from '@phosphor/signaling'; import { Disposable, DisposableCollection, MaybePromise } from '../../common'; import { KeyCode, KeysOrKeyCodes } from '../keys'; @@ -216,39 +215,3 @@ export function addClipboardListener(element document.removeEventListener(type, documentListener) ); } - -/** - * Tracks the current and active widgets in the application. Also provides access to the currently active and current widgets. - * - * FIXME: remove it from Widget public API, this file should be about BaseWidget, not shell internals - */ -export const WidgetTracker = Symbol('WidgetTracker'); -export interface WidgetTracker { - - /** - * 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. - */ - currentWidget: Widget | undefined; - - /** - * The active widget in the application shell. The active widget is the one that has focus - * (either the widget itself or any of its contents). - * - * _Note:_ Focus is taken by a widget through the `onActivateRequest` method. It is up to the - * widget implementation which DOM element will get the focus. The default implementation - * does not take any focus; in that case the widget is never returned by this property. - */ - activeWidget: Widget | undefined; - - /** - * A signal emitted whenever the `currentWidget` property is changed. - */ - readonly currentChanged: Signal>; - - /** - * A signal emitted whenever the `activeWidget` property is changed. - */ - readonly activeChanged: Signal>; - -} diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 6f81413036915..aefb4fb5e9449 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -301,6 +301,7 @@ export class GitViewContribution extends AbstractViewContribution const options = this.getOpenFileOptions(widget); return options && this.editorManager.open(options.uri, options.options); } + protected getOpenFileOptions(widget?: Widget): GitOpenFileOptions | undefined { const ref = widget ? widget : this.editorManager.currentEditor; if (ref instanceof EditorWidget && DiffUris.isDiffUri(ref.editor.uri)) { @@ -320,6 +321,7 @@ export class GitViewContribution extends AbstractViewContribution } return undefined; } + protected getOpenChangesOptions(widget?: Widget): GitOpenChangesOptions | undefined { const view = this.tryGetWidget(); if (!view) { diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts index 5ac5f79c8c3ed..2ae65dfa03773 100644 --- a/packages/preview/src/browser/preview-contribution.ts +++ b/packages/preview/src/browser/preview-contribution.ts @@ -211,10 +211,12 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler Date: Thu, 15 Nov 2018 17:09:04 +0000 Subject: [PATCH 36/49] render tab bar toolbar with react Signed-off-by: Anton Kosyakov --- ...tab-bar-toolbar.ts => tab-bar-toolbar.tsx} | 98 +++++++------------ packages/core/src/browser/style/tabs.css | 4 - 2 files changed, 36 insertions(+), 66 deletions(-) rename packages/core/src/browser/shell/{tab-bar-toolbar.ts => tab-bar-toolbar.tsx} (63%) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.ts b/packages/core/src/browser/shell/tab-bar-toolbar.tsx similarity index 63% rename from packages/core/src/browser/shell/tab-bar-toolbar.ts rename to packages/core/src/browser/shell/tab-bar-toolbar.tsx index ca3020a8bd7bf..e70135c9b7e46 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar.tsx @@ -14,13 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as React from 'react'; import { inject, injectable, named } from 'inversify'; -import { Widget, BaseWidget } from '../widgets'; +import { Widget, ReactWidget } from '../widgets'; import { LabelParser, LabelIcon } from '../label-parser'; import { ContributionProvider } from '../../common/contribution-provider'; import { FrontendApplicationContribution } from '../frontend-application'; -import { Disposable, DisposableCollection } from '../../common/disposable'; import { CommandRegistry, CommandService } from '../../common/command'; +import { Disposable } from '../../common/disposable'; /** * Factory for instantiating tab-bar toolbars. @@ -33,84 +34,58 @@ export interface TabBarToolbarFactory { /** * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). */ -export class TabBarToolbar extends BaseWidget { +export class TabBarToolbar extends ReactWidget { - protected current: Widget | undefined; - protected readonly items = new Map(); - protected readonly toDisposeOnUpdate: DisposableCollection = new DisposableCollection(); + protected current: Widget | undefined; + protected items = new Map(); constructor(protected readonly commandService: CommandService, protected readonly labelParser: LabelParser) { super(); - this.toDispose.push(Disposable.create(() => this.removeItems())); this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR); - this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_HIDDEN); + this.hide(); } updateItems(items: TabBarToolbarItem[], current: Widget | undefined): void { + this.items = new Map(items.sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse().map(item => [item.id, item] as [string, TabBarToolbarItem])); this.current = current; - const copy = items.slice().sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse(); - if (this.areSame(copy, Array.from(this.items.keys()))) { - return; + if (!this.items.size) { + this.hide(); } - this.toDisposeOnUpdate.dispose(); - this.removeItems(); - this.createItems(copy); + this.onRender.push(Disposable.create(() => { + if (this.items.size) { + this.show(); + } + })); + this.update(); } - protected removeItems(): void { - for (const element of this.items.values()) { - const { parentElement } = element; - if (parentElement) { - parentElement.removeChild(element); - } - } - this.items.clear(); + protected render(): React.ReactNode { + return + {[...this.items.values()].map(item => this.renderItem(item))} + ; } - protected createItems(items: TabBarToolbarItem[]): void { - for (const item of items) { - const itemContainer = document.createElement('div'); - itemContainer.classList.add(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM); - for (const labelPart of this.labelParser.parse(item.text)) { - const child = document.createElement('div'); - const listener = () => this.commandService.executeCommand(item.command, this.current); - child.addEventListener('click', listener); - this.toDisposeOnUpdate.push(Disposable.create(() => itemContainer.removeEventListener('click', listener))); - if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { - const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; - child.classList.add(...className.split(' ')); - } else { - child.innerText = labelPart; - } - if (item.tooltip) { - child.title = item.tooltip; - } - itemContainer.appendChild(child); + protected renderItem(item: TabBarToolbarItem): React.ReactNode { + let innerText = ''; + const classNames = []; + for (const labelPart of this.labelParser.parse(item.text)) { + if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { + const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; + classNames.push(...className.split(' ')); + } else { + innerText = labelPart; } - this.node.appendChild(itemContainer); - this.items.set(item, itemContainer); - } - if (this.items.size === 0) { - this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_HIDDEN); - } else { - this.removeClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR_HIDDEN); } + return
+
{innerText}
+
; } - /** - * `true` if `left` and `right` contains the same items in the same order. Otherwise, `false`. - * We consider two items the same, if the IDs of the corresponding items are the same. - */ - protected areSame(left: TabBarToolbarItem[], right: TabBarToolbarItem[]): boolean { - if (left.length === right.length) { - for (let i = 0; i < left.length; i++) { - if (left[0].id !== right[0].id) { - return false; - } - } - return true; + protected executeCommand = (e: React.MouseEvent) => { + const item = this.items.get(e.currentTarget.id); + if (item) { + this.commandService.executeCommand(item.command, this.current); } - return false; } } @@ -121,7 +96,6 @@ export namespace TabBarToolbar { export const TAB_BAR_TOOLBAR = 'p-TabBar-toolbar'; export const TAB_BAR_TOOLBAR_ITEM = 'item'; - export const TAB_BAR_TOOLBAR_HIDDEN = 'hidden'; } diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 36e2d155c5801..ad71c82301e94 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -167,10 +167,6 @@ margin-right: 4px; } -.p-TabBar-toolbar.hidden { - display: none !important; -} - .p-TabBar-content-container { display: flex; flex: 1; From 374976487ccfbfd1d38cdc2c40b7aa81cc18559d Mon Sep 17 00:00:00 2001 From: Simon Marchi Date: Fri, 5 Oct 2018 10:21:30 -0400 Subject: [PATCH 37/49] Include workspace path as part of the fragment part of the URL So let's say you access the workspace /tmp/moo on server potato.com, the URL will look like http(s?)://potato.com/#/tmp/moo Changing workspace using the interface will update the path in the fragment. The goal is that it always the url fragment always reflect whatever workspace you are using. If you find a situation where it doesn't, it's a bug. The motivations of this change: - We can create browser bookmarks for different workspaces on the same server, or access a particular workspace using the browser history. - This makes the "reload" feature more robust. Right now, when you reload, it works because Theia saves (just before exiting) the workspace in the front of ~/recentworkspace.json, and we happen to automatically open the most recent workspace at startup. After this patch, it will work because we will pick up the workspace path from the fragment after reloading). - I want to introduce an option for not opening the most recent workspace at startup (just show the welcome page which shows the list of recent workspaces and let me choose which one I want to open). Enabling this option would break reloading, since reloading relies on opening the most recent workspace. - "Power user" feature: you can easily copy paste a directory path in the URL to open it as a workspace. Change-Id: I73f54451fcf9312110a1f87f3c236c1e4eef7ebd Signed-off-by: Simon Marchi --- .../src/browser/workspace-service.spec.ts | 62 +++++++++++++++---- .../src/browser/workspace-service.ts | 54 +++++++++++++--- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/packages/workspace/src/browser/workspace-service.spec.ts b/packages/workspace/src/browser/workspace-service.spec.ts index 1e3ff4056e28c..a5cd54854ac9a 100644 --- a/packages/workspace/src/browser/workspace-service.spec.ts +++ b/packages/workspace/src/browser/workspace-service.spec.ts @@ -54,7 +54,8 @@ describe('WorkspaceService', () => { const toDispose: Disposable[] = []; let wsService: WorkspaceService; let updateTitleStub: sinon.SinonStub; - let reloadWindowStub: sinon.SinonStub; + // stub of window.location.reload + let windowLocationReloadStub: sinon.SinonStub; let onFilesChangedStub: sinon.SinonStub; let mockFileChangeEmitter: Emitter; @@ -97,11 +98,11 @@ describe('WorkspaceService', () => { // stub the updateTitle() & reloadWindow() function because `document` and `window` are unavailable updateTitleStub = sinon.stub(WorkspaceService.prototype, 'updateTitle').callsFake(() => { }); - reloadWindowStub = sinon.stub(WorkspaceService.prototype, 'reloadWindow').callsFake(() => { }); + windowLocationReloadStub = sinon.stub(window.location, 'reload'); mockFileChangeEmitter = new Emitter(); onFilesChangedStub = sinon.stub(mockFileSystemWatcher, 'onFilesChanged').value(mockFileChangeEmitter.event); toDispose.push(mockFileChangeEmitter); - toRestore.push(...[updateTitleStub, reloadWindowStub, onFilesChangedStub]); + toRestore.push(...[updateTitleStub, windowLocationReloadStub, onFilesChangedStub]); wsService = testContainer.get(WorkspaceService); }); @@ -125,6 +126,7 @@ describe('WorkspaceService', () => { expect((await wsService.roots).length).to.eq(0); expect(wsService.tryGetRoots().length).to.eq(0); expect(updateTitleStub.called).to.be.true; + expect(window.location.hash).to.be.empty; }); it('should reset the exposed roots and title if server returns an invalid or nonexistent file / folder', async () => { @@ -142,12 +144,13 @@ describe('WorkspaceService', () => { expect((await wsService.roots).length).to.eq(0); expect(wsService.tryGetRoots().length).to.eq(0); expect(updateTitleStub.called).to.be.true; + expect(window.location.hash).to.be.empty; }); - ['file:///home/oneFolder', 'file:///home/oneFolder/'].forEach(uriStr => { + ['/home/oneFolder', '/home/oneFolder/'].forEach(uriStr => { it('should set the exposed roots and workspace to the folder returned by server as the most recently used workspace, and start watching that folder', async () => { const stat = { - uri: uriStr, + uri: 'file://' + uriStr, lastModification: 0, isDirectory: true }; @@ -161,12 +164,14 @@ describe('WorkspaceService', () => { expect(wsService.tryGetRoots().length).to.eq(1); expect(wsService.tryGetRoots()[0]).to.eq(stat); expect((mockFileSystemWatcher.watchFileChanges).calledWith(new URI(stat.uri))).to.be.true; + expect(window.location.hash).eq('#' + uriStr); }); }); it('should set the exposed roots and workspace to the folders listed in the workspace file returned by the server, ' + 'and start watching the workspace file and all the folders', async () => { - const workspaceFileUri = 'file:///home/workspaceFile'; + const workspaceFilePath = '/home/workspaceFile'; + const workspaceFileUri = 'file://' + workspaceFilePath; const workspaceFileStat = { uri: workspaceFileUri, lastModification: 0, @@ -194,6 +199,7 @@ describe('WorkspaceService', () => { expect(wsService.tryGetRoots().length).to.eq(2); expect(wsService.tryGetRoots()[0].uri).to.eq(rootA); expect(wsService.tryGetRoots()[1].uri).to.eq(rootB); + expect(window.location.hash).to.eq('#' + workspaceFilePath); expect((>wsService['rootWatchers']).size).to.eq(2); expect((>wsService['rootWatchers']).has(rootA)).to.be.true; @@ -222,6 +228,26 @@ describe('WorkspaceService', () => { expect(wsService.tryGetRoots().length).to.eq(0); expect((mockILogger.error).called).to.be.true; }); + + it('should use the workspace path in the URL fragment, if available', async function() { + const workspacePath = '/home/somewhere'; + window.location.hash = '#' + workspacePath; + const stat = { + uri: 'file://' + workspacePath, + lastModification: 0, + isDirectory: true + }; + (mockFilesystem.getFileStat).resolves(stat); + (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); + + await wsService['init'](); + expect(wsService.workspace).to.eq(stat); + expect((await wsService.roots).length).to.eq(1); + expect(wsService.tryGetRoots().length).to.eq(1); + expect(wsService.tryGetRoots()[0]).to.eq(stat); + expect((mockFileSystemWatcher.watchFileChanges).calledWith(new URI(stat.uri))).to.be.true; + expect(window.location.hash).to.eq('#' + workspacePath); + }); }); describe('onStop() function', () => { @@ -264,13 +290,15 @@ describe('WorkspaceService', () => { .then(() => { done(new Error('WorkspaceService.doOpen() should throw an error but did not')); }).catch(e => { + expect(window.location.hash).to.be.empty; done(); }); }); it('should reload the current window with new uri if preferences["workspace.preserveWindow"] = true and there is an opened current workspace', async () => { mockPreferenceValues['workspace.preserveWindow'] = true; - const newUriStr = 'file:///home/newWorkspaceUri'; + const newPath = '/home/newWorkspaceUri'; + const newUriStr = 'file://' + newPath; const newUri = new URI(newUriStr); const stat = { uri: newUriStr, @@ -282,13 +310,15 @@ describe('WorkspaceService', () => { (wsService['_workspace'] as any) = stat; await wsService['doOpen'](newUri, {}); - expect(reloadWindowStub.called).to.be.true; + expect(windowLocationReloadStub.called).to.be.true; expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newUriStr)).to.be.true; expect(wsService.workspace).to.eq(stat); + expect(window.location.hash).to.eq('#' + newPath); }); it('should keep the old Theia window & open a new window if preferences["workspace.preserveWindow"] = false and there is an opened current workspace', async () => { mockPreferenceValues['workspace.preserveWindow'] = false; + const oldWorkspacePath = '/home/oldWorkspaceUri'; const oldWorkspaceUriStr = 'file:///home/oldWorkspaceUri'; const oldStat = { uri: oldWorkspaceUriStr, @@ -297,6 +327,7 @@ describe('WorkspaceService', () => { }; toRestore.push(sinon.stub(wsService, 'roots').resolves([oldStat])); (wsService['_workspace'] as any) = oldStat; + window.location.hash = '#' + oldWorkspacePath; const newWorkspaceUriStr = 'file:///home/newWorkspaceUri'; const uri = new URI(newWorkspaceUriStr); const newStat = { @@ -309,15 +340,17 @@ describe('WorkspaceService', () => { toRestore.push(stubOpenNewWindow); await wsService['doOpen'](uri, {}); - expect(reloadWindowStub.called).to.be.false; + expect(windowLocationReloadStub.called).to.be.false; expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newWorkspaceUriStr)).to.be.true; expect(stubOpenNewWindow.called).to.be.true; expect(wsService.workspace).to.eq(oldStat); + expect(window.location.hash).to.eq('#' + oldWorkspacePath); }); it('should reload the current window with new uri if preferences["workspace.preserveWindow"] = false and browser blocks the new window being opened', async () => { mockPreferenceValues['workspace.preserveWindow'] = false; - const oldWorkspaceUriStr = 'file:///home/oldWorkspaceUri'; + const oldWorkspacePath = '/home/oldWorkspaceUri'; + const oldWorkspaceUriStr = 'file://' + oldWorkspacePath; const oldStat = { uri: oldWorkspaceUriStr, lastModification: 0, @@ -325,7 +358,9 @@ describe('WorkspaceService', () => { }; toRestore.push(sinon.stub(wsService, 'roots').resolves([oldStat])); (wsService['_workspace'] as any) = oldStat; - const newWorkspaceUriStr = 'file:///home/newWorkspaceUri'; + window.location.hash = '#' + oldWorkspacePath; + const newWorkspacePath = '/home/newWorkspaceUri'; + const newWorkspaceUriStr = 'file://' + newWorkspacePath; const uri = new URI(newWorkspaceUriStr); const newStat = { uri: newWorkspaceUriStr, @@ -338,10 +373,11 @@ describe('WorkspaceService', () => { toRestore.push(stubOpenNewWindow); await wsService['doOpen'](uri, {}); - expect(reloadWindowStub.called).to.be.true; + expect(windowLocationReloadStub.called).to.be.true; expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newWorkspaceUriStr)).to.be.true; expect(stubOpenNewWindow.called).to.be.true; expect(wsService.workspace).to.eq(newStat); + expect(window.location.hash).to.eq('#' + newWorkspacePath); }); }); @@ -354,11 +390,13 @@ describe('WorkspaceService', () => { }; wsService['_workspace'] = stat; wsService['_roots'] = [stat]; + window.location.hash = '#something'; await wsService.close(); expect(wsService.workspace).to.be.undefined; expect((await wsService.roots).length).to.eq(0); expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith('')).to.be.true; + expect(window.location.hash).to.be.empty; }); }); diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index f11bd4e4c8282..bf0aa9e57703c 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -22,7 +22,7 @@ import { WorkspaceServer } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { ILogger, Disposable, DisposableCollection, Emitter, Event } from '@theia/core'; +import { ILogger, Disposable, DisposableCollection, Emitter, Event, MaybePromise } from '@theia/core'; import { WorkspacePreferences } from './workspace-preferences'; import * as jsoncparser from 'jsonc-parser'; import * as Ajv from 'ajv'; @@ -69,9 +69,9 @@ export class WorkspaceService implements FrontendApplicationContribution { @postConstruct() protected async init(): Promise { - const workspaceUri = await this.server.getMostRecentlyUsedWorkspace(); - const workspaceFileStat = await this.toFileStat(workspaceUri); - await this.setWorkspace(workspaceFileStat); + const wpUriString = await this.getDefaultWorkspacePath(); + const wpStat = await this.toFileStat(wpUriString); + await this.setWorkspace(wpStat); this.watcher.onFilesChanged(event => { if (this._workspace && FileChangeEvent.isAffected(event, new URI(this._workspace.uri))) { @@ -86,6 +86,29 @@ export class WorkspaceService implements FrontendApplicationContribution { }); } + /** + * Get the path of the workspace to use initially. + */ + protected getDefaultWorkspacePath(): MaybePromise { + // Prefer the workspace path specified as the URL fragment, if present. + if (window.location.hash.length > 1) { + // Remove the leading #. + const wpPath = window.location.hash.substring(1); + return new URI().withPath(wpPath).withScheme('file').toString(); + } else { + // Else, ask the server for its suggested workspace (usually the one + // specified on the CLI, or the most recent). + return this.server.getMostRecentlyUsedWorkspace(); + } + } + + /** + * Set the URL fragment to the given workspace path. + */ + protected setURLFragment(workspacePath: string): void { + window.location.hash = workspacePath; + } + get roots(): Promise { return this.deferredRoots.promise; } @@ -109,7 +132,11 @@ export class WorkspaceService implements FrontendApplicationContribution { this.toDisposeOnWorkspace.dispose(); this._workspace = workspaceStat; if (this._workspace) { - this.toDisposeOnWorkspace.push(await this.watcher.watchFileChanges(new URI(this._workspace.uri))); + const uri = new URI(this._workspace.uri); + this.toDisposeOnWorkspace.push(await this.watcher.watchFileChanges(uri)); + this.setURLFragment(uri.path.toString()); + } else { + this.setURLFragment(''); } this.updateTitle(); await this.updateWorkspace(); @@ -354,11 +381,13 @@ export class WorkspaceService implements FrontendApplicationContribution { } protected openWindow(uri: FileStat, options?: WorkspaceInput): void { + const workspacePath = new URI(uri.uri).path.toString(); + if (this.shouldPreserveWindow(options)) { this.reloadWindow(); } else { try { - this.openNewWindow(); + this.openNewWindow(workspacePath); } catch (error) { // Fall back to reloading the current window in case the browser has blocked the new window this._workspace = uri; @@ -368,11 +397,20 @@ export class WorkspaceService implements FrontendApplicationContribution { } protected reloadWindow(): void { + // Set the new workspace path as the URL fragment. + if (this._workspace !== undefined) { + this.setURLFragment(new URI(this._workspace.uri).path.toString()); + } else { + this.setURLFragment(''); + } + window.location.reload(true); } - protected openNewWindow(): void { - this.windowService.openNewWindow(window.location.href); + protected openNewWindow(workspacePath: string): void { + const url = new URL(window.location.href); + url.hash = workspacePath; + this.windowService.openNewWindow(url.toString()); } protected shouldPreserveWindow(options?: WorkspaceInput): boolean { From a64f73e8aec6b8180b3f0a95a74acce4b51278e1 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Fri, 16 Nov 2018 22:57:29 +0100 Subject: [PATCH 38/49] GH-3297: Set and use an env variable as the app project path in electron Closes: #3297 Signed-off-by: Akos Kitta --- .../src/generator/frontend-generator.ts | 8 +++++++- packages/core/src/node/backend-application.ts | 13 ++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index 5e61b5559b80d..a56838cc28d94 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -102,7 +102,7 @@ if (process.env.LC_ALL) { process.env.LC_NUMERIC = 'C'; const electron = require('electron'); -const { join } = require('path'); +const { join, resolve } = require('path'); const { isMaster } = require('cluster'); const { fork } = require('child_process'); const { app, BrowserWindow, ipcMain, Menu } = electron; @@ -176,6 +176,12 @@ if (isMaster) { const loadMainWindow = (port) => { mainWindow.loadURL('file://' + join(__dirname, '../../lib/index.html') + '?port=' + port); }; + + // We cannot use the \`process.cwd()\` as the application project path (the location of the \`package.json\` in other words) + // in a bundled electron application because it depends on the way we start it. For instance, on OS X, these are a differences: + // https://github.com/theia-ide/theia/issues/3297#issuecomment-439172274 + process.env.THEIA_APP_PROJECT_PATH = resolve(__dirname, '..', '..'); + const mainPath = join(__dirname, '..', 'backend', 'main'); // We need to distinguish between bundled application and development mode when starting the clusters. // See: https://github.com/electron/electron/issues/6337#issuecomment-230183287 diff --git a/packages/core/src/node/backend-application.ts b/packages/core/src/node/backend-application.ts index 8f7a534c1bfa9..393ac6f708c33 100644 --- a/packages/core/src/node/backend-application.ts +++ b/packages/core/src/node/backend-application.ts @@ -18,7 +18,6 @@ import * as http from 'http'; import * as https from 'https'; import * as express from 'express'; import * as yargs from 'yargs'; -import * as path from 'path'; import * as fs from 'fs-extra'; import { inject, named, injectable } from 'inversify'; import { ILogger, ContributionProvider, MaybePromise } from '../common'; @@ -74,13 +73,13 @@ export class BackendApplicationCliContribution implements CliContribution { } protected appProjectPath(): string { - const cwd = process.cwd(); - // Check whether we are in bundled application or development mode. - // In a bundled electron application, the `package.json` is in `resources/app` by default. - if (environment.electron.is() && !environment.electron.isDevMode()) { - return path.join(cwd, 'resources', 'app'); + if (environment.electron.is()) { + if (process.env.THEIA_APP_PROJECT_PATH) { + return process.env.THEIA_APP_PROJECT_PATH; + } + throw new Error('The \'THEIA_APP_PROJECT_PATH\' environment variable must be set when running in electron.'); } - return cwd; + return process.cwd(); } } From 96ed159badbe10279af4d492d9dc11f038829ddf Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Sun, 18 Nov 2018 16:43:23 +0100 Subject: [PATCH 39/49] GH-3297: Added `fix-path`. macOS GUI applications does not inherit the `PATH` by default. Signed-off-by: Akos Kitta --- .../src/generator/frontend-generator.ts | 6 +++ packages/core/package.json | 1 + yarn.lock | 43 ++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index a56838cc28d94..5c0e0e0e6b3e6 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -92,6 +92,12 @@ module.exports = Promise.resolve()${this.compileFrontendModuleImports(frontendMo protected compileElectronMain(): string { return `// @ts-check +// Useful for Electron/NW.js apps as GUI apps on macOS doesn't inherit the \`$PATH\` define +// in your dotfiles (.bashrc/.bash_profile/.zshrc/etc). +// https://github.com/electron/electron/issues/550#issuecomment-162037357 +// https://github.com/theia-ide/theia/pull/3534#issuecomment-439689082 +require('fix-path')(); + // Workaround for https://github.com/electron/electron/issues/9225. Chrome has an issue where // in certain locales (e.g. PL), image metrics are wrongly computed. We explicitly set the // LC_NUMERIC to prevent this from happening (selects the numeric formatting category of the diff --git a/packages/core/package.json b/packages/core/package.json index 70c3a55a44b13..cc012ae540d87 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "es6-promise": "^4.2.4", "express": "^4.16.3", "file-icons-js": "^1.0.3", + "fix-path": "^2.1.0", "font-awesome": "^4.7.0", "fuzzy": "^0.1.3", "inversify": "^4.14.0", diff --git a/yarn.lock b/yarn.lock index 63c64671bb485..65bc9f471a08f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ cross-spawn-async@^2.1.1: lru-cache "^4.0.0" which "^1.2.8" -cross-spawn@^4: +cross-spawn@^4, cross-spawn@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-4.0.2.tgz#7b9247621c23adfdd3856004a823cbe397424d41" dependencies: @@ -3150,6 +3150,11 @@ default-require-extensions@^1.0.0: dependencies: strip-bom "^2.0.0" +default-shell@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/default-shell/-/default-shell-1.0.1.tgz#752304bddc6174f49eb29cb988feea0b8813c8bc" + integrity sha1-dSMEvdxhdPSespy5iP7qC4gTyLw= + defaults@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" @@ -3666,6 +3671,19 @@ execa@^0.2.2: path-key "^1.0.0" strip-eof "^1.0.0" +execa@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.5.1.tgz#de3fb85cb8d6e91c85bcbceb164581785cb57b36" + integrity sha1-3j+4XLjW6RyFvLzrFkWBeFy1ezY= + dependencies: + cross-spawn "^4.0.0" + get-stream "^2.2.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" @@ -4025,6 +4043,13 @@ first-chunk-stream@^2.0.0: dependencies: readable-stream "^2.0.2" +fix-path@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fix-path/-/fix-path-2.1.0.tgz#72ece739de9af4bd63fd02da23e9a70c619b4c38" + integrity sha1-cuznOd6a9L1j/QLaI+mnDGGbTDg= + dependencies: + shell-path "^2.0.0" + flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" @@ -8648,6 +8673,22 @@ shebang-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" +shell-env@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/shell-env/-/shell-env-0.3.0.tgz#2250339022989165bda4eb7bf383afeaaa92dc34" + integrity sha1-IlAzkCKYkWW9pOt784Ov6qqS3DQ= + dependencies: + default-shell "^1.0.0" + execa "^0.5.0" + strip-ansi "^3.0.0" + +shell-path@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/shell-path/-/shell-path-2.1.0.tgz#ea7d06ae1070874a1bac5c65bb9bdd62e4f67a38" + integrity sha1-6n0GrhBwh0obrFxlu5vdYuT2ejg= + dependencies: + shell-env "^0.3.0" + shelljs@^0.8.0, shelljs@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.2.tgz#345b7df7763f4c2340d584abb532c5f752ca9e35" From e7c419eb92d18df128acc433edcf0a26f15e887b Mon Sep 17 00:00:00 2001 From: Nigel Westbury Date: Wed, 7 Nov 2018 16:22:09 +0000 Subject: [PATCH 40/49] Fix Windows drive root conversion Signed-off-by: Nigel Westbury --- packages/core/src/node/file-uri.spec.ts | 9 +++++++++ packages/core/src/node/file-uri.ts | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/core/src/node/file-uri.spec.ts b/packages/core/src/node/file-uri.spec.ts index ab6877acdc065..6e7cb66c8004b 100644 --- a/packages/core/src/node/file-uri.spec.ts +++ b/packages/core/src/node/file-uri.spec.ts @@ -18,6 +18,7 @@ import * as os from 'os'; import * as path from 'path'; import * as chai from 'chai'; import { FileUri } from './file-uri'; +import { isWindows } from '../common/os'; const expect = chai.expect; @@ -64,4 +65,12 @@ describe('file-uri', () => { expect(uri.toString(true)).to.be.equal('file:///c:/'); }); + it('from file:///c%3A', function () { + if (!isWindows) { + this.skip(); + return; + } + const fsPath = FileUri.fsPath('file:///c%3A'); + expect(fsPath).to.be.equal('c:\\'); + }); }); diff --git a/packages/core/src/node/file-uri.ts b/packages/core/src/node/file-uri.ts index bbb7d3303eb6e..ffef213a7059d 100644 --- a/packages/core/src/node/file-uri.ts +++ b/packages/core/src/node/file-uri.ts @@ -16,9 +16,12 @@ import Uri from 'vscode-uri'; import URI from '../common/uri'; +import { isWindows } from '../common/os'; export namespace FileUri { + const windowsDriveRegex = /^([^:/?#]+?):$/; + /** * Creates a new file URI from the filesystem path argument. * @param fsPath the filesystem path. @@ -36,8 +39,22 @@ export namespace FileUri { if (typeof uri === 'string') { return fsPath(new URI(uri)); } else { + /* + * A uri for the root of a Windows drive, eg file:\\\c%3A, is converted to c: + * by the Uri class. However file:\\\c%3A is unambiguously a uri to the root of + * the drive and c: is interpreted as the default directory for the c drive + * (by, for example, the readdir function in the fs-extra module). + * A backslash must be appended to the drive, eg c:\, to ensure the correct path. + */ // tslint:disable-next-line:no-any - return (uri as any).codeUri.fsPath; + const fsPathFromVsCodeUri = (uri as any).codeUri.fsPath; + if (isWindows) { + const isWindowsDriveRoot = windowsDriveRegex.exec(fsPathFromVsCodeUri); + if (isWindowsDriveRoot) { + return fsPathFromVsCodeUri + '\\'; + } + } + return fsPathFromVsCodeUri; } } From 804dc36e978b71cf121d3d208733e593cec7be2a Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Tue, 20 Nov 2018 08:58:51 +0100 Subject: [PATCH 41/49] GH-633: Fixed the incorrect `beforeEach`/`afterEach` test scopes. `sinon.restore` ran for other tests. E.g.: `file-uri.spec` test module. Signed-off-by: Akos Kitta --- .../src/node/logger-cli-contribution.spec.ts | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/core/src/node/logger-cli-contribution.spec.ts b/packages/core/src/node/logger-cli-contribution.spec.ts index 46d43b6bc1c80..b700a0b11c5a9 100644 --- a/packages/core/src/node/logger-cli-contribution.spec.ts +++ b/packages/core/src/node/logger-cli-contribution.spec.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import * as chai from 'chai'; +import { expect } from 'chai'; import * as yargs from 'yargs'; import * as temp from 'temp'; import * as fs from 'fs'; @@ -26,42 +26,39 @@ import * as sinon from 'sinon'; // Allow creating temporary files, but remove them when we are done. const track = temp.track(); -const expect = chai.expect; - let cli: LogLevelCliContribution; - let consoleErrorSpy: sinon.SinonSpy; -beforeEach(function() { - const container = new Container(); +describe('log-level-cli-contribution', () => { - const module = new ContainerModule(bind => { - bind(LogLevelCliContribution).toSelf().inSingletonScope(); - }); + beforeEach(() => { + const container = new Container(); - container.load(module); + const module = new ContainerModule(bind => { + bind(LogLevelCliContribution).toSelf().inSingletonScope(); + }); - cli = container.get(LogLevelCliContribution); - yargs.reset(); - cli.configure(yargs); + container.load(module); - consoleErrorSpy = sinon.spy(console, 'error'); -}); + cli = container.get(LogLevelCliContribution); + yargs.reset(); + cli.configure(yargs); -afterEach(function() { - consoleErrorSpy.restore(); -}); + consoleErrorSpy = sinon.spy(console, 'error'); + }); -describe('log-level-cli-contribution', function() { + afterEach(() => { + consoleErrorSpy.restore(); + }); - it('should use --log-level flag', async function() { + it('should use --log-level flag', async () => { const args: yargs.Arguments = yargs.parse(['--log-level=debug']); await cli.setArguments(args); expect(cli.defaultLogLevel).eq(LogLevel.DEBUG); }); - it('should read json config file', async function() { + it('should read json config file', async () => { const file = track.openSync(); fs.writeFileSync(file.fd, JSON.stringify({ defaultLevel: 'info', @@ -83,7 +80,7 @@ describe('log-level-cli-contribution', function() { }); }); - it('should use info as default log level', async function() { + it('should use info as default log level', async () => { const args: yargs.Arguments = yargs.parse([]); await cli.setArguments(args); @@ -91,7 +88,7 @@ describe('log-level-cli-contribution', function() { expect(cli.logLevels).eql({}); }); - it('should reject wrong default log level', async function() { + it('should reject wrong default log level', async () => { const file = track.openSync(); fs.writeFileSync(file.fd, JSON.stringify({ defaultLevel: 'potato', @@ -106,7 +103,7 @@ describe('log-level-cli-contribution', function() { sinon.assert.calledWithMatch(consoleErrorSpy, 'Unknown default log level in'); }); - it('should reject wrong logger log level', async function() { + it('should reject wrong logger log level', async () => { const file = track.openSync(); fs.writeFileSync(file.fd, JSON.stringify({ defaultLevel: 'info', @@ -121,13 +118,13 @@ describe('log-level-cli-contribution', function() { sinon.assert.calledWithMatch(consoleErrorSpy, 'Unknown log level for logger hello in'); }); - it('should reject nonexistent config files', async function() { + it('should reject nonexistent config files', async () => { const args: yargs.Arguments = yargs.parse(['--log-config', '/tmp/cacaca']); await cli.setArguments(args); sinon.assert.calledWithMatch(consoleErrorSpy, 'no such file or directory'); }); - it('should reject config file with invalid JSON', async function() { + it('should reject config file with invalid JSON', async () => { const file = track.openSync(); const text = JSON.stringify({ defaultLevel: 'info', @@ -148,7 +145,7 @@ describe('log-level-cli-contribution', function() { // 4) in parallel for a few minutes without failure: // // $ while ./node_modules/.bin/mocha --opts configs/mocha.opts packages/core/lib/node/logger-cli-contribution.spec.js --grep watch; do true; done - it.skip('should watch the config file', async function() { + it.skip('should watch the config file', async () => { let filename: string; { const file = track.openSync(); @@ -199,7 +196,7 @@ describe('log-level-cli-contribution', function() { }); }); - it('should keep original levels when changing the log levels file with a broken one', async function() { + it('should keep original levels when changing the log levels file with a broken one', async function () { this.timeout(5000); const file = track.openSync(); From 8e9e4e7356875e136ae4fe41d88d183c9dc689c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Mar=C3=A9chal?= Date: Mon, 27 Aug 2018 10:32:29 -0400 Subject: [PATCH 42/49] [menus] Do not render empty CompositeNodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When building a menu, we now check if the item is visible. If not, then we do not add it to the menu. This allow us to filter items with invisible children, but that have children nonetheless. Fix #2669. Signed-off-by: Paul Maréchal --- .../src/browser/menu/browser-menu-plugin.ts | 65 +++++++++++++++---- .../menu/electron-main-menu-factory.ts | 49 ++++++++++---- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index b4e8edd88e88e..362bc544c134f 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -146,31 +146,72 @@ class DynamicMenuWidget extends MenuWidget { super.open(x, y, options); } - private updateSubMenus(parent: MenuWidget, menu: CompositeMenuNode, commands: PhosphorCommandRegistry): void { + private updateSubMenus( + parent: MenuWidget, + menu: CompositeMenuNode, + commands: PhosphorCommandRegistry + ): void { + const items = this.buildSubMenus([], menu, commands); + for (const item of items) { + parent.addItem(item); + } + } + + private buildSubMenus( + items: MenuWidget.IItemOptions[], + menu: CompositeMenuNode, + commands: PhosphorCommandRegistry + ): MenuWidget.IItemOptions[] { for (const item of menu.children) { if (item instanceof CompositeMenuNode) { - if (item.label && item.children.length > 0) { - parent.addItem({ - type: 'submenu', - submenu: new DynamicMenuWidget(item, this.options) - }); - } else { - if (item.children.length > 0) { - if (parent.items.length > 0) { - parent.addItem({ + if (item.children.length > 0) { + // do not render empty nodes + + if (item.isSubmenu) { // submenu node + + const submenu = new DynamicMenuWidget(item, this.options); + if (submenu.items.length === 0) { + continue; + } + + items.push({ + type: 'submenu', + submenu, + }); + + } else { // group node + + const submenu = this.buildSubMenus([], item, commands); + if (submenu.length === 0) { + continue; + } + + if (items.length > 0) { + // do not put a separator above the first group + + items.push({ type: 'separator' }); } - this.updateSubMenus(parent, item, commands); + + // render children + items.push(...submenu); } } + } else if (item instanceof ActionMenuNode) { - parent.addItem({ + + if (!commands.isVisible(item.action.commandId)) { + continue; + } + + items.push({ command: item.action.commandId, type: 'command' }); } } + return items; } } diff --git a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts index e105ff3ebdf87..eba03e3b903c9 100644 --- a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts +++ b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts @@ -63,30 +63,53 @@ export class ElectronMainMenuFactory { protected fillMenuTemplate(items: Electron.MenuItemConstructorOptions[], menuModel: CompositeMenuNode): Electron.MenuItemConstructorOptions[] { for (const menu of menuModel.children) { if (menu instanceof CompositeMenuNode) { - if (menu.label) { - // should we create a submenu? - items.push({ - label: menu.label, - submenu: this.fillMenuTemplate([], menu) - }); - } else if (menu.children.length > 0) { - // do not put a separator above the first group - if (items.length > 0) { - // or just a separator? + if (menu.children.length > 0) { + // do not render empty nodes + + if (menu.isSubmenu) { // submenu node + + const submenu = this.fillMenuTemplate([], menu); + if (submenu.length === 0) { + continue; + } + items.push({ - type: 'separator' + label: menu.label, + submenu }); + + } else { // group node + + // process children + const submenu = this.fillMenuTemplate([], menu); + if (submenu.length === 0) { + continue; + } + + if (items.length > 0) { + // do not put a separator above the first group + + items.push({ + type: 'separator' + }); + } + + // render children + items.push(...submenu); } - // followed by the elements - this.fillMenuTemplate(items, menu); } } else if (menu instanceof ActionMenuNode) { const commandId = menu.action.commandId; + // That is only a sanity check at application startup. if (!this.commandRegistry.getCommand(commandId)) { throw new Error(`Unknown command with ID: ${commandId}.`); } + if (!this.commandRegistry.isVisible(commandId)) { + continue; + } + const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId); let accelerator; From 00e7d9d65cdb155fc28095e5782f30764203bab0 Mon Sep 17 00:00:00 2001 From: Casey Flynn Date: Thu, 1 Nov 2018 14:45:59 -0700 Subject: [PATCH 43/49] 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 From f3da35486998777292726a36ca68256942135b40 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Tue, 20 Nov 2018 11:10:08 -0500 Subject: [PATCH 44/49] Fix breaking import 'WidgetTracker' Fixes breaking import 'WidgetTracker' found in `master` Signed-off-by: Vincent Fugnitto --- packages/core/src/browser/frontend-application-module.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 732e89d7ff90b..29db8c1e4ae1c 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -62,7 +62,6 @@ 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) => { From c50e8ef0dcf86bef97f9e0f6ffb28213b0c6f1ba Mon Sep 17 00:00:00 2001 From: elaihau Date: Fri, 16 Nov 2018 11:52:23 -0500 Subject: [PATCH 45/49] unit tests for navigator-model and navigator-tree Signed-off-by: elaihau --- .../src/browser/navigator-model.spec.ts | 319 ++++++++++++++++++ .../navigator/src/browser/navigator-model.ts | 2 +- .../src/browser/navigator-tree.spec.ts | 197 +++++++++++ 3 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 packages/navigator/src/browser/navigator-model.spec.ts create mode 100644 packages/navigator/src/browser/navigator-tree.spec.ts diff --git a/packages/navigator/src/browser/navigator-model.spec.ts b/packages/navigator/src/browser/navigator-model.spec.ts new file mode 100644 index 0000000000000..6670667eced8c --- /dev/null +++ b/packages/navigator/src/browser/navigator-model.spec.ts @@ -0,0 +1,319 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + +import { Container } from 'inversify'; +import { Emitter, ILogger, Logger } from '@theia/core'; +import { + CompositeTreeNode, DefaultOpenerService, ExpandableTreeNode, LabelProvider, OpenerService, + Tree, TreeNode, TreeSelectionService, TreeExpansionService, TreeExpansionServiceImpl, + TreeNavigationService, TreeSearch +} from '@theia/core/lib/browser'; +import { TreeSelectionServiceImpl } from '@theia/core/lib/browser/tree/tree-selection-impl'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import { FileSystemWatcher } from '@theia/filesystem/lib/browser/filesystem-watcher'; +import { FileSystemNode } from '@theia/filesystem/lib/node/node-filesystem'; +import { DirNode, FileChange, FileMoveEvent, FileTreeModel, FileStatNode } from '@theia/filesystem/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { FileNavigatorTree, WorkspaceNode, WorkspaceRootNode } from './navigator-tree'; +import { FileNavigatorModel } from './navigator-model'; +import { expect } from 'chai'; +import URI from '@theia/core/lib/common/uri'; +import * as sinon from 'sinon'; + +disableJSDOM(); + +// tslint:disable:no-any +// tslint:disable:no-unused-expression + +let root: CompositeTreeNode; +let workspaceRootFolder: DirNode; + +let childA: FileStatNode; +let childB: FileStatNode; + +let homeFolder: DirNode; +let childC: FileStatNode; + +let folderA: FileStat; +let folderB: FileStat; + +/** + * The setup function construct a navigator file tree depicted below: + * + * -- root (invisible root node) + * |__ workspaceRootFolder + * |__ childA + * |__ childB + * + * The following nodes are not in the navigator file tree: + * + * -- homeFolder + * |__ childC + * |__ folderA + * |__ folderB + */ +const setup = () => { + root = { id: 'WorkspaceNodeId', name: 'WorkspaceNode', parent: undefined, children: [] }; + workspaceRootFolder = { + parent: root, + uri: new URI('file:///home/rootFolder'), + selected: false, expanded: true, children: [], id: 'id_rootFolder', name: 'name_rootFolder', + fileStat: { uri: 'file:///home/rootFolder', isDirectory: true, lastModification: 0 } + }; + childA = { + id: 'idA', name: 'nameA', parent: workspaceRootFolder, uri: new URI('file:///home/rootFolder/childA'), selected: false, + fileStat: { uri: 'file:///home/rootFolder/childA', isDirectory: true, lastModification: 0 } + }; + childB = { + id: 'idB', name: 'nameB', parent: workspaceRootFolder, uri: new URI('file:///home/rootFolder/childB'), selected: false, + fileStat: { uri: 'file:///home/rootFolder/childB', isDirectory: true, lastModification: 0 } + }; + root.children = [workspaceRootFolder]; + workspaceRootFolder.children = [childA, childB]; + + homeFolder = { + parent: root, + uri: new URI('file:///home'), + selected: false, expanded: true, children: [], id: 'id_rootFolder', name: 'name_rootFolder', + fileStat: { uri: 'file:///home/rootFolder', isDirectory: true, lastModification: 0 } + }; + childC = { + id: 'idC', name: 'nameC', parent: homeFolder, uri: new URI('file:///home/childC'), selected: false, + fileStat: { uri: 'file:///home/childC', isDirectory: false, lastModification: 0 } + }; + homeFolder.children = [childC]; + + folderA = Object.freeze({ + uri: 'file:///home/folderA', + lastModification: 0, + isDirectory: true + }); + folderB = Object.freeze({ + uri: 'file:///home/folderB', + lastModification: 0, + isDirectory: true + }); +}; + +describe('FileNavigatorModel', () => { + let testContainer: Container; + + let mockOpenerService: OpenerService; + let mockFileNavigatorTree: FileNavigatorTree; + let mockWorkspaceService: WorkspaceService; + let mockFilesystem: FileSystem; + let mockLabelProvider: LabelProvider; + let mockFileSystemWatcher: FileSystemWatcher; + let mockILogger: ILogger; + let mockTreeSelectionService: TreeSelectionService; + let mockTreeExpansionService: TreeExpansionService; + let mockTreeNavigationService: TreeNavigationService; + let mockTreeSearch: TreeSearch; + + const mockWorkspaceServiceEmitter: Emitter = new Emitter(); + const mockFileChangeEmitter: Emitter = new Emitter(); + const mockFileMoveEmitter: Emitter = new Emitter(); + const mockTreeChangeEmitter: Emitter = new Emitter(); + const mockExpansionChangeEmitter: Emitter> = new Emitter(); + + let navigatorModel: FileNavigatorModel; + const toRestore: Array = []; + + before(() => { + disableJSDOM = enableJSDOM(); + }); + after(() => { + disableJSDOM(); + }); + + beforeEach(() => { + mockOpenerService = sinon.createStubInstance(DefaultOpenerService); + mockFileNavigatorTree = sinon.createStubInstance(FileNavigatorTree); + mockWorkspaceService = sinon.createStubInstance(WorkspaceService); + mockFilesystem = sinon.createStubInstance(FileSystemNode); + mockLabelProvider = sinon.createStubInstance(LabelProvider); + mockFileSystemWatcher = sinon.createStubInstance(FileSystemWatcher); + mockILogger = sinon.createStubInstance(Logger); + mockTreeSelectionService = sinon.createStubInstance(TreeSelectionServiceImpl); + mockTreeExpansionService = sinon.createStubInstance(TreeExpansionServiceImpl); + mockTreeNavigationService = sinon.createStubInstance(TreeNavigationService); + mockTreeSearch = sinon.createStubInstance(TreeSearch); + + testContainer = new Container(); + testContainer.bind(FileNavigatorModel).toSelf().inSingletonScope(); + testContainer.bind(OpenerService).toConstantValue(mockOpenerService); + testContainer.bind(FileNavigatorTree).toConstantValue(mockFileNavigatorTree); + testContainer.bind(WorkspaceService).toConstantValue(mockWorkspaceService); + testContainer.bind(FileSystem).toConstantValue(mockFilesystem); + testContainer.bind(LabelProvider).toConstantValue(mockLabelProvider); + testContainer.bind(FileSystemWatcher).toConstantValue(mockFileSystemWatcher); + testContainer.bind(ILogger).toConstantValue(mockILogger); + testContainer.bind(Tree).toConstantValue(mockFileNavigatorTree); + testContainer.bind(TreeSelectionService).toConstantValue(mockTreeSelectionService); + testContainer.bind(TreeExpansionService).toConstantValue(mockTreeExpansionService); + testContainer.bind(TreeNavigationService).toConstantValue(mockTreeNavigationService); + testContainer.bind(TreeSearch).toConstantValue(mockTreeSearch); + + sinon.stub(mockWorkspaceService, 'onWorkspaceChanged').value(mockWorkspaceServiceEmitter.event); + sinon.stub(mockFileSystemWatcher, 'onFilesChanged').value(mockFileChangeEmitter.event); + sinon.stub(mockFileSystemWatcher, 'onDidMove').value(mockFileMoveEmitter.event); + sinon.stub(mockFileNavigatorTree, 'onChanged').value(mockTreeChangeEmitter.event); + sinon.stub(mockTreeExpansionService, 'onExpansionChanged').value(mockExpansionChangeEmitter.event); + + setup(); + navigatorModel = testContainer.get(FileNavigatorModel); + }); + afterEach(() => { + toRestore.forEach(res => { + res.restore(); + }); + toRestore.length = 0; + }); + + it('should update the root(s) on receiving a WorkspaceChanged event from the WorkspaceService', done => { + sinon.stub(navigatorModel, 'updateRoot').callsFake(() => { + done(); // This test would time out if updateRoot() is not called + }); + mockWorkspaceServiceEmitter.fire([]); + }).timeout(2000); + + describe('updateRoot() function', () => { + it('should assign "this.root" a WorkspaceNode with WorkspaceRootNodes (one for each root folder in the workspace) as its children', async () => { + sinon.stub(mockWorkspaceService, 'roots').value([folderA, folderB]); + sinon.stub(mockWorkspaceService, 'opened').value(true); + (mockFileNavigatorTree.createWorkspaceRoot).callsFake((stat, rootNode) => + Promise.resolve({ + parent: rootNode, + uri: new URI(stat.uri), + selected: false, expanded: true, children: [], id: 'id_rootFolder', name: 'name_rootFolder', + fileStat: { uri: stat.uri, isDirectory: true, lastModification: 0 } + }) + ); + + await navigatorModel.updateRoot(); + const thisRoot = navigatorModel['root'] as WorkspaceNode; + expect(thisRoot).not.to.be.undefined; + expect(thisRoot.children.length).to.eq(2); + expect(thisRoot.children[0].uri.toString()).to.eq(folderA.uri); + expect(thisRoot.children[1].uri.toString()).to.eq(folderB.uri); + }); + + it('should assign "this.root" undefined if there is no workspace open', async () => { + sinon.stub(mockWorkspaceService, 'opened').value(false); + + await navigatorModel.updateRoot(); + const thisRoot = navigatorModel['root'] as WorkspaceNode; + expect(thisRoot).to.be.undefined; + }); + }); + + describe('move() function', () => { + it('should do nothing if user tries to move a root folder', () => { + const stubMove = sinon.stub(FileTreeModel.prototype, 'move').callsFake(() => { }); + const stubCheckRoot = sinon.stub(WorkspaceRootNode, 'is').returns(true); + toRestore.push(...[stubMove, stubCheckRoot]); + + navigatorModel.move(workspaceRootFolder, childA); + expect(stubMove.called).to.be.false; + }); + + it('should pass argument to move() in FileTreeModel class if the node being moved is not a root folder', () => { + const stubMove = sinon.stub(FileTreeModel.prototype, 'move').callsFake(() => { }); + const stubCheckRoot = sinon.stub(WorkspaceRootNode, 'is').returns(false); + toRestore.push(...[stubMove, stubCheckRoot]); + + navigatorModel.move(childA, workspaceRootFolder); + expect(stubMove.called).to.be.true; + }); + }); + + describe('revealFile() function', () => { + it('should return undefined if the uri to be revealed does not contain an absolute path', async () => { + const ret = await navigatorModel.revealFile(new URI('folderC/untitled')); + expect(ret).to.be.undefined; + }); + + it('should return undefined if node being revealed is not part of the file tree', async () => { + navigatorModel['root'] = root; + (mockFileNavigatorTree.createId).callsFake((rootNode, uri) => `${rootNode ? rootNode.id : 'no_root_node'}:${uri.path.toString()}`); + sinon.stub(navigatorModel, 'getNode').callsFake((id: string | undefined): TreeNode | undefined => { + if (id) { + if (id.endsWith(childA.uri.path.toString())) { + return childA; + } else if (id.endsWith(childB.uri.path.toString())) { + return childB; + } else if (id.endsWith(workspaceRootFolder.uri.path.toString())) { + return workspaceRootFolder; + } else if (id.endsWith(childC.uri.path.toString())) { + return childC; + } + } + return undefined; + }); + const ret = await navigatorModel.revealFile(childC.uri); // childC is not under any root folder of the workspace + expect(ret).to.be.undefined; + }); + + const fakeCreateId = (rootNode: WorkspaceRootNode, uri: URI) => `${rootNode ? rootNode.id : 'no_root_node'}:${uri.path.toString()}`; + const fakeGetNode = (id: string | undefined): TreeNode | undefined => { + if (id) { + if (id.endsWith(childA.uri.path.toString())) { + return childA; + } else if (id.endsWith(childB.uri.path.toString())) { + return childB; + } else if (id.endsWith(workspaceRootFolder.uri.path.toString())) { + return workspaceRootFolder; + } else if (id.endsWith(childC.uri.path.toString())) { + return childC; + } + } + return undefined; + }; + + it('should return undefined if cannot find a node from the file tree', async () => { + navigatorModel['root'] = root; + (mockFileNavigatorTree.createId).callsFake(fakeCreateId); + sinon.stub(navigatorModel, 'getNode').callsFake(fakeGetNode); + + const ret = await navigatorModel.revealFile(childC.uri); + expect(ret).to.be.undefined; + }); + + it('should return the node if the node being revealed is part of the file tree', async () => { + navigatorModel['root'] = root; + (mockFileNavigatorTree.createId).callsFake(fakeCreateId); + sinon.stub(navigatorModel, 'getNode').callsFake(fakeGetNode); + + const ret = await navigatorModel.revealFile(childB.uri); + expect(ret).not.to.be.undefined; + expect(ret && ret.id).to.eq(childB.id); + }); + + it('should return the node and expand the node if the node being revealed is a folder as part of the file tree', async () => { + navigatorModel['root'] = root; + (mockFileNavigatorTree.createId).callsFake(fakeCreateId); + const stubExpand = sinon.stub(navigatorModel, 'expandNode'); + stubExpand.callsFake(() => { }); + sinon.stub(navigatorModel, 'getNode').callsFake(fakeGetNode); + + await navigatorModel.revealFile(Object.assign(childB, { expanded: false, children: [] }).uri); + expect(stubExpand.called).to.be.true; + }); + }); +}); diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts index 60354bd2dcd88..8d22d4e746b3a 100644 --- a/packages/navigator/src/browser/navigator-model.ts +++ b/packages/navigator/src/browser/navigator-model.ts @@ -30,7 +30,7 @@ export class FileNavigatorModel extends FileTreeModel { @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @postConstruct() - protected async init(): Promise { + protected init(): void { this.toDispose.push( this.workspaceService.onWorkspaceChanged(event => { this.updateRoot(); diff --git a/packages/navigator/src/browser/navigator-tree.spec.ts b/packages/navigator/src/browser/navigator-tree.spec.ts new file mode 100644 index 0000000000000..0a13eb9e99aac --- /dev/null +++ b/packages/navigator/src/browser/navigator-tree.spec.ts @@ -0,0 +1,197 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + +import { Container } from 'inversify'; +import { Emitter } from '@theia/core'; +import { CompositeTreeNode, LabelProvider, TreeNode } from '@theia/core/lib/browser'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import { FileSystemNode } from '@theia/filesystem/lib/node/node-filesystem'; +import { DirNode, FileTree } from '@theia/filesystem/lib/browser'; +import { FileNavigatorTree, WorkspaceNode, WorkspaceRootNode } from './navigator-tree'; +import { FileNavigatorFilter } from './navigator-filter'; +import { expect } from 'chai'; +import URI from '@theia/core/lib/common/uri'; +import * as sinon from 'sinon'; + +disableJSDOM(); + +// tslint:disable:no-any +// tslint:disable:no-unused-expression + +let root: CompositeTreeNode; +let workspaceRootFolder: DirNode; +let childA: TreeNode; +let childB: TreeNode; + +/** + * The setup function construct a navigator file tree depicted below: + * + * -- root (invisible root node) + * |__ workspaceRootFolder + * |__ childA + * |__ childB + */ +const setup = () => { + root = { id: 'WorkspaceNodeId', name: 'WorkspaceNode', parent: undefined, children: [] }; + workspaceRootFolder = { + parent: root, + uri: new URI('file:///home/rootFolder'), + selected: false, expanded: true, children: [], id: 'id_rootFolder', name: 'name_rootFolder', + fileStat: { uri: 'file:///home/rootFolder', isDirectory: true, lastModification: 0 } + }; + childA = { id: 'idA', name: 'nameA', parent: workspaceRootFolder }; + childB = { id: 'idB', name: 'nameB', parent: workspaceRootFolder }; + root.children = [workspaceRootFolder]; + workspaceRootFolder.children = [childA, childB]; +}; + +describe('FileNavigatorTree', () => { + let testContainer: Container; + + let mockFileNavigatorFilter: FileNavigatorFilter; + let mockFilesystem: FileSystem; + let mockLabelProvider: LabelProvider; + + const mockFilterChangeEmitter: Emitter = new Emitter(); + + let navigatorTree: FileNavigatorTree; + + before(() => { + disableJSDOM = enableJSDOM(); + }); + after(() => { + disableJSDOM(); + }); + + beforeEach(() => { + mockFileNavigatorFilter = sinon.createStubInstance(FileNavigatorFilter); + mockFilesystem = sinon.createStubInstance(FileSystemNode); + mockLabelProvider = sinon.createStubInstance(LabelProvider); + + testContainer = new Container(); + testContainer.bind(FileNavigatorTree).toSelf().inSingletonScope(); + testContainer.bind(FileNavigatorFilter).toConstantValue(mockFileNavigatorFilter); + testContainer.bind(FileSystem).toConstantValue(mockFilesystem); + testContainer.bind(LabelProvider).toConstantValue(mockLabelProvider); + + sinon.stub(mockFileNavigatorFilter, 'onFilterChanged').value(mockFilterChangeEmitter.event); + setup(); + + navigatorTree = testContainer.get(FileNavigatorTree); + }); + + it('should refresh the tree on filter gets changed', () => { + const stubRefresh = sinon.stub(navigatorTree, 'refresh').callsFake(() => { }); + mockFilterChangeEmitter.fire(undefined); + expect(stubRefresh.called).to.be.true; + }); + + describe('resolveChildren() function', () => { + it('should return the children of the parent node if it is the root node of workspace', async () => { + const children = await navigatorTree.resolveChildren(root); + expect(children.length).to.eq(1); + expect(children[0]).to.deep.eq(workspaceRootFolder); + }); + + it('should return children filtered by FileNavigatorFilter', async () => { + const children = Promise.resolve([childA, childB]); + sinon.stub(FileTree.prototype, 'resolveChildren').returns(children); + await navigatorTree.resolveChildren(workspaceRootFolder); + expect((mockFileNavigatorFilter.filter).calledWith(children)).to.be.true; + }); + }); + + describe('createWorkspaceRoot() function', () => { + it('should pass arguments to toNode() function', async () => { + const stubToNode = sinon.stub(navigatorTree, 'toNode').callsFake(() => { }); + await navigatorTree.createWorkspaceRoot(workspaceRootFolder.fileStat, root); + expect(stubToNode.calledWith(workspaceRootFolder.fileStat, root)).to.be.true; + }); + }); + + describe('createId() function', () => { + it('should return the concatenation of root id + node uri', () => { + const uri = new URI('file:///home/fileC'); + const ret = navigatorTree.createId(workspaceRootFolder, uri); + expect(ret).to.eq(`${workspaceRootFolder.id}:${uri.path.toString()}`); + }); + }); +}); + +describe('WorkspaceNode', () => { + describe('is() function', () => { + it('should return true if the node is a CompositeTreeNode with the name of "WorkspaceNode", otherwise false', () => { + expect(WorkspaceNode.is(undefined)).to.be.false; + + const noNode = { id: 'id', name: 'name', parent: undefined, children: [] }; + expect(WorkspaceNode.is(noNode)).to.be.false; + + // root of the entire navigator file tree + expect(WorkspaceNode.is(root)).to.be.true; + + // tree node + expect(WorkspaceNode.is(childA)).to.be.false; + }); + }); + + describe('createRoot() function', () => { + it('should return a node with the name of "WorkspaceNode" and id of "WorkspaceNodeId"', () => { + expect(WorkspaceNode.createRoot()).to.deep.eq({ + id: 'WorkspaceNodeId', + name: 'WorkspaceNode', + parent: undefined, + children: [], + visible: false, + selected: false + }); + }); + }); +}); + +describe('WorkspaceRootNode', () => { + describe('is() function', () => { + it('should return false if the node is a DirNode with the parent of WorkspaceNode, otherwise false', () => { + expect(WorkspaceRootNode.is(undefined)).to.be.false; + + expect(WorkspaceRootNode.is(workspaceRootFolder)).to.be.true; + + const noNode = { + parent: { id: 'parentId', name: 'parentName', parent: undefined, children: [] }, + uri: new URI('file:///home/folderB'), + selected: false, expanded: true, children: [], id: 'id', name: 'name', + fileStat: { uri: 'file:///home/folderB', isDirectory: true, lastModification: 0 } + }; + expect(WorkspaceRootNode.is(noNode)).to.be.false; + + expect(WorkspaceRootNode.is(childB)).to.be.false; + }); + }); + + describe('find() function', () => { + it('should return the node itself if the node is a WorkspaceRootNode', () => { + expect(WorkspaceRootNode.find(workspaceRootFolder)).to.deep.eq(workspaceRootFolder); + }); + + it('should return the ancestor of the node if the node itself is not a WorkspaceRootNode', () => { + expect(WorkspaceRootNode.find(undefined)).to.be.undefined; + + expect(WorkspaceRootNode.find(childA)).to.deep.eq(workspaceRootFolder); + }); + }); +}); From b387c6e6ad3ac930d395af8351f5af3ac5353178 Mon Sep 17 00:00:00 2001 From: Vincent Fugnitto Date: Tue, 20 Nov 2018 14:56:31 -0500 Subject: [PATCH 46/49] Fix breaking 'navigator-model' tests Added missing mocked `CorePreferences` to the `navigator-model.spec.ts` file which is required for the tests to successfully pass. Signed-off-by: Vincent Fugnitto --- packages/navigator/src/browser/navigator-model.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/navigator/src/browser/navigator-model.spec.ts b/packages/navigator/src/browser/navigator-model.spec.ts index 6670667eced8c..582fa9dc1b873 100644 --- a/packages/navigator/src/browser/navigator-model.spec.ts +++ b/packages/navigator/src/browser/navigator-model.spec.ts @@ -22,7 +22,7 @@ import { Emitter, ILogger, Logger } from '@theia/core'; import { CompositeTreeNode, DefaultOpenerService, ExpandableTreeNode, LabelProvider, OpenerService, Tree, TreeNode, TreeSelectionService, TreeExpansionService, TreeExpansionServiceImpl, - TreeNavigationService, TreeSearch + TreeNavigationService, TreeSearch, CorePreferences } from '@theia/core/lib/browser'; import { TreeSelectionServiceImpl } from '@theia/core/lib/browser/tree/tree-selection-impl'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; @@ -32,6 +32,7 @@ import { DirNode, FileChange, FileMoveEvent, FileTreeModel, FileStatNode } from import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileNavigatorTree, WorkspaceNode, WorkspaceRootNode } from './navigator-tree'; import { FileNavigatorModel } from './navigator-model'; +import { createMockPreferenceProxy } from '@theia/core/lib/browser/preferences/test'; import { expect } from 'chai'; import URI from '@theia/core/lib/common/uri'; import * as sinon from 'sinon'; @@ -125,6 +126,7 @@ describe('FileNavigatorModel', () => { let mockTreeExpansionService: TreeExpansionService; let mockTreeNavigationService: TreeNavigationService; let mockTreeSearch: TreeSearch; + let mockPreferences: CorePreferences; const mockWorkspaceServiceEmitter: Emitter = new Emitter(); const mockFileChangeEmitter: Emitter = new Emitter(); @@ -154,6 +156,7 @@ describe('FileNavigatorModel', () => { mockTreeExpansionService = sinon.createStubInstance(TreeExpansionServiceImpl); mockTreeNavigationService = sinon.createStubInstance(TreeNavigationService); mockTreeSearch = sinon.createStubInstance(TreeSearch); + mockPreferences = createMockPreferenceProxy({}); testContainer = new Container(); testContainer.bind(FileNavigatorModel).toSelf().inSingletonScope(); @@ -169,6 +172,7 @@ describe('FileNavigatorModel', () => { testContainer.bind(TreeExpansionService).toConstantValue(mockTreeExpansionService); testContainer.bind(TreeNavigationService).toConstantValue(mockTreeNavigationService); testContainer.bind(TreeSearch).toConstantValue(mockTreeSearch); + testContainer.bind(CorePreferences).toConstantValue(mockPreferences); sinon.stub(mockWorkspaceService, 'onWorkspaceChanged').value(mockWorkspaceServiceEmitter.event); sinon.stub(mockFileSystemWatcher, 'onFilesChanged').value(mockFileChangeEmitter.event); From 30456dac12b12a838c213cdcf64f79aabe2626da Mon Sep 17 00:00:00 2001 From: Mykola Morhun Date: Wed, 21 Nov 2018 10:03:04 +0200 Subject: [PATCH 47/49] [Plug-in] Fix workspace folder uri transfer Signed-off-by: Mykola Morhun --- packages/plugin-ext/src/api/model.ts | 11 ++++ .../src/main/browser/workspace-main.ts | 8 +-- .../plugin-ext/src/plugin/plugin-context.ts | 2 +- .../plugin-ext/src/plugin/type-converters.ts | 63 +++++++++---------- packages/plugin-ext/src/plugin/workspace.ts | 42 ++++++------- 5 files changed, 67 insertions(+), 59 deletions(-) diff --git a/packages/plugin-ext/src/api/model.ts b/packages/plugin-ext/src/api/model.ts index 78db956bc9cf9..4131ec0180e67 100644 --- a/packages/plugin-ext/src/api/model.ts +++ b/packages/plugin-ext/src/api/model.ts @@ -299,3 +299,14 @@ export interface WorkspaceEdit { edits: Array; rejectReason?: string; } + +export interface WorkspaceFoldersChangeEvent { + added: WorkspaceFolder[]; + removed: WorkspaceFolder[]; +} + +export interface WorkspaceFolder { + uri: UriComponents; + name: string; + index: number; +} diff --git a/packages/plugin-ext/src/main/browser/workspace-main.ts b/packages/plugin-ext/src/main/browser/workspace-main.ts index 6e0977e280f04..be8984e4827c8 100644 --- a/packages/plugin-ext/src/main/browser/workspace-main.ts +++ b/packages/plugin-ext/src/main/browser/workspace-main.ts @@ -55,7 +55,7 @@ export class WorkspaceMainImpl implements WorkspaceMain { }); } - notifyWorkspaceFoldersChanged() { + notifyWorkspaceFoldersChanged(): void { if (this.roots && this.roots.length) { const folders = this.roots.map(root => { const uri = Uri.parse(root.uri); @@ -64,18 +64,18 @@ export class WorkspaceMainImpl implements WorkspaceMain { uri: uri, name: path.base, index: 0 - } as theia.WorkspaceFolder; + }; }); this.proxy.$onWorkspaceFoldersChanged({ added: folders, removed: [] - } as theia.WorkspaceFoldersChangeEvent); + }); } else { this.proxy.$onWorkspaceFoldersChanged({ added: [], removed: [] - } as theia.WorkspaceFoldersChangeEvent); + }); } } diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index a06d8851b2d4c..e5e788b2a8831 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -165,7 +165,7 @@ export function createAPIFactory( return quickOpenExt.showQuickPick(items, options); } }, - showWorkspaceFolderPick(options?: theia.WorkspaceFolderPickOptions) { + showWorkspaceFolderPick(options?: theia.WorkspaceFolderPickOptions): PromiseLike { return workspaceExt.pickWorkspaceFolder(options); }, showInformationMessage(message: string, diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index cafd842b31d8e..74dd9b802db8f 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -15,23 +15,12 @@ ********************************************************************************/ import { EditorPosition, Selection, Position, DecorationOptions, WorkspaceEditDto, ResourceTextEditDto, ResourceFileEditDto } from '../api/plugin-api'; -import { - Range, - Hover, - MarkdownString, - CompletionType, - SingleEditOperation, - MarkerData, - RelatedInformation, - Location, - DefinitionLink, - DocumentLink, - Command -} from '../api/model'; +import * as model from '../api/model'; import * as theia from '@theia/plugin'; import * as types from './types-impl'; import { LanguageSelector, LanguageFilter, RelativePattern } from './languages'; import { isMarkdownString } from './markdown-string'; +import URI from 'vscode-uri'; export function toViewColumn(ep?: EditorPosition): theia.ViewColumn | undefined { if (typeof ep !== 'number') { @@ -66,7 +55,7 @@ export function fromSelection(selection: types.Selection): Selection { }; } -export function toRange(range: Range): types.Range { +export function toRange(range: model.Range): types.Range { // if (!range) { // return undefined; // } @@ -75,7 +64,7 @@ export function toRange(range: Range): types.Range { return new types.Range(startLineNumber - 1, startColumn - 1, endLineNumber - 1, endColumn - 1); } -export function fromRange(range: theia.Range | undefined): Range | undefined { +export function fromRange(range: theia.Range | undefined): model.Range | undefined { if (!range) { return undefined; } @@ -135,7 +124,7 @@ export function fromRangeOrRangeWithMessage(ranges: theia.Range[] | theia.Decora } } -export function fromManyMarkdown(markup: (theia.MarkdownString | theia.MarkedString)[]): MarkdownString[] { +export function fromManyMarkdown(markup: (theia.MarkdownString | theia.MarkedString)[]): model.MarkdownString[] { return markup.map(fromMarkdown); } @@ -151,7 +140,7 @@ function isCodeblock(thing: any): thing is Codeblock { && typeof (thing).value === 'string'; } -export function fromMarkdown(markup: theia.MarkdownString | theia.MarkedString): MarkdownString { +export function fromMarkdown(markup: theia.MarkdownString | theia.MarkedString): model.MarkdownString { if (isCodeblock(markup)) { const { language, value } = markup; return { value: '```' + language + '\n' + value + '\n```\n' }; @@ -198,7 +187,7 @@ function isRelativePattern(obj: {}): obj is theia.RelativePattern { return rp && typeof rp.base === 'string' && typeof rp.pattern === 'string'; } -export function fromCompletionItemKind(kind?: types.CompletionItemKind): CompletionType { +export function fromCompletionItemKind(kind?: types.CompletionItemKind): model.CompletionType { switch (kind) { case types.CompletionItemKind.Method: return 'method'; case types.CompletionItemKind.Function: return 'function'; @@ -229,7 +218,7 @@ export function fromCompletionItemKind(kind?: types.CompletionItemKind): Complet return 'property'; } -export function toCompletionItemKind(type?: CompletionType): types.CompletionItemKind { +export function toCompletionItemKind(type?: model.CompletionType): types.CompletionItemKind { if (type) { switch (type) { case 'method': return types.CompletionItemKind.Method; @@ -262,8 +251,8 @@ export function toCompletionItemKind(type?: CompletionType): types.CompletionIte return types.CompletionItemKind.Property; } -export function fromTextEdit(edit: theia.TextEdit): SingleEditOperation { - return { +export function fromTextEdit(edit: theia.TextEdit): model.SingleEditOperation { + return { text: edit.newText, range: fromRange(edit.range) }; @@ -285,7 +274,7 @@ export function fromLanguageSelector(selector: theia.DocumentSelector): Language } } -export function convertDiagnosticToMarkerData(diagnostic: theia.Diagnostic): MarkerData { +export function convertDiagnosticToMarkerData(diagnostic: theia.Diagnostic): model.MarkerData { return { code: convertCode(diagnostic.code), severity: convertSeverity(diagnostic.severity), @@ -317,12 +306,12 @@ function convertSeverity(severity: types.DiagnosticSeverity): types.MarkerSeveri } } -function convertRelatedInformation(diagnosticsRelatedInformation: theia.DiagnosticRelatedInformation[] | undefined): RelatedInformation[] | undefined { +function convertRelatedInformation(diagnosticsRelatedInformation: theia.DiagnosticRelatedInformation[] | undefined): model.RelatedInformation[] | undefined { if (!diagnosticsRelatedInformation) { return undefined; } - const relatedInformation: RelatedInformation[] = []; + const relatedInformation: model.RelatedInformation[] = []; for (const item of diagnosticsRelatedInformation) { relatedInformation.push({ resource: item.location.uri, @@ -350,22 +339,22 @@ function convertTags(tags: types.DiagnosticTag[] | undefined): types.MarkerTag[] return markerTags; } -export function fromHover(hover: theia.Hover): Hover { - return { +export function fromHover(hover: theia.Hover): model.Hover { + return { range: fromRange(hover.range), contents: fromManyMarkdown(hover.contents) }; } -export function fromLocation(location: theia.Location): Location { - return { +export function fromLocation(location: theia.Location): model.Location { + return { uri: location.uri, range: fromRange(location.range) }; } -export function fromDefinitionLink(definitionLink: theia.DefinitionLink): DefinitionLink { - return { +export function fromDefinitionLink(definitionLink: theia.DefinitionLink): model.DefinitionLink { + return { uri: definitionLink.targetUri, range: fromRange(definitionLink.targetRange), origin: definitionLink.originSelectionRange ? fromRange(definitionLink.originSelectionRange) : undefined, @@ -373,14 +362,14 @@ export function fromDefinitionLink(definitionLink: theia.DefinitionLink): Defini }; } -export function fromDocumentLink(definitionLink: theia.DocumentLink): DocumentLink { - return { +export function fromDocumentLink(definitionLink: theia.DocumentLink): model.DocumentLink { + return { range: fromRange(definitionLink.range), url: definitionLink.target && definitionLink.target.toString() }; } -export function toInternalCommand(command: theia.Command): Command { +export function toInternalCommand(command: theia.Command): model.Command { return { id: command.id, title: command.label || '', @@ -407,3 +396,11 @@ export function fromWorkspaceEdit(value: theia.WorkspaceEdit, documents?: any): } return result; } + +export function toWorkspaceFolder(folder: model.WorkspaceFolder): theia.WorkspaceFolder { + return { + uri: URI.revive(folder.uri), + name: folder.name, + index: folder.index + }; +} diff --git a/packages/plugin-ext/src/plugin/workspace.ts b/packages/plugin-ext/src/plugin/workspace.ts index 90167ae67f143..6bb826c54c923 100644 --- a/packages/plugin-ext/src/plugin/workspace.ts +++ b/packages/plugin-ext/src/plugin/workspace.ts @@ -14,15 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { - WorkspaceFolder, - WorkspaceFoldersChangeEvent, - WorkspaceFolderPickOptions, - GlobPattern, - FileSystemWatcher, - TextDocumentContentProvider, - Disposable -} from '@theia/plugin'; +import * as theia from '@theia/plugin'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { @@ -33,7 +25,9 @@ import { } from '../api/plugin-api'; import { Path } from '@theia/core/lib/common/path'; import { RPCProtocol } from '../api/rpc-protocol'; +import { WorkspaceFoldersChangeEvent } from '../api/model'; import { EditorsAndDocumentsExtImpl } from './editors-and-documents'; +import { toWorkspaceFolder } from './type-converters'; import URI from 'vscode-uri'; export class WorkspaceExtImpl implements WorkspaceExt { @@ -43,15 +37,15 @@ export class WorkspaceExtImpl implements WorkspaceExt { private workspaceFoldersChangedEmitter = new Emitter(); public readonly onDidChangeWorkspaceFolders: Event = this.workspaceFoldersChangedEmitter.event; - private folders: WorkspaceFolder[] | undefined; + private folders: theia.WorkspaceFolder[] | undefined; - private documentContentProviders = new Map(); + private documentContentProviders = new Map(); constructor(rpc: RPCProtocol, private editorsAndDocuments: EditorsAndDocumentsExtImpl) { this.proxy = rpc.getProxy(Ext.WORKSPACE_MAIN); } - get workspaceFolders(): WorkspaceFolder[] | undefined { + get workspaceFolders(): theia.WorkspaceFolder[] | undefined { return this.folders; } @@ -64,16 +58,22 @@ export class WorkspaceExtImpl implements WorkspaceExt { } $onWorkspaceFoldersChanged(event: WorkspaceFoldersChangeEvent): void { - this.folders = event.added; + // TODO add support for multiroot workspace + this.folders = []; + + const added: theia.WorkspaceFolder[] = []; + event.added.map(folder => added.push(toWorkspaceFolder(folder))); + event.added = added; + this.folders = added; this.workspaceFoldersChangedEmitter.fire(event); } - pickWorkspaceFolder(options?: WorkspaceFolderPickOptions): PromiseLike { + pickWorkspaceFolder(options?: theia.WorkspaceFolderPickOptions): PromiseLike { return new Promise((resolve, reject) => { - const optionsMain = { + const optionsMain: WorkspaceFolderPickOptionsMain = { placeHolder: options && options.placeHolder ? options.placeHolder : undefined, ignoreFocusOut: options && options.ignoreFocusOut - } as WorkspaceFolderPickOptionsMain; + }; this.proxy.$pickWorkspaceFolder(optionsMain).then(value => { resolve(value); @@ -81,7 +81,7 @@ export class WorkspaceExtImpl implements WorkspaceExt { }); } - findFiles(include: GlobPattern, exclude?: GlobPattern | undefined, maxResults?: number, + findFiles(include: theia.GlobPattern, exclude?: theia.GlobPattern | undefined, maxResults?: number, token: CancellationToken = CancellationToken.None): PromiseLike { let includePattern: string; if (include) { @@ -115,12 +115,12 @@ export class WorkspaceExtImpl implements WorkspaceExt { .then(data => Array.isArray(data) ? data.map(URI.revive) : []); } - createFileSystemWatcher(globPattern: GlobPattern, ignoreCreateEvents?: boolean, ignoreChangeEvents?: boolean, ignoreDeleteEvents?: boolean): FileSystemWatcher { + createFileSystemWatcher(globPattern: theia.GlobPattern, ignoreCreateEvents?: boolean, ignoreChangeEvents?: boolean, ignoreDeleteEvents?: boolean): theia.FileSystemWatcher { // FIXME: to implement - return new Proxy({}, {}); + return new Proxy({}, {}); } - registerTextDocumentContentProvider(scheme: string, provider: TextDocumentContentProvider): Disposable { + registerTextDocumentContentProvider(scheme: string, provider: theia.TextDocumentContentProvider): theia.Disposable { if (scheme === 'file' || scheme === 'untitled' || this.documentContentProviders.has(scheme)) { throw new Error(`Text Content Document Provider for scheme '${scheme}' is already registered`); } @@ -128,7 +128,7 @@ export class WorkspaceExtImpl implements WorkspaceExt { this.documentContentProviders.set(scheme, provider); this.proxy.$registerTextDocumentContentProvider(scheme); - let onDidChangeSubscription: Disposable; + let onDidChangeSubscription: theia.Disposable; if (typeof provider.onDidChange === 'function') { onDidChangeSubscription = provider.onDidChange(async uri => { if (uri.scheme === scheme && this.editorsAndDocuments.getDocument(uri.toString())) { From 6b6455f71191c8457274e6a9b893028ed5be15f0 Mon Sep 17 00:00:00 2001 From: Casey Flynn Date: Tue, 20 Nov 2018 16:37:44 -0800 Subject: [PATCH 48/49] Add documentation for Preview Editor Extension. Signed-off-by: Casey Flynn --- packages/editor-preview/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/editor-preview/README.md b/packages/editor-preview/README.md index e5731a931d3d3..f6cd0c7aa178c 100644 --- a/packages/editor-preview/README.md +++ b/packages/editor-preview/README.md @@ -1,5 +1,29 @@ # Theia - Editor Preview Extension +A Preview Editor supports the same functionality as a regular editor widget with the exception: if +a preview editor has not "transitioned to a permanent editor" at the time an additional request to +preview a file is received, instead of opening a new editor, it will display the contents of the +newly requested file. + +Events that will transition the preview to a permanent editor are as follows: +* Modifying file contents being previewed +* Double clicking the preview tab +* Performing a drag/drop operation of the editor preview tab resulting in the tab being moved. +* Issuing a request to open the file being previewed (e.g. double clicking the file in the +navigator) + +The preview editor is enabled by default when the extension is included in a Theia application, but +may be disabled by modifying the preference: +```json +editor.enablePreview +``` + +In addition to this value, the preference: +```json +list.openMode +``` +must be set to "singleClick" to enable opening files in preview mode. + See [here](https://www.theia-ide.org/doc/index.html) for a detailed documentation. ## License From ac97c739c9ebbbcff815bf06a665228125367716 Mon Sep 17 00:00:00 2001 From: Rob Moran Date: Wed, 21 Nov 2018 09:03:42 +0000 Subject: [PATCH 49/49] Add nls check to vscode debug adapter Signed-off-by: Rob Moran --- .../vscode/vscode-debug-adapter-contribution.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts b/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts index 5a02b328906dc..4f040478a85f6 100644 --- a/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts +++ b/packages/debug/src/node/vscode/vscode-debug-adapter-contribution.ts @@ -86,13 +86,18 @@ export abstract class AbstractVSCodeDebugAdapterContribution implements DebugAda this.languages = this.debuggerContribution.then(({ languages }) => languages); } protected async parse(): Promise { - const nlsMap = require(path.join(this.extensionPath, 'package.nls.json')); const pckPath = path.join(this.extensionPath, 'package.json'); let text = (await fs.readFile(pckPath)).toString(); - for (const key of Object.keys(nlsMap)) { - const value = nlsMap[key]; - text = text.split('%' + key + '%').join(value); + + const nlsPath = path.join(this.extensionPath, 'package.nls.json'); + if (fs.existsSync(nlsPath)) { + const nlsMap = require(nlsPath); + for (const key of Object.keys(nlsMap)) { + const value = nlsMap[key]; + text = text.split('%' + key + '%').join(value); + } } + const pck: { contributes: { debuggers: VSCodeDebuggerContribution[]