From a0fe5c50c222295a6110b8f0a6e28c25d1dda76e Mon Sep 17 00:00:00 2001 From: elaihau Date: Wed, 6 Jun 2018 08:40:18 -0400 Subject: [PATCH] multi-root workspace support, minimal patch What is a workspace / workspace folder? It is a folder on the file system that serves as a container for: - workspace configuration: in sub-folder ".theia" - preferences, in ".theia/settings.json" - root folders list, in ".theia/roots.json" - optionally content in the form of other files/folders For backward compatibility and simplicity, a workspace folder that contains no explicit root folder list configuration will use its own folder as the single root. This permits using any folder as a workspace. If subsequently an additional root folder is added to such a workspace, then both the newly added folder and the original implicit root are added to the root folders list configuration. Either can later be removed. If all roots are removed form the configuration, the workspace falls-back to using its own folder as implicit root. This PR is the minimal patch for #1660 What's included in this PR: - a workspace's root folders list can be modified using the navigator context menu "add/remove folder" items to add or remove a folder - preference 'workspace.supportMultiRootWorkspace', with default value false, can be used to control if the context menu entries, used to modify the list of root folders, are shown or not. Since this is a new feature that is not supported by most Theia extensions yet, it probably makes sense to hide the UI by default for now - previously the "current root" was a static value, obtained by each extension that needs it, at the startup of a Theia client. To change it (e.g. to switch workspace), a restart of the client was required. For this feature we did not want to have to restart the client each time the list of root folders is updated, so we introduced a new "onWorkspaceRootChanged" event. Extensions that need to know when the list of root folders is modified can react to this event, e.g. to update their widgets. It's still necessary to reload the Theia frontend when switching workspace in a given tab or when closing a workspace. - the root folder list comprised of 1 to many folders can be displayed from the file navigator widget. - the root folders will be scanned for git repositories that the user can select, stage and commit files into. What's not included: - users should have the choice of naming the workspace and choosing where to store the config file - preference extension should be able to handle the settings from multiple projects - search-in-workspace and file-search extension should be able to perform the search across all folders in the workspace, and / or provide filtering mechanism to reduce the amount of information being displayed - output and problems widgets should display data from all root folders, and / or provide filtering mechanism to reduce the amount of information being displayed - task extension should be able to run tasks defined under all root folders - users should be able to open the terminal from any root folder in the workspace Signed-off-by: elaihau --- .../core/src/browser/tree/tree-widget.tsx | 32 +-- packages/core/src/browser/tree/tree.ts | 2 +- .../src/browser/quick-file-open.ts | 34 +-- .../src/browser/file-dialog-service.ts | 49 ++++ .../src/browser/file-tree/file-tree-model.ts | 14 +- .../src/browser/file-tree/file-tree.ts | 15 +- .../src/browser/filesystem-frontend-module.ts | 3 + packages/filesystem/src/browser/index.ts | 1 + packages/git/src/browser/git-decorator.ts | 14 +- .../src/browser/git-repository-provider.ts | 18 +- .../git/src/browser/git-view-contribution.ts | 24 +- .../browser/history/git-history-widget.tsx | 2 + .../src/browser/problem/problem-decorator.ts | 14 +- .../monaco/src/browser/monaco-workspace.ts | 4 +- .../src/browser/navigator-contribution.ts | 27 +- .../navigator/src/browser/navigator-model.ts | 95 ++++++- .../navigator/src/browser/navigator-tree.ts | 67 ++++- .../src/browser/navigator-widget.tsx | 24 +- .../hosted/browser/hosted-plugin-informer.ts | 5 +- .../browser/hosted-plugin-manager-client.ts | 15 +- .../src/main/browser/quick-open-main.ts | 2 +- .../src/main/browser/workspace-main.ts | 7 +- .../browser/workspace-preference-provider.ts | 6 +- .../python/src/node/python-contribution.ts | 2 +- ...search-in-workspace-result-tree-widget.tsx | 3 +- .../browser/search-in-workspace-service.ts | 2 +- packages/task/src/browser/task-service.ts | 3 +- .../src/browser/terminal-widget-impl.ts | 2 +- .../src/browser/quick-open-workspace.ts | 3 +- .../src/browser/workspace-commands.ts | 85 ++++++- .../workspace-frontend-contribution.ts | 24 +- .../src/browser/workspace-preferences.ts | 22 +- .../src/browser/workspace-service.ts | 239 +++++++++++++++--- .../src/browser/workspace-storage-service.ts | 15 +- .../src/browser/workspace-uri-contribution.ts | 22 +- .../workspace-variable-contribution.ts | 2 +- .../src/common/test/mock-workspace-server.ts | 6 +- .../src/common/workspace-protocol.ts | 10 +- .../src/node/default-workspace-server.ts | 40 ++- 39 files changed, 732 insertions(+), 222 deletions(-) create mode 100644 packages/filesystem/src/browser/file-dialog-service.ts diff --git a/packages/core/src/browser/tree/tree-widget.tsx b/packages/core/src/browser/tree/tree-widget.tsx index 03b07b7a16d4f..e6adc0d67e1e8 100644 --- a/packages/core/src/browser/tree/tree-widget.tsx +++ b/packages/core/src/browser/tree/tree-widget.tsx @@ -136,27 +136,29 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { protected updateRows = debounce(() => this.doUpdateRows(), 10); protected doUpdateRows(): void { const root = this.model.root; + const rowsToUpdate: Array<[string, TreeWidget.NodeRow]> = []; if (root) { const depths = new Map(); - const rows = Array.from(new TopDownTreeIterator(root, { + let index = 0; + for (const node of new TopDownTreeIterator(root, { pruneCollapsed: true, pruneSiblings: true - }), (node, index) => { - const parentDepth = depths.get(node.parent); - const depth = parentDepth === undefined ? 0 : TreeNode.isVisible(node.parent) ? parentDepth + 1 : parentDepth; - if (CompositeTreeNode.is(node)) { - depths.set(node, depth); + })) { + if (TreeNode.isVisible(node)) { + const parentDepth = depths.get(node.parent); + const depth = parentDepth === undefined ? 0 : TreeNode.isVisible(node.parent) ? parentDepth + 1 : parentDepth; + if (CompositeTreeNode.is(node)) { + depths.set(node, depth); + } + rowsToUpdate.push([node.id, { + index: index++, + node, + depth + }]); } - return [node.id, { - index, - node, - depth - }] as [string, TreeWidget.NodeRow]; - }); - this.rows = new Map(rows); - } else { - this.rows = new Map(); + } } + this.rows = new Map(rowsToUpdate); this.updateScrollToRow(); } diff --git a/packages/core/src/browser/tree/tree.ts b/packages/core/src/browser/tree/tree.ts index 96ebf17724890..f03e1bc4c86db 100644 --- a/packages/core/src/browser/tree/tree.ts +++ b/packages/core/src/browser/tree/tree.ts @@ -50,7 +50,7 @@ export interface Tree extends Disposable { */ refresh(parent: Readonly): Promise; /** - * Emit when the children of the give node are refreshed. + * Emit when the children of the given node are refreshed. */ readonly onNodeRefreshed: Event>; } diff --git a/packages/file-search/src/browser/quick-file-open.ts b/packages/file-search/src/browser/quick-file-open.ts index 2bac011085838..096348b3ac350 100644 --- a/packages/file-search/src/browser/quick-file-open.ts +++ b/packages/file-search/src/browser/quick-file-open.ts @@ -19,7 +19,7 @@ import { QuickOpenModel, QuickOpenItem, QuickOpenMode, QuickOpenService, OpenerService, KeybindingRegistry, Keybinding } from '@theia/core/lib/browser'; -import { FileSystem, FileStat } from '@theia/filesystem/lib/common/filesystem'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import URI from '@theia/core/lib/common/uri'; import { FileSearchService } from '../common/file-search-service'; @@ -37,19 +37,18 @@ export class QuickFileOpenService implements QuickOpenModel { @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry; - - constructor( - @inject(FileSystem) protected readonly fileSystem: FileSystem, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, - @inject(OpenerService) protected readonly openerService: OpenerService, - @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService, - @inject(FileSearchService) protected readonly fileSearchService: FileSearchService, - @inject(LabelProvider) protected readonly labelProvider: LabelProvider - ) { - workspaceService.root.then(root => this.wsRoot = root); - } - - protected wsRoot: FileStat | undefined; + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + @inject(OpenerService) + protected readonly openerService: OpenerService; + @inject(QuickOpenService) + protected readonly quickOpenService: QuickOpenService; + @inject(FileSearchService) + protected readonly fileSearchService: FileSearchService; + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; /** * Whether to hide .gitignored (and other ignored) files. @@ -67,7 +66,7 @@ export class QuickFileOpenService implements QuickOpenModel { protected currentLookFor: string = ''; isEnabled(): boolean { - return this.wsRoot !== undefined; + return this.workspaceService.opened; } open(): void { @@ -117,7 +116,8 @@ export class QuickFileOpenService implements QuickOpenModel { private cancelIndicator = new CancellationTokenSource(); public async onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): Promise { - if (!this.wsRoot) { + const workspaceFolder = this.workspaceService.tryGetRoots()[0]; + if (!workspaceFolder) { return; } @@ -127,7 +127,7 @@ export class QuickFileOpenService implements QuickOpenModel { this.cancelIndicator = new CancellationTokenSource(); const token = this.cancelIndicator.token; const proposed = new Set(); - const rootUri = this.wsRoot.uri; + const rootUri = workspaceFolder.uri; const handler = async (result: string[]) => { if (!token.isCancellationRequested) { const root = new URI(rootUri); diff --git a/packages/filesystem/src/browser/file-dialog-service.ts b/packages/filesystem/src/browser/file-dialog-service.ts new file mode 100644 index 0000000000000..466e8a5c7f6fa --- /dev/null +++ b/packages/filesystem/src/browser/file-dialog-service.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 { injectable, inject } from 'inversify'; +import { FileSystem, FileStat } from '../common'; +import { FileStatNode, DirNode } from './file-tree'; +import { FileDialogFactory, FileDialogProps } from './file-dialog'; +import URI from '@theia/core/lib/common/uri'; +import { LabelProvider } from '@theia/core/lib/browser'; + +@injectable() +export class FileDialogService { + @inject(FileSystem) protected readonly fileSystem: FileSystem; + @inject(FileDialogFactory) protected readonly fileDialogFactory: FileDialogFactory; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + + async show(props: FileDialogProps, folder?: FileStat): Promise { + const title = props && props.title ? props.title : 'Open'; + const folderToOpen = folder || await this.fileSystem.getCurrentUserHome(); + if (folderToOpen) { + const rootUri = new URI(folderToOpen.uri).parent; + const name = this.labelProvider.getName(rootUri); + const [rootStat, label] = await Promise.all([ + this.fileSystem.getFileStat(rootUri.toString()), + this.labelProvider.getIcon(folderToOpen) + ]); + if (rootStat) { + const rootNode = DirNode.createRoot(rootStat, name, label); + const dialog = this.fileDialogFactory({ title }); + dialog.model.navigateTo(rootNode); + const nodes = await dialog.open(); + return Array.isArray(nodes) ? nodes[0] : nodes; + } + } + } +} 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 a63bb2b972d1c..f732d2524bd86 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-model.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree-model.ts @@ -64,8 +64,8 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { return this.selectedNodes.filter(FileStatNode.is); } - protected onFilesChanged(changes: FileChange[]): void { - const affectedNodes = this.getAffectedNodes(changes); + protected async onFilesChanged(changes: FileChange[]): Promise { + const affectedNodes = await this.getAffectedNodes(changes); if (affectedNodes.length !== 0) { affectedNodes.forEach(node => this.refresh(node)); } else if (this.isRootAffected(changes)) { @@ -83,15 +83,15 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { return false; } - protected getAffectedNodes(changes: FileChange[]): CompositeTreeNode[] { + protected async getAffectedNodes(changes: FileChange[]): Promise { const nodes = new Map(); for (const change of changes) { - this.collectAffectedNodes(change, node => nodes.set(node.id, node)); + await this.collectAffectedNodes(change, node => nodes.set(node.id, node)); } return [...nodes.values()]; } - protected collectAffectedNodes(change: FileChange, accept: (node: CompositeTreeNode) => void): void { + protected async collectAffectedNodes(change: FileChange, accept: (node: CompositeTreeNode) => void): Promise { if (this.isFileContentChanged(change)) { return; } @@ -133,6 +133,10 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { await this.fileSystem.move(sourceUri, targetUri, { overwrite: true }); // to workaround https://github.com/Axosoft/nsfw/issues/42 this.refresh(target); + + if (source.parent) { + this.refresh(source.parent); + } } } } diff --git a/packages/filesystem/src/browser/file-tree/file-tree.ts b/packages/filesystem/src/browser/file-tree/file-tree.ts index 33e08ab2b8bd4..480044d3fceef 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree.ts @@ -19,7 +19,7 @@ import URI from '@theia/core/lib/common/uri'; import { TreeNode, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode, TreeImpl } from '@theia/core/lib/browser'; import { FileSystem, FileStat } from '../../common'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; -import { UriSelection } from '@theia/core/lib/common//selection'; +import { UriSelection } from '@theia/core/lib/common/selection'; @injectable() export class FileTree extends TreeImpl { @@ -33,7 +33,6 @@ export class FileTree extends TreeImpl { if (fileStat) { return this.toNodes(fileStat, parent); } - return []; } return super.resolveChildren(parent); @@ -63,7 +62,7 @@ export class FileTree extends TreeImpl { const uri = new URI(fileStat.uri); const name = await this.labelProvider.getName(uri); const icon = await this.labelProvider.getIcon(fileStat); - const id = fileStat.uri; + const id = this.toNodeId(fileStat, parent); const node = this.getNode(id); if (fileStat.isDirectory) { if (DirNode.is(node)) { @@ -87,6 +86,9 @@ export class FileTree extends TreeImpl { }; } + protected toNodeId(fileStat: FileStat, parent: CompositeTreeNode): string { + return fileStat.uri; + } } export interface FileStatNode extends SelectableTreeNode, UriSelection { @@ -96,6 +98,13 @@ export namespace FileStatNode { export function is(node: object | undefined): node is FileStatNode { return !!node && 'fileStat' in node; } + + export function getUri(node: TreeNode | undefined): string | undefined { + if (is(node)) { + return node.fileStat.uri; + } + return undefined; + } } export type FileNode = FileStatNode; diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index 514a3d7c0e61d..16ad68f38febb 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -26,6 +26,7 @@ import { FileResourceResolver } from './file-resource'; import { FileSystemListener } from './filesystem-listener'; import { bindFileSystemPreferences } from './filesystem-preferences'; import { FileSystemWatcher } from './filesystem-watcher'; +import { FileDialogService } from './file-dialog-service'; import '../../src/browser/style/index.css'; @@ -47,4 +48,6 @@ export default new ContainerModule(bind => { bind(FileResourceResolver).toSelf().inSingletonScope(); bind(ResourceResolver).toDynamicValue(ctx => ctx.container.get(FileResourceResolver)); + + bind(FileDialogService).toSelf().inSingletonScope(); }); diff --git a/packages/filesystem/src/browser/index.ts b/packages/filesystem/src/browser/index.ts index 38dedcfdf934b..4ba014344ea2e 100644 --- a/packages/filesystem/src/browser/index.ts +++ b/packages/filesystem/src/browser/index.ts @@ -20,3 +20,4 @@ export * from './file-dialog'; export * from './filesystem-preferences'; export * from './filesystem-watcher'; export * from './file-resource'; +export * from './file-dialog-service'; diff --git a/packages/git/src/browser/git-decorator.ts b/packages/git/src/browser/git-decorator.ts index 6b063a0c1e20d..52b32ead4f1cf 100644 --- a/packages/git/src/browser/git-decorator.ts +++ b/packages/git/src/browser/git-decorator.ts @@ -27,6 +27,7 @@ import { WorkingDirectoryStatus } from '../common/git-model'; import { GitFileChange, GitFileStatus } from '../common/git-model'; import { GitPreferences, GitConfiguration } from './git-preferences'; import { GitRepositoryTracker } from './git-repository-tracker'; +import { FileStatNode } from '@theia/filesystem/lib/browser'; @injectable() export class GitDecorator implements TreeDecorator { @@ -73,13 +74,16 @@ export class GitDecorator implements TreeDecorator { return result; } const markers = this.appendContainerChanges(tree, status.changes); - for (const { id } of new DepthFirstTreeIterator(tree.root)) { - const marker = markers.get(id); - if (marker) { - result.set(id, marker); + for (const treeNode of new DepthFirstTreeIterator(tree.root)) { + const uri = FileStatNode.getUri(treeNode); + if (uri) { + const marker = markers.get(uri); + if (marker) { + result.set(treeNode.id, marker); + } } } - return new Map(Array.from(result.values()).map(m => [m.uri, this.toDecorator(m)] as [string, TreeDecoration.Data])); + return new Map(Array.from(result.entries()).map(m => [m[0], this.toDecorator(m[1])] as [string, TreeDecoration.Data])); } protected appendContainerChanges(tree: Tree, changes: GitFileChange[]): Map { diff --git a/packages/git/src/browser/git-repository-provider.ts b/packages/git/src/browser/git-repository-provider.ts index c0add432899b4..a98429f131485 100644 --- a/packages/git/src/browser/git-repository-provider.ts +++ b/packages/git/src/browser/git-repository-provider.ts @@ -38,6 +38,9 @@ export class GitRepositoryProvider { } protected async initialize(): Promise { + this.workspaceService.onWorkspaceChanged(event => { + this.refresh(); + }); await this.refresh({ maxCount: 1 }); await this.refresh(); } @@ -74,14 +77,21 @@ export class GitRepositoryProvider { } async refresh(options?: GitRefreshOptions): Promise { - const root = await this.workspaceService.root; + const roots = await this.workspaceService.roots; + const root = roots[0]; if (!root) { return; } - const repositories = await this.git.repositories(root.uri, { - ...options + const repoUris = new Map(); + const reposOfRoots = await Promise.all( + roots.map(r => this.git.repositories(r.uri, { ...options })) + ); + reposOfRoots.forEach(reposPerRoot => { + reposPerRoot.forEach(repoOfOneRoot => { + repoUris.set(repoOfOneRoot.localUri, repoOfOneRoot); + }); }); - this._allRepositories = repositories; + this._allRepositories = Array.from(repoUris.values()); const selectedRepository = this._selectedRepository; if (!selectedRepository || !this.exists(selectedRepository)) { this.selectedRepository = this._allRepositories[0]; diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index cc14412fae5da..7c058a5b64472 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -111,17 +111,23 @@ export class GitViewContribution extends AbstractViewContribution imp onStart(): void { this.repositoryTracker.onDidChangeRepository(repository => { - if (repository && this.hasMultipleRepositories()) { - const path = new URI(repository.localUri).path; - this.statusBar.setElement(GitViewContribution.GIT_SELECTED_REPOSITORY, { - text: `$(database) ${path.base}`, - alignment: StatusBarAlignment.LEFT, - priority: 102, - command: GIT_COMMANDS.CHANGE_REPOSITORY.id, - tooltip: path.toString() - }); + if (repository) { + if (this.hasMultipleRepositories()) { + const path = new URI(repository.localUri).path; + this.statusBar.setElement(GitViewContribution.GIT_SELECTED_REPOSITORY, { + text: `$(database) ${path.base}`, + alignment: StatusBarAlignment.LEFT, + priority: 102, + command: GIT_COMMANDS.CHANGE_REPOSITORY.id, + tooltip: path.toString() + }); + } else { + this.statusBar.removeElement(GitViewContribution.GIT_SELECTED_REPOSITORY); + } } else { this.statusBar.removeElement(GitViewContribution.GIT_SELECTED_REPOSITORY); + this.statusBar.removeElement(GitViewContribution.GIT_REPOSITORY_STATUS); + this.statusBar.removeElement(GitViewContribution.GIT_SYNC_STATUS); } }); this.repositoryTracker.onGitEvent(event => { diff --git a/packages/git/src/browser/history/git-history-widget.tsx b/packages/git/src/browser/history/git-history-widget.tsx index 3f9b5c5cf283d..c23afb9d98008 100644 --- a/packages/git/src/browser/history/git-history-widget.tsx +++ b/packages/git/src/browser/history/git-history-widget.tsx @@ -66,6 +66,8 @@ export class GitHistoryWidget extends GitNavigableListWidget this.scrollContainer = 'git-history-list-container'; this.title.label = 'Git History'; this.addClass('theia-git'); + this.options = {}; + this.commits = []; } protected onAfterAttach(msg: Message) { diff --git a/packages/markers/src/browser/problem/problem-decorator.ts b/packages/markers/src/browser/problem/problem-decorator.ts index 15663e5b5baae..faeef7d7f50cb 100644 --- a/packages/markers/src/browser/problem/problem-decorator.ts +++ b/packages/markers/src/browser/problem/problem-decorator.ts @@ -22,6 +22,7 @@ import { Event, Emitter } from '@theia/core/lib/common/event'; import { Tree } from '@theia/core/lib/browser/tree/tree'; import { DepthFirstTreeIterator } from '@theia/core/lib/browser/tree/tree-iterator'; import { TreeDecorator, TreeDecoration } from '@theia/core/lib/browser/tree/tree-decorator'; +import { FileStatNode } from '@theia/filesystem/lib/browser'; import { Marker } from '../../common/marker'; import { ProblemManager } from './problem-manager'; @@ -55,13 +56,16 @@ export class ProblemDecorator implements TreeDecorator { return result; } const markers = this.appendContainerMarkers(tree, this.collectMarkers(tree)); - for (const { id } of new DepthFirstTreeIterator(tree.root)) { - const marker = markers.get(id); - if (marker) { - result.set(id, marker); + for (const node of new DepthFirstTreeIterator(tree.root)) { + const nodeUri = FileStatNode.getUri(node); + if (nodeUri) { + const marker = markers.get(nodeUri); + if (marker) { + result.set(node.id, marker); + } } } - return new Map(Array.from(result.values()).map(m => [m.uri, this.toDecorator(m)] as [string, TreeDecoration.Data])); + return new Map(Array.from(result.entries()).map(m => [m[0], this.toDecorator(m[1])] as [string, TreeDecoration.Data])); } protected appendContainerMarkers(tree: Tree, markers: Marker[]): Map> { diff --git a/packages/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts index 4d038065ae482..ffce9cf485f65 100644 --- a/packages/monaco/src/browser/monaco-workspace.ts +++ b/packages/monaco/src/browser/monaco-workspace.ts @@ -97,12 +97,14 @@ export class MonacoWorkspace implements lang.Workspace { @postConstruct() protected init(): void { - this.workspaceService.root.then(rootStat => { + this.workspaceService.roots.then(roots => { + const rootStat = roots[0]; if (rootStat) { this._rootUri = rootStat.uri; this.resolveReady(); } }); + for (const model of this.textModelService.models) { this.fireDidOpen(model); } diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index 1a96221be36a0..7078e4c59bc4e 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -16,12 +16,14 @@ import { injectable, inject, postConstruct } from 'inversify'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; +import { + Navigatable, SelectableTreeNode, Widget, KeybindingRegistry, CommonCommands, + OpenerService, FrontendApplicationContribution, FrontendApplication +} from '@theia/core/lib/browser'; +import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution'; import { CommandRegistry, MenuModelRegistry, MenuPath, isOSX } from '@theia/core/lib/common'; -import { Navigatable, SelectableTreeNode, Widget, KeybindingRegistry, CommonCommands, - OpenerService, FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser'; import { SHELL_TABBAR_CONTEXT_MENU } from '@theia/core/lib/browser'; -import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution'; -import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands'; +import { WorkspaceCommands, WorkspaceService, WorkspacePreferences } from '@theia/workspace/lib/browser'; import { FILE_NAVIGATOR_ID, FileNavigatorWidget } from './navigator-widget'; import { FileNavigatorPreferences } from './navigator-preferences'; import { NavigatorKeybindingContexts } from './navigator-keybinding-context'; @@ -47,6 +49,7 @@ export namespace NavigatorContextMenu { export const MOVE = [...NAVIGATOR_CONTEXT_MENU, '3_move']; export const NEW = [...NAVIGATOR_CONTEXT_MENU, '4_new']; export const DIFF = [...NAVIGATOR_CONTEXT_MENU, '5_diff']; + export const WORKSPACE = [...NAVIGATOR_CONTEXT_MENU, '6_workspace']; } @injectable() @@ -55,7 +58,9 @@ export class FileNavigatorContribution extends AbstractViewContribution { + if (this.workspacePreferences['workspace.supportMultiRootWorkspace']) { + registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { + commandId: WorkspaceCommands.ADD_FOLDER.id + }); + registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { + commandId: WorkspaceCommands.REMOVE_FOLDER.id, + label: 'Remove Folder from Workspace' + }); + } + }); } registerKeybindings(registry: KeybindingRegistry): void { diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts index 5f0dc10f1bf99..7345bbb2e6fe4 100644 --- a/packages/navigator/src/browser/navigator-model.ts +++ b/packages/navigator/src/browser/navigator-model.ts @@ -14,13 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject } from 'inversify'; +import { injectable, inject, postConstruct } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; +import { DirNode, FileChange, FileChangeType, FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; import { TreeIterator, Iterators } from '@theia/core/lib/browser/tree/tree-iterator'; -import { OpenerService, open, TreeNode, ExpandableTreeNode } from '@theia/core/lib/browser'; -import { FileNavigatorTree } from './navigator-tree'; +import { OpenerService, open, TreeNode, ExpandableTreeNode, CompositeTreeNode } from '@theia/core/lib/browser'; +import { FileNavigatorTree, WorkspaceRootNode, WorkspaceNode } from './navigator-tree'; import { FileNavigatorSearch } from './navigator-search'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; @injectable() export class FileNavigatorModel extends FileTreeModel { @@ -28,6 +29,17 @@ export class FileNavigatorModel extends FileTreeModel { @inject(OpenerService) protected readonly openerService: OpenerService; @inject(FileNavigatorTree) protected readonly tree: FileNavigatorTree; @inject(FileNavigatorSearch) protected readonly navigatorSearch: FileNavigatorSearch; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + + @postConstruct() + protected async init(): Promise { + this.toDispose.push( + this.workspaceService.onWorkspaceChanged(event => { + this.updateRoot(); + }) + ); + super.init(); + } protected doOpenNode(node: TreeNode): void { if (FileNode.is(node)) { @@ -37,6 +49,34 @@ export class FileNavigatorModel extends FileTreeModel { } } + async updateRoot(): Promise { + this.root = await this.createRoot(); + } + + protected async createRoot(): Promise { + const roots = await this.workspaceService.roots; + if (roots.length > 0) { + const workspaceNode = WorkspaceNode.createRoot([]); + const children: WorkspaceRootNode[] = []; + for (const root of roots) { + children.push(await this.tree.createWorkspaceRoot(root, workspaceNode)); + } + workspaceNode.children = children; + return workspaceNode; + } + } + + /** + * Move the given source file or directory to the given target directory. + */ + async move(source: TreeNode, target: TreeNode) { + if (source.parent && WorkspaceRootNode.is(source)) { + // do not support moving a root folder + return; + } + await super.move(source, target); + } + /** * Reveals node in the navigator by given file uri. * @@ -49,11 +89,11 @@ export class FileNavigatorModel extends FileTreeModel { } const navigatorNodeId = targetFileUri.toString(); - let node = this.getNode(navigatorNodeId); + let node = await this.getNodeClosestToRootByUri(navigatorNodeId); // success stop condition // we have to reach workspace root because expanded node could be inside collapsed one - if (this.root === node) { + if (WorkspaceRootNode.is(node)) { if (ExpandableTreeNode.is(node)) { if (!node.expanded) { await this.expandNode(node); @@ -74,7 +114,7 @@ export class FileNavigatorModel extends FileTreeModel { if (await this.revealFile(targetFileUri.parent)) { if (node === undefined) { // get node if it wasn't mounted into navigator tree before expansion - node = this.getNode(navigatorNodeId); + node = await this.getNodeClosestToRootByUri(navigatorNodeId); } if (ExpandableTreeNode.is(node) && !node.expanded) { await this.expandNode(node); @@ -84,6 +124,15 @@ export class FileNavigatorModel extends FileTreeModel { return undefined; } + protected async getNodeClosestToRootByUri(uri: string): Promise { + const nodes = await this.getNodesByUri(uri); + return nodes.length > 0 + ? nodes.reduce((node1, node2) => // return the node closest to the workspace root + node1.id.length >= node2.id.length ? node1 : node2 + ) + : undefined; + } + protected createBackwardIterator(node: TreeNode | undefined): TreeIterator | undefined { if (node === undefined) { return undefined; @@ -112,4 +161,36 @@ export class FileNavigatorModel extends FileTreeModel { return Iterators.cycle(filteredNodes, node); } + protected async collectAffectedNodes(change: FileChange, accept: (node: CompositeTreeNode) => void): Promise { + if (this.isFileContentChanged(change)) { + return; + } + (await this.getNodesByUri(change.uri.parent.toString())).forEach(parentNode => { + if (DirNode.is(parentNode) && parentNode.expanded) { + accept(parentNode); + } + }); + } + + protected isFileContentChanged(change: FileChange): boolean { + const roots = this.workspaceService.tryGetRoots(); + if (roots.length === 0) { + return false; + } + const nodeId = WorkspaceRootNode.createId(roots[0].uri, change.uri.toString()); + const node = this.getNode(nodeId); + return change.type === FileChangeType.UPDATED && FileNode.is(node); + } + + protected async getNodesByUri(nodeUri: string): Promise { + const roots = await this.workspaceService.roots; + const nodes: TreeNode[] = []; + for (const node of roots.map(root => WorkspaceRootNode.createId(root.uri, nodeUri)) + .map(parentNodeId => this.getNode(parentNodeId))) { + if (node) { + nodes.push(node); + } + } + return nodes; + } } diff --git a/packages/navigator/src/browser/navigator-tree.ts b/packages/navigator/src/browser/navigator-tree.ts index facd9e9bafc60..0dc21c95436b1 100644 --- a/packages/navigator/src/browser/navigator-tree.ts +++ b/packages/navigator/src/browser/navigator-tree.ts @@ -15,8 +15,10 @@ ********************************************************************************/ import { injectable, inject, postConstruct } from 'inversify'; -import { FileTree } from '@theia/filesystem/lib/browser'; -import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree/tree'; +import { FileTree, DirNode } from '@theia/filesystem/lib/browser'; +import { FileStat } from '@theia/filesystem/lib/common'; +import URI from '@theia/core/lib/common/uri'; +import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser'; import { FileNavigatorFilter } from './navigator-filter'; @injectable() @@ -30,7 +32,68 @@ export class FileNavigatorTree extends FileTree { } async resolveChildren(parent: CompositeTreeNode): Promise { + if (WorkspaceNode.is(parent)) { + return parent.children; + } return this.filter.filter(super.resolveChildren(parent)); } + protected toNodeId(childFileStat: FileStat, parent: CompositeTreeNode): string { + if (WorkspaceNode.is(parent)) { + return WorkspaceRootNode.createId(childFileStat.uri, childFileStat.uri); + } + const workspaceRootNode = WorkspaceRootNode.find(parent); + if (workspaceRootNode) { + return WorkspaceRootNode.createId(workspaceRootNode.uri, childFileStat.uri); + } + return childFileStat.uri; + } + + async createWorkspaceRoot(rootFolder: FileStat, workspaceNode: WorkspaceNode): Promise { + return (await this.toNode(rootFolder, workspaceNode)) as WorkspaceRootNode; + } +} + +export interface WorkspaceNode extends CompositeTreeNode { + children: WorkspaceRootNode[]; +} +export namespace WorkspaceNode { + export const id = 'WorkspaceNodeId'; + export const name = 'WorkspaceNode'; + + export function is(node: TreeNode | undefined): node is WorkspaceNode { + return CompositeTreeNode.is(node) && node.name === WorkspaceNode.name; + } + + export function createRoot(children: WorkspaceRootNode[]): WorkspaceNode { + return { + id: WorkspaceNode.id, + name: WorkspaceNode.name, + parent: undefined, + children: children.map(c => c as WorkspaceRootNode), + visible: false + }; + } +} + +export interface WorkspaceRootNode extends DirNode { + parent: WorkspaceNode; +} +export namespace WorkspaceRootNode { + export function is(node: TreeNode | undefined): node is WorkspaceRootNode { + return DirNode.is(node) && WorkspaceNode.is(node.parent); + } + + export function find(node: TreeNode | undefined): WorkspaceRootNode | undefined { + if (node) { + if (is(node)) { + return node; + } + return find(node.parent); + } + } + + export function createId(workspaceRootNodeUri: URI | string, nodeUri: string): string { + return `${new URI(workspaceRootNodeUri.toString()).withoutScheme().toString()}///${nodeUri}`; + } } diff --git a/packages/navigator/src/browser/navigator-widget.tsx b/packages/navigator/src/browser/navigator-widget.tsx index 245289f67c3ee..e4e448c2c0e2a 100644 --- a/packages/navigator/src/browser/navigator-widget.tsx +++ b/packages/navigator/src/browser/navigator-widget.tsx @@ -19,8 +19,12 @@ import { Message } from '@phosphor/messaging'; import URI from '@theia/core/lib/common/uri'; import { SelectionService, CommandService } from '@theia/core/lib/common'; import { CommonCommands } from '@theia/core/lib/browser/common-frontend-contribution'; -import { ContextMenuRenderer, TreeProps, TreeModel, TreeNode, LabelProvider, Widget, SelectableTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser'; -import { FileTreeWidget, DirNode, FileNode } from '@theia/filesystem/lib/browser'; +import { + ContextMenuRenderer, ExpandableTreeNode, + TreeProps, TreeModel, TreeNode, + LabelProvider, Widget, SelectableTreeNode +} from '@theia/core/lib/browser'; +import { FileTreeWidget, FileNode } from '@theia/filesystem/lib/browser'; import { WorkspaceService, WorkspaceCommands } from '@theia/workspace/lib/browser'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { FileNavigatorModel } from './navigator-model'; @@ -92,17 +96,8 @@ export class FileNavigatorWidget extends FileTreeWidget { ]); } - protected initialize(): void { - this.workspaceService.root.then(async resolvedRoot => { - if (resolvedRoot) { - const uri = new URI(resolvedRoot.uri); - const label = this.labelProvider.getName(uri); - const icon = await this.labelProvider.getIcon(resolvedRoot); - this.model.root = DirNode.createRoot(resolvedRoot, label, icon); - } else { - this.update(); - } - }); + protected async initialize(): Promise { + await this.model.updateRoot(); } protected enableDndOnMainPanel(): void { @@ -180,7 +175,7 @@ export class FileNavigatorWidget extends FileTreeWidget { } /** - * Instead of rendering the file resources form the workspace, we render a placeholder + * Instead of rendering the file resources from the workspace, we render a placeholder * button when the workspace root is not yet set. */ protected renderOpenWorkspaceDiv(): React.ReactNode { @@ -193,5 +188,4 @@ export class FileNavigatorWidget extends FileTreeWidget { ; } - } diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts index 0cea33907e38b..7a73eb78cde5a 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts @@ -55,10 +55,11 @@ export class HostedPluginInformer implements FrontendApplicationContribution { protected readonly frontendApplicationStateService: FrontendApplicationStateService; public initialize(): void { - this.workspaceService.root.then(root => { + this.workspaceService.roots.then(roots => { + const workspaceFolder = roots[0]; this.hostedPluginServer.getHostedPlugin().then(pluginMetadata => { if (pluginMetadata) { - this.updateTitle(root); + this.updateTitle(workspaceFolder); this.entry = { text: `$(cube) ${HostedPluginInformer.DEVELOPMENT_HOST_TITLE}`, diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin-manager-client.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin-manager-client.ts index 5c17257556482..1480e1c03203b 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin-manager-client.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin-manager-client.ts @@ -191,19 +191,14 @@ export class HostedPluginManagerClient { * Creates directory choose dialog and set selected folder into pluginLocation field. */ async selectPluginPath(): Promise { - let rootStat = await this.workspaceService.root; - - if (!rootStat) { - rootStat = await this.fileSystem.getCurrentUserHome(); - } - - if (!rootStat) { + const workspaceFolder = (await this.workspaceService.roots)[0] || await this.fileSystem.getCurrentUserHome(); + if (!workspaceFolder) { throw new Error('Unable to find the root'); } - const name = this.labelProvider.getName(rootStat); - const label = await this.labelProvider.getIcon(rootStat); - const rootNode = DirNode.createRoot(rootStat, name, label); + const name = this.labelProvider.getName(workspaceFolder); + const label = await this.labelProvider.getIcon(workspaceFolder); + const rootNode = DirNode.createRoot(workspaceFolder, name, label); const dialog = this.fileDialogFactory({ title: HostedPluginCommands.SELECT_PATH.label!, diff --git a/packages/plugin-ext/src/main/browser/quick-open-main.ts b/packages/plugin-ext/src/main/browser/quick-open-main.ts index 02fc2e8182ee1..39edde80d4b8b 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -128,7 +128,7 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { // Try to use workspace service root if there is no preconfigured URI if (!rootStat) { - rootStat = await this.workspaceService.root; + rootStat = (await this.workspaceService.roots)[0]; } // Try to use current user home if root folder is still not taken diff --git a/packages/plugin-ext/src/main/browser/workspace-main.ts b/packages/plugin-ext/src/main/browser/workspace-main.ts index ae50c5184b6c2..26fbdaf2438bb 100644 --- a/packages/plugin-ext/src/main/browser/workspace-main.ts +++ b/packages/plugin-ext/src/main/browser/workspace-main.ts @@ -30,9 +30,10 @@ export class WorkspaceMain { constructor(rpc: RPCProtocol, workspaceService: WorkspaceService) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WORKSPACE_EXT); - workspaceService.root.then(root => { - if (root) { - this.workspaceRoot = Uri.parse(root.uri); + workspaceService.roots.then(roots => { + const workspaceFolder = roots[0]; + if (workspaceFolder) { + this.workspaceRoot = Uri.parse(workspaceFolder.uri); const workspacePath = new Path(this.workspaceRoot.path); const folder: WorkspaceFolder = { diff --git a/packages/preferences/src/browser/workspace-preference-provider.ts b/packages/preferences/src/browser/workspace-preference-provider.ts index 7f109038737f5..a326fc30d1d91 100644 --- a/packages/preferences/src/browser/workspace-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-preference-provider.ts @@ -26,9 +26,9 @@ export class WorkspacePreferenceProvider extends AbstractResourcePreferenceProvi protected readonly workspaceService: WorkspaceService; async getUri(): Promise { - const root = await this.workspaceService.root; - if (root) { - const rootUri = new URI(root.uri); + const workspaceFolder = (await this.workspaceService.roots)[0]; + if (workspaceFolder) { + const rootUri = new URI(workspaceFolder.uri); return rootUri.resolve('.theia').resolve('settings.json'); } return undefined; diff --git a/packages/python/src/node/python-contribution.ts b/packages/python/src/node/python-contribution.ts index f6e25a446e03f..cce0dff551e48 100644 --- a/packages/python/src/node/python-contribution.ts +++ b/packages/python/src/node/python-contribution.ts @@ -31,7 +31,7 @@ export class PythonContribution extends BaseLanguageServerContribution { const pythonLsCommand = process.env.PYTHON_LS_COMMAND; if (pythonLsCommand) { command = pythonLsCommand; - args = parseArgs(process.env.PYTHON_LS_ARGS || ""); + args = parseArgs(process.env.PYTHON_LS_ARGS || ''); } const serverConnection = this.createProcessStreamConnection(command, args); this.forward(clientConnection, serverConnection); 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 20450cee7754e..b559a46d5970b 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 @@ -113,7 +113,8 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { super.init(); this.addClass('resultContainer'); - this.workspaceService.root.then(rootFileStat => { + this.workspaceService.roots.then(roots => { + const rootFileStat = roots[0]; if (rootFileStat) { const uri = new URI(rootFileStat.uri); this.workspaceRoot = uri.withoutScheme().toString(); diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-service.ts b/packages/search-in-workspace/src/browser/search-in-workspace-service.ts index dd7738048b175..1b77fc7106e77 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-service.ts +++ b/packages/search-in-workspace/src/browser/search-in-workspace-service.ts @@ -106,7 +106,7 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient { // Start a search of the string "what" in the workspace. async search(what: string, callbacks: SearchInWorkspaceCallbacks, opts?: SearchInWorkspaceOptions): Promise { - const root = await this.workspaceService.root; + const root = (await this.workspaceService.roots)[0]; if (!root) { throw new Error('Search failed: no workspace root.'); diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index 320ce2fce8bcf..65cbb770bbd33 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -77,7 +77,8 @@ export class TaskService implements TaskConfigurationClient { @postConstruct() protected init(): void { // wait for the workspace root to be set - this.workspaceService.root.then(async root => { + this.workspaceService.roots.then(async roots => { + const root = roots[0]; if (root) { this.configurationFileFound = await this.taskConfigurations.watchConfigurationFile(root.uri); this.workspaceRootUri = root.uri; diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index d501e9cd66be6..8d9782699e41a 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -239,7 +239,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget protected async createTerminal(): Promise { let rootURI = this.options.cwd; if (!rootURI) { - const root = await this.workspaceService.root; + const root = (await this.workspaceService.roots)[0]; rootURI = root && root.uri; } const { cols, rows } = this.term; diff --git a/packages/workspace/src/browser/quick-open-workspace.ts b/packages/workspace/src/browser/quick-open-workspace.ts index d2e84189696ce..51d3459232cbf 100644 --- a/packages/workspace/src/browser/quick-open-workspace.ts +++ b/packages/workspace/src/browser/quick-open-workspace.ts @@ -87,7 +87,8 @@ export class WorkspaceQuickOpenItem extends QuickOpenItem { if (mode !== QuickOpenMode.OPEN) { return false; } - this.workspaceService.root.then(current => { + this.workspaceService.roots.then(roots => { + const current = roots[0]; if (current === undefined) { // Available recent workspace(s) but closed if (this.workspace && this.workspace.length > 0) { this.workspaceService.open(new URI(this.workspace)); diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index e010804598e2c..9c47c247c7cc1 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -22,11 +22,13 @@ import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/c import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common/filesystem'; +import { FileStatNode, FileDialogService } from '@theia/filesystem/lib/browser'; import { SingleTextInputDialog, ConfirmDialog } from '@theia/core/lib/browser/dialogs'; import { OpenerService, OpenHandler, open, FrontendApplication } from '@theia/core/lib/browser'; import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; import { WorkspaceService } from './workspace-service'; import { MessageService } from '@theia/core/lib/common/message-service'; +import { WorkspacePreferences } from './workspace-preferences'; const validFilename: (arg: string) => boolean = require('valid-filename'); @@ -68,6 +70,13 @@ export namespace WorkspaceCommands { id: 'file.compare', label: 'Compare with Each Other' }; + export const ADD_FOLDER: Command = { + id: 'workspace:addFolder', + label: 'Add Folder to Workspace...' + }; + export const REMOVE_FOLDER: Command = { + id: 'workspace:removeFolder' + }; } @injectable() @@ -93,8 +102,9 @@ export class WorkspaceCommandContribution implements CommandContribution { @inject(SelectionService) protected readonly selectionService: SelectionService, @inject(OpenerService) protected readonly openerService: OpenerService, @inject(FrontendApplication) protected readonly app: FrontendApplication, - @inject(MessageService) protected readonly messageService: MessageService - + @inject(MessageService) protected readonly messageService: MessageService, + @inject(WorkspacePreferences) protected readonly preferences: WorkspacePreferences, + @inject(FileDialogService) protected readonly fileDialogService: FileDialogService ) { } registerCommands(registry: CommandRegistry): void { @@ -164,7 +174,8 @@ export class WorkspaceCommandContribution implements CommandContribution { }) })); let rootUri: URI | undefined; - this.workspaceService.root.then(root => { + this.workspaceService.roots.then(roots => { + const root = roots[0]; if (root) { rootUri = new URI(root.uri); } @@ -193,7 +204,7 @@ export class WorkspaceCommandContribution implements CommandContribution { })(); const dialog = new ConfirmDialog({ title: `Delete File${uris.length === 1 ? '' : 's'}`, - msg, + msg }); if (await dialog.open()) { @@ -239,6 +250,32 @@ export class WorkspaceCommandContribution implements CommandContribution { } } })); + this.preferences.ready.then(() => { + const isEnabled = () => this.workspaceService.isMultiRootWorkspaceOpened; + const isVisible = (uris: URI[]): boolean => { + const roots = this.workspaceService.tryGetRoots(); + const selected = new Set(uris.map(u => u.toString())); + for (const root of roots) { + if (selected.has(root.uri)) { + return true; + } + } + return false; + }; + registry.registerCommand(WorkspaceCommands.ADD_FOLDER, this.newMultiUriAwareCommandHandler({ + isEnabled, + isVisible, + execute: async uris => { + const node = await this.fileDialogService.show({ title: WorkspaceCommands.ADD_FOLDER.label! }); + this.addFolderToWorkspace(node); + } + })); + registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, this.newMultiUriAwareCommandHandler({ + execute: uris => this.removeFolderFromWorkspace(uris), + isEnabled, + isVisible + })); + }); } protected newUriAwareCommandHandler(handler: UriCommandHandler): UriAwareCommandHandler { @@ -296,6 +333,43 @@ export class WorkspaceCommandContribution implements CommandContribution { } return parentUri.resolve(base); } + + protected addFolderToWorkspace(node: Readonly | undefined): void { + if (!node) { + return; + } + if (node.fileStat.isDirectory) { + this.workspaceService.addRoot(node.uri); + } else { + throw new Error(`Invalid folder. URI: ${node.fileStat.uri}.`); + } + } + + protected async removeFolderFromWorkspace(uris: URI[]): Promise { + const roots = new Set(this.workspaceService.tryGetRoots().map(r => r.uri)); + const toRemove = uris.filter(u => roots.has(u.toString())); + if (toRemove.length > 0) { + const messageContainer = document.createElement('div'); + messageContainer.textContent = 'Remove the following folders from workspace? (note: nothing will be erased from disk)'; + const list = document.createElement('ul'); + list.style.listStyleType = 'none'; + toRemove.forEach(u => { + const listItem = document.createElement('li'); + listItem.textContent = u.displayName; + list.appendChild(listItem); + }); + messageContainer.appendChild(list); + const dialog = new ConfirmDialog({ + title: 'Remove Folder from Workspace', + msg: messageContainer + }); + if (await dialog.open()) { + await this.workspaceService.removeRoots(toRemove); + } + } else { + throw new Error('Expected at least one root folder location.'); + } + } } export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler { @@ -308,7 +382,8 @@ export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler< protected readonly handler: UriCommandHandler ) { super(selectionService, handler); - workspaceService.root.then(root => { + workspaceService.roots.then(roots => { + const root = roots[0]; if (root) { this.rootUri = new URI(root.uri); } diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index cfa07d68247a0..209bf6eccdca2 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -15,10 +15,9 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; -import URI from '@theia/core/lib/common/uri'; import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common'; import { open, OpenerService, CommonMenus, StorageService, LabelProvider, ConfirmDialog } from '@theia/core/lib/browser'; -import { DirNode, FileDialogFactory, FileStatNode } from '@theia/filesystem/lib/browser'; +import { FileDialogFactory, FileStatNode, FileDialogService } from '@theia/filesystem/lib/browser'; import { FileSystem } from '@theia/filesystem/lib/common'; import { WorkspaceService } from './workspace-service'; import { WorkspaceCommands } from './workspace-commands'; @@ -35,6 +34,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, MenuC @inject(StorageService) protected readonly workspaceStorage: StorageService, @inject(LabelProvider) protected readonly labelProvider: LabelProvider, @inject(QuickOpenWorkspace) protected readonly quickOpenWorkspace: QuickOpenWorkspace, + @inject(FileDialogService) protected readonly fileDialogService: FileDialogService ) { } registerCommands(commands: CommandRegistry): void { @@ -65,23 +65,9 @@ export class WorkspaceFrontendContribution implements CommandContribution, MenuC } protected showFileDialog(): void { - this.workspaceService.root.then(async resolvedRoot => { - const root = resolvedRoot || await this.fileSystem.getCurrentUserHome(); - if (root) { - const parentUri = new URI(root.uri).parent; - const parentStat = await this.fileSystem.getFileStat(parentUri.toString()); - if (parentStat) { - const name = this.labelProvider.getName(parentUri); - const label = await this.labelProvider.getIcon(root); - const rootNode = DirNode.createRoot(parentStat, name, label); - const dialog = this.fileDialogFactory({ title: WorkspaceCommands.OPEN.label! }); - dialog.model.navigateTo(rootNode); - const result = await dialog.open(); - if (FileStatNode.is(result)) { - this.openFile(result); - } - } - } + this.workspaceService.roots.then(async roots => { + const node = await this.fileDialogService.show({ title: WorkspaceCommands.OPEN.label! }, roots[0]); + this.openFile(node); }); } diff --git a/packages/workspace/src/browser/workspace-preferences.ts b/packages/workspace/src/browser/workspace-preferences.ts index f2558c8f994de..36a250d8f08af 100644 --- a/packages/workspace/src/browser/workspace-preferences.ts +++ b/packages/workspace/src/browser/workspace-preferences.ts @@ -24,20 +24,28 @@ import { } from '@theia/core/lib/browser/preferences'; export const workspacePreferenceSchema: PreferenceSchema = { - 'type': 'object', - 'properties': { + type: 'object', + properties: { 'workspace.preserveWindow': { - 'description': 'Enable opening workspaces in current window', - 'additionalProperties': { - 'type': 'boolean' + description: 'Enable opening workspaces in current window', + additionalProperties: { + type: 'boolean' }, - 'default': false + default: false + }, + 'workspace.supportMultiRootWorkspace': { + description: 'Enable the multi-root workspace support to test this feature internally', + additionalProperties: { + type: 'boolean' + }, + default: false } } }; export interface WorkspaceConfiguration { - 'workspace.preserveWindow': boolean + 'workspace.preserveWindow': boolean, + 'workspace.supportMultiRootWorkspace': boolean } export const WorkspacePreferences = Symbol('WorkspacePreferences'); diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index 6d0d01b621c86..67a4dc2e7faf3 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -21,6 +21,7 @@ import { FileSystemWatcher } from '@theia/filesystem/lib/browser'; import { WorkspaceServer } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { Disposable, Emitter, Event } from '@theia/core/lib/common'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ILogger } from '@theia/core/lib/common/logger'; import { WorkspacePreferences } from './workspace-preferences'; @@ -31,11 +32,12 @@ import { WorkspacePreferences } from './workspace-preferences'; @injectable() export class WorkspaceService implements FrontendApplicationContribution { - private _root: FileStat | undefined; + // TODO remove it with the patch where the config file becomes independent from the workspace + private _workspaceFolder: FileStat | undefined; + private _roots: FileStat[]; + private deferredRoots = new Deferred(); - private readonly deferredRoot = new Deferred(); - - readonly root = this.deferredRoot.promise; + private rootWatchers: { [uri: string]: Disposable } = {}; private hasWorkspace: boolean = false; @@ -59,18 +61,108 @@ export class WorkspaceService implements FrontendApplicationContribution { @postConstruct() protected async init(): Promise { - const rootUri = await this.server.getWorkspace(); - this._root = await this.toValidRoot(rootUri); - if (this._root) { - const uri = new URI(this._root.uri); - this.updateTitle(uri); - this.watcher.watchFileChanges(uri); + this._workspaceFolder = undefined; + this._roots = []; + await this.updateWorkspace(); + this.updateTitle(); + const configUri = this.getWorkspaceConfigFileUri(); + if (configUri) { + const configUriString = configUri.toString(); + this.watcher.onFilesChanged(changes => { + if (changes.some(change => change.uri.toString() === configUriString)) { + this.updateWorkspace(); + } + }); + } + } + + get roots(): Promise { + return this.deferredRoots.promise; + } + tryGetRoots(): FileStat[] { + return this._roots; + } + + protected readonly onWorkspaceChangeEmitter = new Emitter(); + get onWorkspaceChanged(): Event { + return this.onWorkspaceChangeEmitter.event; + } + + protected async updateWorkspace(): Promise { + if (!this._workspaceFolder) { + const rootUri = await this.server.getMostRecentlyUsedWorkspace(); + this._workspaceFolder = await this.toValidRoot(rootUri); + if (this._workspaceFolder) { + this._roots.push(this._workspaceFolder); + } + } + this.deferredRoots.resolve(this._roots); + + await this.preferences.ready; + if (this._workspaceFolder) { + let roots: string[]; + if (this.preferences['workspace.supportMultiRootWorkspace']) { + const rootConfig = await this.getRootConfig(); + roots = rootConfig.roots; + } else { + roots = [this._workspaceFolder.uri]; + } + + for (const rootBeingWatched of Object.keys(this.rootWatchers)) { + if (roots.indexOf(rootBeingWatched) < 0) { + this.stopWatch(rootBeingWatched); + } + } + this._roots.length = 0; + for (const rootToWatch of roots) { + const valid = await this.toValidRoot(rootToWatch); + if (!this.rootWatchers[rootToWatch]) { + await this.startWatch(valid); + } + if (valid) { + this._roots.push(valid); + } + } + if (!this.rootWatchers[this._workspaceFolder.uri]) { + // must watch the workspace folder for meta data changes, even if it is not in the workspace + await this.startWatch(this._workspaceFolder); + } + } + this.onWorkspaceChangeEmitter.fire(this._roots); + } + + protected async getRootConfig(): Promise<{ stat: FileStat | undefined, roots: string[] }> { + const configUri = this.getWorkspaceConfigFileUri(); + if (configUri) { + let fileStat = undefined; + const uriStr = configUri.path.toString(); + if (await this.fileSystem.exists(uriStr)) { + const { stat, content } = await this.fileSystem.resolveContent(uriStr); + fileStat = stat; + if (content) { + const roots = JSON.parse(content).roots || []; + return { stat, roots: this._workspaceFolder && roots.length === 0 ? [this._workspaceFolder.uri] : roots }; + } + } + return { stat: fileStat, roots: [this._workspaceFolder!.uri] }; + } + return { stat: undefined, roots: [] }; + } + + protected getWorkspaceConfigFileUri(): URI | undefined { + if (this._workspaceFolder) { + const rootUri = new URI(this._workspaceFolder.uri); + return rootUri.resolve('.theia').resolve('root.json'); } - this.deferredRoot.resolve(this._root); } - protected updateTitle(uri: URI): void { - document.title = uri.displayName; + protected updateTitle(): void { + if (this._workspaceFolder) { + const uri = new URI(this._workspaceFolder.uri); + document.title = uri.displayName; + } else { + document.title = window.location.href; + } } /** @@ -78,8 +170,8 @@ export class WorkspaceService implements FrontendApplicationContribution { * @param app */ onStop(app: FrontendApplication): void { - if (this._root) { - this.server.setWorkspace(this._root.uri); + if (this._workspaceFolder) { + this.server.setMostRecentlyUsedWorkspace(this._workspaceFolder.uri); } } @@ -103,7 +195,15 @@ export class WorkspaceService implements FrontendApplicationContribution { * @returns {boolean} */ get opened(): boolean { - return !!this._root; + return !!this._workspaceFolder; + } + + /** + * Returns `true` if there is an opened workspace in theia, and the workspace has more than one root. + * @returns {boolean} + */ + get isMultiRootWorkspaceOpened(): boolean { + return this.opened && this.preferences['workspace.supportMultiRootWorkspace']; } /** @@ -119,30 +219,82 @@ export class WorkspaceService implements FrontendApplicationContribution { if (valid) { // The same window has to be preserved too (instead of opening a new one), if the workspace root is not yet available and we are setting it for the first time. // Option passed as parameter has the highest priority (for api developers), then the preference, then the default. + await this.roots; + const rootToOpen = this._workspaceFolder; const { preserveWindow } = { - preserveWindow: this.preferences['workspace.preserveWindow'] || !(await this.root), + preserveWindow: this.preferences['workspace.preserveWindow'] || !(rootToOpen), ...options }; - await this.server.setWorkspace(rootUri); + await this.server.setMostRecentlyUsedWorkspace(rootUri); if (preserveWindow) { - this._root = valid; + this._workspaceFolder = valid; } - this.openWindow(uri, { preserveWindow }); + await this.openWindow({ preserveWindow }); return; } - throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${rootUri}.`); + throw new Error('Invalid workspace root URI. Expected an existing directory location.'); } /** - * Clears current workspace root and reloads window. + * Adds a root folder to the workspace + * @param uri URI of the root folder being added */ - close(): void { - this.doClose(); + async addRoot(uri: URI): Promise { + await this.roots; + if (!this.opened || !this._workspaceFolder) { + throw new Error('Folder cannot be added as there is no active folder in the current workspace.'); + } + + const rootToAdd = uri.toString(); + const valid = await this.toValidRoot(rootToAdd); + if (!valid) { + throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${rootToAdd}.`); + } + if (this._workspaceFolder && !this._roots.find(r => r.uri === valid.uri)) { + const configUri = this.getWorkspaceConfigFileUri(); + if (configUri) { + if (!await this.fileSystem.exists(configUri.toString())) { + await this.fileSystem.createFile(configUri.toString()); + } + await this.writeRootFolderConfigFile( + (await this.fileSystem.getFileStat(configUri.toString()))!, + [...this._roots, valid] + ); + } + } + } + + /** + * Removes root folder(s) from workspace. + */ + async removeRoots(uris: URI[]): Promise { + if (!this.opened) { + throw new Error('Folder cannot be removed as there is no active folder in the current workspace.'); + } + const configStat = (await this.getRootConfig()).stat; + if (configStat) { + await this.writeRootFolderConfigFile( + configStat, this._roots.filter(root => uris.findIndex(u => u.toString() === root.uri) < 0) + ); + } } - protected async doClose(): Promise { - this._root = undefined; - await this.server.setWorkspace(''); + private async writeRootFolderConfigFile(rootConfigFile: FileStat, rootFolders: FileStat[]): Promise { + const folders = rootFolders.slice(); + if (folders.length === 0 && this._workspaceFolder) { + folders.push(this._workspaceFolder); + } + await this.fileSystem.setContent(rootConfigFile, JSON.stringify({ roots: folders.map(f => f.uri) })); + } + + /** + * Clears current workspace root. + */ + close(): void { + this._workspaceFolder = undefined; + this._roots.length = 0; + + this.server.setMostRecentlyUsedWorkspace(''); this.reloadWindow(); } @@ -170,7 +322,7 @@ export class WorkspaceService implements FrontendApplicationContribution { } } - protected openWindow(uri: URI, options?: WorkspaceInput): void { + protected openWindow(options?: WorkspaceInput): void { if (this.shouldPreserveWindow(options)) { this.reloadWindow(); } else { @@ -178,8 +330,8 @@ export class WorkspaceService implements FrontendApplicationContribution { this.openNewWindow(); } catch (error) { // Fall back to reloading the current window in case the browser has blocked the new window - this._root = undefined; - this.logger.error(error.toString()).then(() => this.reloadWindow()); + this._workspaceFolder = undefined; + this.logger.error(error.toString()).then(async () => await this.reloadWindow()); } } } @@ -201,9 +353,9 @@ export class WorkspaceService implements FrontendApplicationContribution { * NOTE: You should always explicitly use `/` as the separator between the path segments. */ async containsSome(paths: string[]): Promise { - const workspaceRoot = await this.root; - if (workspaceRoot) { - const uri = new URI(workspaceRoot.uri); + await this.roots; + if (this._workspaceFolder) { + const uri = new URI(this._workspaceFolder.uri); for (const path of paths) { const fileUri = uri.resolve(path).toString(); const exists = await this.fileSystem.exists(fileUri); @@ -215,6 +367,27 @@ export class WorkspaceService implements FrontendApplicationContribution { return false; } + protected async startWatch(validRoot: FileStat | undefined): Promise { + if (validRoot && !this.rootWatchers[validRoot.uri]) { + const uri = new URI(validRoot.uri); + const watcher = (await this.watcher.watchFileChanges(uri)); + this.rootWatchers[validRoot.uri] = watcher; + } + } + + protected stopWatch(uri?: string): void { + if (uri) { + if (this.rootWatchers[uri]) { + this.rootWatchers[uri].dispose(); + delete this.rootWatchers[uri]; + } + } else { + for (const watchedUri of Object.keys(this.rootWatchers)) { + this.rootWatchers[watchedUri].dispose(); + } + this.rootWatchers = {}; + } + } } export interface WorkspaceInput { diff --git a/packages/workspace/src/browser/workspace-storage-service.ts b/packages/workspace/src/browser/workspace-storage-service.ts index 500db362033c7..c773212400c10 100644 --- a/packages/workspace/src/browser/workspace-storage-service.ts +++ b/packages/workspace/src/browser/workspace-storage-service.ts @@ -18,6 +18,7 @@ import { inject, injectable, postConstruct } from 'inversify'; import { LocalStorageService } from '@theia/core/lib/browser/storage-service'; import { StorageService } from '@theia/core/lib/browser/storage-service'; import { WorkspaceService } from './workspace-service'; +import { FileStat } from '@theia/filesystem/lib/common'; /* * Prefixes any stored data with the current workspace path. @@ -33,12 +34,8 @@ export class WorkspaceStorageService implements StorageService { @postConstruct() protected init() { - this.initialized = this.workspaceService.root.then(stat => { - if (stat) { - this.prefix = stat.uri; - } else { - this.prefix = '_global_'; - } + this.initialized = this.workspaceService.roots.then(roots => { + this.prefix = this.getPrefix(roots[0]); }); } @@ -57,6 +54,10 @@ export class WorkspaceStorageService implements StorageService { } protected prefixWorkspaceURI(originalKey: string): string { - return this.prefix + ':' + originalKey; + return `${this.prefix}:${originalKey}`; + } + + protected getPrefix(rootStat: FileStat | undefined): string { + return rootStat ? rootStat.uri : '_global_'; } } diff --git a/packages/workspace/src/browser/workspace-uri-contribution.ts b/packages/workspace/src/browser/workspace-uri-contribution.ts index 888e1dd051e82..a440b1b90583b 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.ts @@ -16,7 +16,7 @@ import { DefaultUriLabelProviderContribution } from '@theia/core/lib/browser/label-provider'; import URI from '@theia/core/lib/common/uri'; -import { injectable, inject } from 'inversify'; +import { injectable, inject, postConstruct } from 'inversify'; import { WorkspaceService } from './workspace-service'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { MaybePromise } from '@theia/core'; @@ -24,15 +24,19 @@ import { MaybePromise } from '@theia/core'; @injectable() export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProviderContribution { + @inject(WorkspaceService) + protected workspaceService: WorkspaceService; + @inject(FileSystem) + protected fileSystem: FileSystem; + wsRoot: string; - constructor(@inject(WorkspaceService) wsService: WorkspaceService, - @inject(FileSystem) protected fileSystem: FileSystem) { - super(); - wsService.root.then(root => { - if (root) { - this.wsRoot = new URI(root.uri).toString(true); - } - }); + + @postConstruct() + protected async init(): Promise { + const root = (await this.workspaceService.roots)[0]; + if (root) { + this.wsRoot = new URI(root.uri).toString(true); + } } canHandle(element: object): number { diff --git a/packages/workspace/src/browser/workspace-variable-contribution.ts b/packages/workspace/src/browser/workspace-variable-contribution.ts index 75b94936ebfaf..dd513432228b7 100644 --- a/packages/workspace/src/browser/workspace-variable-contribution.ts +++ b/packages/workspace/src/browser/workspace-variable-contribution.ts @@ -96,7 +96,7 @@ export class WorkspaceVariableContribution implements VariableContribution { } protected async getWorkspaceRootUri(): Promise { - const wsRoot = await this.workspaceService.root; + const wsRoot = (await this.workspaceService.roots)[0]; if (wsRoot) { return new URI(wsRoot.uri); } diff --git a/packages/workspace/src/common/test/mock-workspace-server.ts b/packages/workspace/src/common/test/mock-workspace-server.ts index 569c0be10b8a2..c85c12df8a76a 100644 --- a/packages/workspace/src/common/test/mock-workspace-server.ts +++ b/packages/workspace/src/common/test/mock-workspace-server.ts @@ -19,9 +19,9 @@ import { WorkspaceServer } from '../workspace-protocol'; @injectable() export class MockWorkspaceServer implements WorkspaceServer { - getWorkspace(): Promise { return Promise.resolve(''); } + getRecentWorkspaces(): Promise { return Promise.resolve([]); } - setWorkspace(uri: string): Promise { return Promise.resolve(); } + getMostRecentlyUsedWorkspace(): Promise { return Promise.resolve(''); } - getRecentWorkspaces(): Promise { return Promise.resolve([]); } + setMostRecentlyUsedWorkspace(uri: string): Promise { return Promise.resolve(); } } diff --git a/packages/workspace/src/common/workspace-protocol.ts b/packages/workspace/src/common/workspace-protocol.ts index 6f04f0f35bb54..428ce6a139b5b 100644 --- a/packages/workspace/src/common/workspace-protocol.ts +++ b/packages/workspace/src/common/workspace-protocol.ts @@ -23,18 +23,18 @@ export const WorkspaceServer = Symbol('WorkspaceServer'); export interface WorkspaceServer { /** - * Returns with a promise that resolves to the workspace root URI as a string. Resolves to `undefined` if the workspace root is not yet set. + * Returns with a promise that resolves to the most recently used workspace folder URI as a string. + * Resolves to `undefined` if the workspace folder is not yet set. */ - getWorkspace(): Promise; + getMostRecentlyUsedWorkspace(): Promise; /** - * Sets the desired string representation of the URI as the workspace root. + * Sets the desired string representation of the URI as the most recently used workspace folder. */ - setWorkspace(uri: string): Promise; + setMostRecentlyUsedWorkspace(uri: string): Promise; /** * Returns list of recently opened workspaces as an array. */ getRecentWorkspaces(): Promise - } diff --git a/packages/workspace/src/node/default-workspace-server.ts b/packages/workspace/src/node/default-workspace-server.ts index 554dc3b2476cf..82bc614fbb55e 100644 --- a/packages/workspace/src/node/default-workspace-server.ts +++ b/packages/workspace/src/node/default-workspace-server.ts @@ -52,6 +52,9 @@ export class WorkspaceCliContribution implements CliContribution { const cwd = process.cwd(); wsPath = path.join(cwd, wsPath); } + if (wsPath && wsPath.endsWith('/')) { + wsPath = wsPath.slice(0, -1); + } this.workspaceRoot.resolve(wsPath); } } @@ -74,7 +77,7 @@ export class DefaultWorkspaceServer implements WorkspaceServer { protected async init() { let root = await this.getWorkspaceURIFromCli(); if (!root) { - const data = await this.readFromUserHome(); + const data = await this.readMostRecentWorkspaceRootFromUserHome(); if (data && data.recentRoots) { root = data.recentRoots[0]; } @@ -82,11 +85,11 @@ export class DefaultWorkspaceServer implements WorkspaceServer { this.root.resolve(root); } - getWorkspace(): Promise { + getMostRecentlyUsedWorkspace(): Promise { return this.root.promise; } - async setWorkspace(uri: string): Promise { + async setMostRecentlyUsedWorkspace(uri: string): Promise { this.root = new Deferred(); const listUri: string[] = []; const oldListUri = await this.getRecentWorkspaces(); @@ -106,7 +109,7 @@ export class DefaultWorkspaceServer implements WorkspaceServer { async getRecentWorkspaces(): Promise { const listUri: string[] = []; - const data = await this.readFromUserHome(); + const data = await this.readMostRecentWorkspaceRootFromUserHome(); if (data && data.recentRoots) { data.recentRoots.forEach(element => { if (element.length > 0) { @@ -138,19 +141,29 @@ export class DefaultWorkspaceServer implements WorkspaceServer { */ private async writeToUserHome(data: WorkspaceData): Promise { const file = this.getUserStoragePath(); - if (!await fs.pathExists(file)) { - await fs.mkdirs(path.resolve(file, '..')); + await this.writeToFile(file, data); + } + + private async writeToFile(filePath: string, data: object): Promise { + if (!await fs.pathExists(filePath)) { + await fs.mkdirs(path.resolve(filePath, '..')); } - await fs.writeJson(file, data); + await fs.writeJson(filePath, data); } /** * Reads the most recently used workspace root from the user's home directory. */ - private async readFromUserHome(): Promise { - const file = this.getUserStoragePath(); - if (await fs.pathExists(file)) { - const rawContent = await fs.readFile(file, 'utf-8'); + private async readMostRecentWorkspaceRootFromUserHome(): Promise { + const data = await this.readJsonFromFile(this.getUserStoragePath()); + if (data && WorkspaceData.is(data)) { + return data; + } + } + + private async readJsonFromFile(filePath: string): Promise { + if (await fs.pathExists(filePath)) { + const rawContent = await fs.readFile(filePath, 'utf-8'); const content = rawContent.trim(); if (!content) { return undefined; @@ -160,8 +173,8 @@ export class DefaultWorkspaceServer implements WorkspaceServer { try { config = JSON.parse(content); } catch (error) { - this.messageService.warn(`Parse error in '${file}':\nFile will be ignored...`); - error.message = `${file}:\n${error.message}`; + this.messageService.warn(`Parse error in '${filePath}':\nFile will be ignored...`); + error.message = `${filePath}:\n${error.message}`; this.logger.warn('[CAUGHT]', error); return undefined; } @@ -177,7 +190,6 @@ export class DefaultWorkspaceServer implements WorkspaceServer { protected getUserStoragePath(): string { return path.resolve(os.homedir(), '.theia', 'recentworkspace.json'); } - } interface WorkspaceData {