From a98a3518f9856c64ef7a122d564c39e1671b4b07 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 - minimal patch of #1660 - use the first root folder as the workspace folder. And the workspace config would be saved under that folder in the `root.json` file - workspace folders can be added and removed as needed, while the first root folder cannot be removed from the workspace - 'workspace.supportMultiRootWorkspace' is added as a preference, which users could toggle (true/false), to show & hide the "add/remove folder" menu items - when add / remove folders to / from the workspace, use event to keep widgets up to date, instead of reloading the whole theia, whereas when users switch from one workspace to another in the same tab, or close the workspace, theia still reloads. - extensions updated: workspace, git, & navigator Signed-off-by: elaihau --- packages/core/src/browser/dialogs.ts | 10 +- packages/core/src/browser/tree/tree-model.ts | 4 + packages/core/src/browser/tree/tree.ts | 17 +- .../src/browser/quick-file-open.ts | 6 +- .../src/browser/file-tree/file-tree-model.ts | 23 +- .../src/browser/file-tree/file-tree-widget.ts | 28 +- .../src/browser/file-tree/file-tree.ts | 98 ++++++- .../src/browser/filesystem-listener.ts | 4 +- packages/git/src/browser/git-decorator.ts | 4 +- .../src/browser/git-repository-provider.ts | 42 ++- packages/git/src/browser/git-sync-service.ts | 7 +- .../git/src/browser/git-view-contribution.ts | 24 +- .../src/browser/history/git-history-widget.ts | 2 + .../monaco/src/browser/monaco-workspace.ts | 11 +- .../src/browser/navigator-contribution.ts | 78 +++++- .../navigator/src/browser/navigator-model.ts | 44 ++- .../navigator/src/browser/navigator-tree.ts | 6 +- .../navigator/src/browser/navigator-widget.ts | 59 +++- .../abstract-resource-preference-provider.ts | 23 +- .../preference-frontend-contribution.ts | 5 +- .../browser/workspace-preference-provider.ts | 10 +- .../search-in-workspace-result-tree-widget.ts | 3 +- .../browser/search-in-workspace-service.ts | 4 +- packages/task/src/browser/task-service.ts | 11 +- .../terminal/src/browser/terminal-widget.ts | 10 +- .../src/browser/workspace-commands.ts | 14 +- .../workspace-frontend-contribution.ts | 72 +++-- .../src/browser/workspace-preferences.ts | 26 +- .../src/browser/workspace-service.ts | 262 ++++++++++++++++-- .../src/browser/workspace-storage-service.ts | 18 +- .../src/browser/workspace-uri-contribution.ts | 3 +- .../workspace-variable-contribution.ts | 2 +- .../src/common/test/mock-workspace-server.ts | 8 + .../src/common/workspace-protocol.ts | 37 +++ .../src/node/default-workspace-server.ts | 104 +++++-- 35 files changed, 885 insertions(+), 194 deletions(-) diff --git a/packages/core/src/browser/dialogs.ts b/packages/core/src/browser/dialogs.ts index d0e3563a69da6..995830508862f 100644 --- a/packages/core/src/browser/dialogs.ts +++ b/packages/core/src/browser/dialogs.ts @@ -189,7 +189,9 @@ export abstract class AbstractDialog extends BaseWidget { @injectable() export class ConfirmDialogProps extends DialogProps { readonly msg: string | HTMLElement; + readonly hasCancel: boolean; readonly cancel?: string; + readonly hasOk: boolean; readonly ok?: string; } @@ -201,8 +203,12 @@ export class ConfirmDialog extends AbstractDialog { super(props); this.contentNode.appendChild(this.createMessageNode(this.props.msg)); - this.appendCloseButton(props.cancel); - this.appendAcceptButton(props.ok); + if (props.hasCancel) { + this.appendCloseButton(props.cancel); + } + if (props.hasOk) { + this.appendAcceptButton(props.ok); + } } protected onCloseRequest(msg: Message): void { diff --git a/packages/core/src/browser/tree/tree-model.ts b/packages/core/src/browser/tree/tree-model.ts index 45807e5e3cc0c..af500625b18f7 100644 --- a/packages/core/src/browser/tree/tree-model.ts +++ b/packages/core/src/browser/tree/tree-model.ts @@ -182,6 +182,10 @@ export class TreeModelImpl implements TreeModel, SelectionProvider boolean): TreeNode[] { + return this.tree.getNodes(test); + } + validateNode(node: TreeNode | undefined) { return this.tree.validateNode(node); } diff --git a/packages/core/src/browser/tree/tree.ts b/packages/core/src/browser/tree/tree.ts index 35861ae24d1f5..17d69577503a1 100644 --- a/packages/core/src/browser/tree/tree.ts +++ b/packages/core/src/browser/tree/tree.ts @@ -28,6 +28,10 @@ export interface Tree extends Disposable { * Return a node for the given identifier or undefined if such does not exist. */ getNode(id: string | undefined): TreeNode | undefined; + /** + * Return an array of nodes that satisfy the given condition. An empty array is return when none is found. + */ + getNodes(test: (node: TreeNode) => boolean): TreeNode[]; /** * Return a valid node in this tree matching to the given; otherwise undefined. */ @@ -41,7 +45,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>; } @@ -204,6 +208,17 @@ export class TreeImpl implements Tree { return id !== undefined ? this.nodes[id] : undefined; } + getNodes(test: (node: TreeNode) => boolean): TreeNode[] { + const nodes: TreeNode[] = []; + Object.keys(this.nodes).forEach(nodeId => { + const node = this.nodes[nodeId]; + if (node && test(node)) { + nodes.push(node); + } + }); + return nodes; + } + validateNode(node: TreeNode | undefined): TreeNode | undefined { const id = !!node ? node.id : undefined; return this.getNode(id); diff --git a/packages/file-search/src/browser/quick-file-open.ts b/packages/file-search/src/browser/quick-file-open.ts index e9eeda3414982..81ff8af7ad40d 100644 --- a/packages/file-search/src/browser/quick-file-open.ts +++ b/packages/file-search/src/browser/quick-file-open.ts @@ -37,7 +37,11 @@ export class QuickFileOpenService implements QuickOpenModel { @inject(FileSearchService) protected readonly fileSearchService: FileSearchService, @inject(LabelProvider) protected readonly labelProvider: LabelProvider ) { - workspaceService.root.then(root => this.wsRoot = root); + this.workspaceService.onWorkspaceRootChanged(event => { + if (event) { + this.wsRoot = event.root; + } + }); } protected wsRoot: FileStat | undefined; 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 6a39cde3d50f8..1a88f2ba675f6 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-model.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree-model.ts @@ -86,13 +86,16 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { if (this.isFileContentChanged(change)) { return; } - const parent = this.getNode(change.uri.parent.toString()); - if (DirNode.is(parent) && parent.expanded) { - accept(parent); + const parentNodes = this.getNodes((node: FileStatNode) => change.uri.parent.toString() === node.uri.toString()); + for (const parent of parentNodes) { + if (DirNode.is(parent) && parent.expanded) { + accept(parent); + } } } protected isFileContentChanged(change: FileChange): boolean { - return change.type === FileChangeType.UPDATED && FileNode.is(this.getNode(change.uri.toString())); + const nodes = this.getNodes((node: FileStatNode) => change.uri.toString() === node.uri.toString()); + return change.type === FileChangeType.UPDATED && nodes.length > 0 && FileNode.is(nodes[0]); } copy(uri: URI): boolean { @@ -112,6 +115,10 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { * Move the given source file or directory to the given target directory. */ async move(source: TreeNode, target: TreeNode) { + if (source.parent && source.parent.name === 'WorkspaceRoot') { + // do not support moving the root folder + return; + } if (DirNode.is(target) && FileStatNode.is(source)) { const sourceUri = source.uri.toString(); if (target.uri.toString() === sourceUri) { /* Folder on itself */ @@ -124,6 +131,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); + } } } } @@ -134,7 +145,9 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { title: 'Replace file', msg: `File '${fileName}' already exists in the destination folder. Do you want to replace it?`, ok: 'Yes', - cancel: 'No' + cancel: 'No', + hasOk: true, + hasCancel: true }); return dialog.open(); } diff --git a/packages/filesystem/src/browser/file-tree/file-tree-widget.ts b/packages/filesystem/src/browser/file-tree/file-tree-widget.ts index 4a2902ec0ac74..e7fbd5c9800fa 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-widget.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree-widget.ts @@ -5,16 +5,13 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { injectable, inject } from "inversify"; -import { h } from "@phosphor/virtualdom"; -import { Message } from "@phosphor/messaging"; -import { - ContextMenuRenderer, - TreeWidget, NodeProps, TreeProps, TreeNode -} from "@theia/core/lib/browser"; -import { ElementAttrs } from "@phosphor/virtualdom"; -import { DirNode, FileStatNode } from "./file-tree"; -import { FileTreeModel } from "./file-tree-model"; +import { injectable, inject } from 'inversify'; +import { h } from '@phosphor/virtualdom'; +import { Message } from '@phosphor/messaging'; +import { ContextMenuRenderer, NodeProps, TreeDecoration, TreeNode, TreeProps, TreeWidget } from '@theia/core/lib/browser'; +import { ElementAttrs } from '@phosphor/virtualdom'; +import { DirNode, FileStatNode } from './file-tree'; +import { FileTreeModel } from './file-tree-model'; import { DisposableCollection, Disposable } from '@theia/core/lib/common'; export const FILE_TREE_CLASS = 'theia-FileTree'; @@ -139,4 +136,15 @@ export class FileTreeWidget extends TreeWidget { return this.model.getNode(id); } + protected getDecorations(node: TreeNode): TreeDecoration.Data[] { + const treeNode = { + id: node.id, + name: node.name, + parent: node.parent + }; + if (FileStatNode.is(node)) { + treeNode.id = FileStatNode.getUri(node); + } + return super.getDecorations(treeNode as TreeNode); + } } diff --git a/packages/filesystem/src/browser/file-tree/file-tree.ts b/packages/filesystem/src/browser/file-tree/file-tree.ts index b0ff7aab8eb1f..c363360d096f1 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree.ts @@ -5,11 +5,11 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { injectable, inject } from "inversify"; +import { injectable, inject } from 'inversify'; 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 { 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'; @injectable() @@ -17,27 +17,39 @@ export class FileTree extends TreeImpl { @inject(FileSystem) protected readonly fileSystem: FileSystem; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + hasVirtualRoot: boolean = false; async resolveChildren(parent: CompositeTreeNode): Promise { if (FileStatNode.is(parent)) { - const fileStat = await this.resolveFileStat(parent); + let fileStat; + if (this.isVirtualRoot(parent)) { + fileStat = await this.resolveVirtualRootFileStat(parent); + if (fileStat) { + parent.fileStat = fileStat; + } + } else { + fileStat = await this.resolveFileStat(parent); + } + if (fileStat) { return this.toNodes(fileStat, parent); } - return []; } return super.resolveChildren(parent); } - protected resolveFileStat(node: FileStatNode): Promise { - return this.fileSystem.getFileStat(node.fileStat.uri).then(fileStat => { - if (fileStat) { - node.fileStat = fileStat; - return fileStat; - } - return undefined; - }); + protected resolveFileStat(node: CompositeTreeNode): Promise { + if (FileStatNode.is(node)) { + return this.fileSystem.getFileStat(node.fileStat.uri).then(fileStat => { + if (fileStat) { + node.fileStat = fileStat; + return fileStat; + } + return undefined; + }); + } + return Promise.resolve(undefined); } protected async toNodes(fileStat: FileStat, parent: CompositeTreeNode): Promise { @@ -54,7 +66,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.generateFileTreeNodeId(name, fileStat.uri, parent); const node = this.getNode(id); if (fileStat.isDirectory) { if (DirNode.is(node)) { @@ -78,6 +90,38 @@ export class FileTree extends TreeImpl { }; } + protected isVirtualRoot(node: CompositeTreeNode): boolean { + return node.parent === undefined && this.hasVirtualRoot && node.name === 'WorkspaceRoot'; + } + + protected generateFileTreeNodeId(nodeName: string, uri: string, parent: CompositeTreeNode | undefined): string { + if (parent && parent.name) { + return this.generateFileTreeNodeId(`${parent.name}/${nodeName}`, uri, parent.parent); + } + return `${nodeName}///${uri}`; + } + + protected async resolveVirtualRootFileStat(virtualRoot: FileStatNode): Promise { + const rootFileStat = await this.fileSystem.getFileStat(virtualRoot.uri.toString()); + const children = virtualRoot.fileStat.children; + let childrenFileStat: (FileStat | undefined)[] = []; + if (children) { + childrenFileStat = await Promise.all(children.map(child => + this.fileSystem.getFileStat(child.uri.toString()) + )); + } + if (rootFileStat) { + rootFileStat.isDirectory = true; + rootFileStat.children = []; + for (const stat of childrenFileStat) { + if (stat) { + rootFileStat.children.push(stat); + } + } + } + return rootFileStat; + + } } export interface FileStatNode extends SelectableTreeNode, UriSelection { @@ -87,6 +131,13 @@ export namespace FileStatNode { export function is(node: TreeNode | undefined): node is FileStatNode { return !!node && 'fileStat' in node; } + + export function getUri(node: TreeNode | undefined): string { + if (!node) { + return ''; + } + return node.id.slice(node.id.indexOf('file:///')); + } } export type FileNode = FileStatNode; @@ -127,6 +178,23 @@ export namespace DirNode { }; } + export function createWorkspaceRoot(workspace: FileStat, children: TreeNode[]): DirNode { + const id = workspace.uri; + const uri = new URI(id); + return { + id, + uri, + fileStat: workspace, + name: 'WorkspaceRoot', + parent: undefined, + children, + expanded: true, + selected: false, + focus: false, + visible: false + }; + } + export function getContainingDir(node: TreeNode | undefined): DirNode | undefined { let containing = node; while (!!containing && !is(containing)) { diff --git a/packages/filesystem/src/browser/filesystem-listener.ts b/packages/filesystem/src/browser/filesystem-listener.ts index 0b4959d9e6b16..175ad713bd2f1 100644 --- a/packages/filesystem/src/browser/filesystem-listener.ts +++ b/packages/filesystem/src/browser/filesystem-listener.ts @@ -23,7 +23,9 @@ export class FileSystemListener implements FileSystemClient { title: `The file '${file.uri}' has been changed on the file system.`, msg: 'Do you want to overwrite the changes made on the file system?', ok: 'Yes', - cancel: 'No' + cancel: 'No', + hasOk: true, + hasCancel: true }); return dialog.open(); } diff --git a/packages/git/src/browser/git-decorator.ts b/packages/git/src/browser/git-decorator.ts index d93081c8c3528..5987e09b4806f 100644 --- a/packages/git/src/browser/git-decorator.ts +++ b/packages/git/src/browser/git-decorator.ts @@ -20,6 +20,7 @@ import { WorkingDirectoryStatus } from '../common/git-model'; import { GitRepositoryProvider } from './git-repository-provider'; import { GitFileChange, GitFileStatus } from '../common/git-model'; import { GitPreferences, GitConfiguration } from './git-preferences'; +import { FileStatNode } from '@theia/filesystem/lib/browser'; @injectable() export class GitDecorator implements TreeDecorator { @@ -77,7 +78,8 @@ export class GitDecorator implements TreeDecorator { return result; } const markers = this.appendContainerChanges(tree, status.changes); - for (const { id } of new DepthFirstTreeIterator(tree.root)) { + for (const treeNode of new DepthFirstTreeIterator(tree.root)) { + const id = FileStatNode.is(treeNode) ? FileStatNode.getUri(treeNode) : treeNode.id; const marker = markers.get(id); if (marker) { result.set(id, marker); diff --git a/packages/git/src/browser/git-repository-provider.ts b/packages/git/src/browser/git-repository-provider.ts index 0152f1ab411d1..fa1e5877a9e8d 100644 --- a/packages/git/src/browser/git-repository-provider.ts +++ b/packages/git/src/browser/git-repository-provider.ts @@ -6,9 +6,10 @@ */ import { Git, Repository } from '../common'; -import { injectable, inject } from "inversify"; +import { injectable, inject } from 'inversify'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { Event, Emitter } from '@theia/core'; +import { FileStat } from '@theia/filesystem/lib/common'; export interface GitRefreshOptions { readonly maxCount: number @@ -21,6 +22,9 @@ export class GitRepositoryProvider { protected _allRepositories: Repository[] = []; protected readonly onDidChangeRepositoryEmitter = new Emitter(); + protected root: FileStat | undefined; + protected roots: FileStat[]; + constructor( @inject(Git) protected readonly git: Git, @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService @@ -29,6 +33,22 @@ export class GitRepositoryProvider { } protected async initialize(): Promise { + const workspaceRootChangeData = await this.workspaceService.workspaceRootChange; + this.root = workspaceRootChangeData.root; + this.roots = workspaceRootChangeData.roots; + this.workspaceService.onWorkspaceRootChanged(async event => { + if (event) { + this.root = event.root; + this.roots = event.roots; + if (this.selectedRepository) { + const selected = this.roots.find(r => r.uri === this.selectedRepository!.localUri); + if (!selected) { + this.selectedRepository = undefined; + } + } + } + await this.refresh(); + }); await this.refresh({ maxCount: 1 }); await this.refresh(); } @@ -65,14 +85,24 @@ export class GitRepositoryProvider { } async refresh(options?: GitRefreshOptions): Promise { - const root = await this.workspaceService.root; - if (!root) { + if (!this.root) { return; } - const repositories = await this.git.repositories(root.uri, { - ...options + const repoUris = new Set(); + this._allRepositories = await Promise.all( + this.roots.map(r => this.git.repositories(r.uri, { ...options })) + ).then(reposOfRoots => { + const repos: Repository[] = []; + reposOfRoots.forEach(reposPerRoot => { + reposPerRoot.forEach(repoOfOneRoot => { + if (!repoUris.has(repoOfOneRoot.localUri)) { + repos.push(repoOfOneRoot); + repoUris.add(repoOfOneRoot.localUri); + } + }); + }); + return repos; }); - this._allRepositories = repositories; const selectedRepository = this._selectedRepository; if (!selectedRepository || !this.exists(selectedRepository)) { this.selectedRepository = this._allRepositories[0]; diff --git a/packages/git/src/browser/git-sync-service.ts b/packages/git/src/browser/git-sync-service.ts index b934a6c636ec9..05d64f6568499 100644 --- a/packages/git/src/browser/git-sync-service.ts +++ b/packages/git/src/browser/git-sync-service.ts @@ -188,7 +188,12 @@ export class GitSyncService { } protected confirm(title: string, msg: string): Promise { - return new ConfirmDialog({ title, msg, }).open(); + return new ConfirmDialog({ + title, + msg, + hasOk: true, + hasCancel: true + }).open(); } // tslint:disable-next-line:no-any diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 8aa930aca3927..2ecba9dfab61c 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -102,17 +102,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.ts b/packages/git/src/browser/history/git-history-widget.ts index eab9e8a48d38e..903679920956d 100644 --- a/packages/git/src/browser/history/git-history-widget.ts +++ b/packages/git/src/browser/history/git-history-widget.ts @@ -57,6 +57,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/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts index 4ba0d370f5676..b2e758d6dab50 100644 --- a/packages/monaco/src/browser/monaco-workspace.ts +++ b/packages/monaco/src/browser/monaco-workspace.ts @@ -88,10 +88,13 @@ export class MonacoWorkspace implements lang.Workspace { @postConstruct() protected init(): void { - this.workspaceService.root.then(rootStat => { - if (rootStat) { - this._rootUri = rootStat.uri; - this.resolveReady(); + this.workspaceService.onWorkspaceRootChanged(event => { + if (event) { + const rootStat = event.root; + if (rootStat) { + this._rootUri = rootStat.uri; + this.resolveReady(); + } } }); for (const model of this.textModelService.models) { diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index d85598580987e..250cb75159b8b 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -5,18 +5,22 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { injectable, inject, postConstruct } from "inversify"; +import { injectable, inject, postConstruct } from 'inversify'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-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 { + 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 { SHELL_TABBAR_CONTEXT_MENU } from '@theia/core/lib/browser'; +import { WorkspaceCommands, WorkspaceService, WorkspacePreferences } from '@theia/workspace/lib/browser'; import { FILE_NAVIGATOR_ID, FileNavigatorWidget } from './navigator-widget'; -import { FileNavigatorPreferences } from "./navigator-preferences"; +import { FileNavigatorPreferences } from './navigator-preferences'; import { NavigatorKeybindingContexts } from './navigator-keybinding-context'; -import { FileNavigatorFilter } from "./navigator-filter"; +import { FileNavigatorFilter } from './navigator-filter'; +import { FileStatNode } from '@theia/filesystem/lib/browser'; +import URI from '@theia/core/lib/common/uri'; export namespace FileNavigatorCommands { export const REVEAL_IN_NAVIGATOR = { @@ -38,6 +42,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() @@ -46,7 +51,9 @@ export class FileNavigatorContribution extends AbstractViewContribution true, isVisible: () => true }); + this.workspacePreferences.ready.then(() => { + registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, { + execute: () => this.removeFolderFromWorkspace(), + isEnabled: () => this.workspaceService.opened && this.workspacePreferences['workspace.supportMultiRootWorkspace'], + isVisible: () => this.isRootFolderSelected() + }); + }); } registerMenus(registry: MenuModelRegistry): void { @@ -139,6 +153,18 @@ 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 { @@ -198,4 +224,36 @@ export class FileNavigatorContribution extends AbstractViewContribution this.isRootDirectory(node)) + .map(rootFolder => { + const rootFolderNode = rootFolder as FileStatNode; + return new URI(rootFolderNode.uri.toString()); + }) + ); + } + + protected isRootFolderSelected(): boolean { + return this.getSelectedNodesInFileNavigator().some(node => this.isRootDirectory(node)); + } + + private getSelectedNodesInFileNavigator(): Readonly[] { + return this.widgetManager.getWidgets(FILE_NAVIGATOR_ID) + .map(widget => widget as FileNavigatorWidget) + .map(fileNavigatorWidget => fileNavigatorWidget.model.selectedNodes as Array>) + .reduce((prev, cur) => [...prev, ...cur]); + } + + private isRootDirectory(node: Readonly): boolean { + if (this.workspaceService.numberOfRoots > 1) { + return node && node.parent !== undefined + && node.parent.parent === undefined + && !node.parent.visible + && node.parent.name === 'WorkspaceRoot'; + } + return node && node.parent === undefined; + } } diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts index 80a77af8a9146..bab928b45a27c 100644 --- a/packages/navigator/src/browser/navigator-model.ts +++ b/packages/navigator/src/browser/navigator-model.ts @@ -7,9 +7,9 @@ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; +import { FileNode, FileTreeModel, FileStatNode } 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 { OpenerService, open, TreeNode, ExpandableTreeNode, CompositeTreeNode } from '@theia/core/lib/browser'; import { FileNavigatorTree } from './navigator-tree'; import { FileNavigatorSearch } from './navigator-search'; @@ -19,6 +19,16 @@ export class FileNavigatorModel extends FileTreeModel { @inject(OpenerService) protected readonly openerService: OpenerService; @inject(FileNavigatorTree) protected readonly tree: FileNavigatorTree; @inject(FileNavigatorSearch) protected readonly navigatorSearch: FileNavigatorSearch; + _hasMultipleRoots: boolean = false; + + get hasMultipleRoots() { + return this._hasMultipleRoots; + } + + set hasMultipleRoots(multipleRoots: boolean) { + this._hasMultipleRoots = multipleRoots; + this.tree.hasVirtualRoot = multipleRoots; + } protected doOpenNode(node: TreeNode): void { if (FileNode.is(node)) { @@ -40,11 +50,11 @@ export class FileNavigatorModel extends FileTreeModel { } const navigatorNodeId = targetFileUri.toString(); - let node = this.getNode(navigatorNodeId); + let node = 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 (this.isProjectRoot(node)) { if (ExpandableTreeNode.is(node)) { if (!node.expanded) { await this.expandNode(node); @@ -65,7 +75,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 = this.getNodeClosestToRootByUri(navigatorNodeId); } if (ExpandableTreeNode.is(node) && !node.expanded) { await this.expandNode(node); @@ -75,6 +85,30 @@ export class FileNavigatorModel extends FileTreeModel { return undefined; } + protected getNodeClosestToRootByUri(uri: string): TreeNode | undefined { + const nodes = this.getNodes((node: FileStatNode) => { + if (node.uri) { + return node.uri.toString() === uri; + } + return false; + }); + 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 isProjectRoot(node: TreeNode | undefined): boolean { + if (!this.hasMultipleRoots && this.root === node) { + return true; + } + if (node && this.hasMultipleRoots && CompositeTreeNode.is(this.root)) { + return this.root.children.some(child => child.id === node.id); + } + return false; + } + protected createBackwardIterator(node: TreeNode | undefined): TreeIterator | undefined { if (node === undefined) { return undefined; diff --git a/packages/navigator/src/browser/navigator-tree.ts b/packages/navigator/src/browser/navigator-tree.ts index 9935f3b9bf7c0..500a49f6c3992 100644 --- a/packages/navigator/src/browser/navigator-tree.ts +++ b/packages/navigator/src/browser/navigator-tree.ts @@ -6,8 +6,8 @@ */ 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 { TreeNode } from '@theia/core/lib/browser/tree/tree'; import { FileNavigatorFilter } from './navigator-filter'; @injectable() @@ -20,7 +20,7 @@ export class FileNavigatorTree extends FileTree { this.toDispose.push(this.filter.onFilterChanged(() => this.refresh())); } - async resolveChildren(parent: CompositeTreeNode): Promise { + async resolveChildren(parent: DirNode): Promise { return this.filter.filter(super.resolveChildren(parent)); } diff --git a/packages/navigator/src/browser/navigator-widget.ts b/packages/navigator/src/browser/navigator-widget.ts index 6ae18c0fe418b..539c46b28004d 100644 --- a/packages/navigator/src/browser/navigator-widget.ts +++ b/packages/navigator/src/browser/navigator-widget.ts @@ -11,14 +11,14 @@ 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 { ContextMenuRenderer, TreeProps, TreeModel, TreeNode, LabelProvider, Widget, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser'; import { FileTreeWidget, DirNode, 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'; import { FileNavigatorSearch } from './navigator-search'; import { SearchBox, SearchBoxProps, SearchBoxFactory } from './search-box'; -import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common/filesystem'; export const FILE_NAVIGATOR_ID = 'files'; export const LABEL = 'Files'; @@ -79,21 +79,56 @@ export class FileNavigatorWidget extends FileTreeWidget { this.model.expandNode(child); } } + }), + this.workspaceService.onWorkspaceRootChanged(async event => { + if (event) { + await this.refreshWidget(event.root, event.roots, event.workspaceConfigFile); + } }) ]); } - protected initialize(): void { - this.workspaceService.root.then(async resolvedRoot => { - if (resolvedRoot) { - const uri = new URI(resolvedRoot.uri); + protected async initialize(): Promise { + const workspaceChangeData = await this.workspaceService.workspaceRootChange; + await this.refreshWidget(workspaceChangeData.root, workspaceChangeData.roots, workspaceChangeData.workspaceConfigFile); + } + + protected async refreshWidget(root: FileStat | undefined, roots: FileStat[], workspaceConfig: FileStat | undefined) { + if (root) { + if (roots.length === 1) { // only one folder is opened + this.model.hasMultipleRoots = false; + const uri = new URI(root.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(); + const icon = await this.labelProvider.getIcon(root); + this.model.root = DirNode.createRoot(root, label, icon); + } else if (roots.length > 1) { // more than one folder + if (workspaceConfig) { + this.model.hasMultipleRoots = true; + const workspaceRootNode = { + id: workspaceConfig.uri, + name: 'WorkspaceRoot', + parent: undefined + } as CompositeTreeNode; + + const children: TreeNode[] = []; + for (const r of roots) { + const icon = await this.labelProvider.getIcon(r); + children.push({ + id: r.uri, + name: this.labelProvider.getName(new URI(r.uri)), + icon, + visible: true, + parent: workspaceRootNode + }); + } + workspaceConfig.children = roots; + this.model.root = DirNode.createWorkspaceRoot(workspaceConfig, children); + } } - }); + } else { + this.model.root = undefined; + this.update(); + } } protected enableDndOnMainPanel(): void { @@ -166,7 +201,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(): h.Child { diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index ba9e76aed3056..90a778e85269c 100644 --- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts @@ -6,10 +6,12 @@ */ import { inject, injectable, postConstruct } from 'inversify'; -import * as jsoncparser from "jsonc-parser"; +import * as jsoncparser from 'jsonc-parser'; import URI from '@theia/core/lib/common/uri'; -import { ILogger, Resource, ResourceProvider, MaybePromise } from "@theia/core/lib/common"; +import { ILogger, Resource, ResourceProvider, MaybePromise } from '@theia/core/lib/common'; import { PreferenceProvider } from '@theia/core/lib/browser/preferences'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { FileStat } from '@theia/filesystem/lib/common/filesystem'; @injectable() export abstract class AbstractResourcePreferenceProvider extends PreferenceProvider { @@ -20,12 +22,13 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi @inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + protected resource: Promise; @postConstruct() protected async init(): Promise { - const uri = await this.getUri(); - + const uri = await this.getUri(await this.workspaceService.root); // In case if no workspace is opened there are no workspace settings. // There is nothing to contribute to preferences and we just skip it. if (!uri) { @@ -46,9 +49,18 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi if (resource.onDidChangeContents) { this.toDispose.push(resource.onDidChangeContents(content => this.readPreferences())); } + this.toDispose.push(this.workspaceService.onWorkspaceRootChanged(async event => { + if (event) { + const newRoot = await this.getUri(event.root); + if (newRoot) { + this.resource = this.resourceProvider(newRoot); + this.readPreferences(); + } + } + })); } - abstract getUri(): MaybePromise; + abstract getUri(root?: FileStat): MaybePromise; getPreferences(): { [key: string]: any } { return this.preferences; @@ -82,5 +94,4 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi return ''; } } - } diff --git a/packages/preferences/src/browser/preference-frontend-contribution.ts b/packages/preferences/src/browser/preference-frontend-contribution.ts index c94134eed1153..26eadeedeacbe 100644 --- a/packages/preferences/src/browser/preference-frontend-contribution.ts +++ b/packages/preferences/src/browser/preference-frontend-contribution.ts @@ -12,6 +12,7 @@ import { open, OpenerService, CommonMenus, PreferenceScope, PreferenceProvider } import { WorkspacePreferenceProvider } from './workspace-preference-provider'; import { FileSystem } from "@theia/filesystem/lib/common"; import { UserStorageService } from "@theia/userstorage/lib/browser"; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; export namespace PreferenceCommands { export const OPEN_USER_PREFERENCES: Command = { @@ -32,6 +33,7 @@ export class PreferenceFrontendContribution implements CommandContribution, Menu @inject(PreferenceProvider) @named(PreferenceScope.Workspace) protected readonly workspacePreferenceProvider: WorkspacePreferenceProvider; @inject(OpenerService) protected readonly openerService: OpenerService; @inject(FileSystem) protected readonly filesystem: FileSystem; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; registerCommands(commands: CommandRegistry): void { commands.registerCommand(PreferenceCommands.OPEN_USER_PREFERENCES, { @@ -65,7 +67,8 @@ export class PreferenceFrontendContribution implements CommandContribution, Menu } protected async openWorkspacePreferences(): Promise { - const wsUri = await this.workspacePreferenceProvider.getUri(); + const root = (await this.workspaceService.workspaceRootChange).root; + const wsUri = await this.workspacePreferenceProvider.getUri(root); if (!wsUri) { return; } diff --git a/packages/preferences/src/browser/workspace-preference-provider.ts b/packages/preferences/src/browser/workspace-preference-provider.ts index 4a0cb072565db..ac701d937c302 100644 --- a/packages/preferences/src/browser/workspace-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-preference-provider.ts @@ -5,19 +5,15 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { inject, injectable } from 'inversify'; +import { injectable } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; +import { FileStat } from '@theia/filesystem/lib/common/filesystem'; @injectable() export class WorkspacePreferenceProvider extends AbstractResourcePreferenceProvider { - @inject(WorkspaceService) - protected readonly workspaceService: WorkspaceService; - - async getUri(): Promise { - const root = await this.workspaceService.root; + getUri(root?: FileStat) { if (root) { const rootUri = new URI(root.uri); return rootUri.resolve('.theia').resolve('settings.json'); diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts index 327c4f5c557dc..841db64b9ebe5 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts +++ b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts @@ -104,7 +104,8 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { super.init(); this.addClass("resultContainer"); - this.workspaceService.root.then(rootFileStat => { + this.workspaceService.workspaceRootChange.then(data => { + const rootFileStat = data.root; 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 0ce9f06a2b494..34e90ad5744df 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 @@ -97,10 +97,10 @@ 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.workspaceRootChange).root; if (!root) { - throw new Error("Search failed: no workspace root."); + throw new Error('Search failed: no workspace root.'); } const rootUri = new URI(root.uri); diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index bed660e22a13c..dbaf434607297 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -42,10 +42,13 @@ export class TaskService implements TaskConfigurationClient { @inject(VariableResolverService) protected readonly variableResolverService: VariableResolverService ) { // wait for the workspace root to be set - this.workspaceService.root.then(async root => { - if (root) { - this.configurationFileFound = await this.taskConfigurations.watchConfigurationFile(root.uri); - this.workspaceRootUri = root.uri; + this.workspaceService.onWorkspaceRootChanged(async event => { + if (event) { + const root = event.root; + if (root) { + this.configurationFileFound = await this.taskConfigurations.watchConfigurationFile(root.uri); + this.workspaceRootUri = root.uri; + } } }); diff --git a/packages/terminal/src/browser/terminal-widget.ts b/packages/terminal/src/browser/terminal-widget.ts index 1b80ad1afa626..94bf66fe7aec2 100644 --- a/packages/terminal/src/browser/terminal-widget.ts +++ b/packages/terminal/src/browser/terminal-widget.ts @@ -7,19 +7,19 @@ import * as Xterm from 'xterm'; import { proposeGeometry } from 'xterm/lib/addons/fit/fit'; -import { inject, injectable, named, postConstruct } from "inversify"; +import { inject, injectable, named, postConstruct } from 'inversify'; import { Disposable, DisposableCollection, ILogger } from '@theia/core/lib/common'; import { Widget, BaseWidget, Message, WebSocketConnectionProvider, StatefulWidget, isFirefox, MessageLoop } from '@theia/core/lib/browser'; -import { WorkspaceService } from "@theia/workspace/lib/browser"; +import { WorkspaceService } from '@theia/workspace/lib/browser'; import { ShellTerminalServerProxy } from '../common/shell-terminal-protocol'; import { terminalsPath } from '../common/terminal-protocol'; import { IBaseTerminalServer } from '../common/base-terminal-protocol'; import { TerminalWatcher } from '../common/terminal-watcher'; -import { ThemeService } from "@theia/core/lib/browser/theming"; +import { ThemeService } from '@theia/core/lib/browser/theming'; export const TERMINAL_WIDGET_FACTORY_ID = 'terminal'; -export const TerminalWidgetOptions = Symbol("TerminalWidgetOptions"); +export const TerminalWidgetOptions = Symbol('TerminalWidgetOptions'); export interface TerminalWidgetOptions { id: string, caption: string, @@ -218,7 +218,7 @@ export class TerminalWidget extends BaseWidget implements StatefulWidget { return this.createTerminal(); } protected async createTerminal(): Promise { - const root = await this.workspaceService.root; + const root = (await this.workspaceService.workspaceRootChange).root; const rootURI = root && root.uri; const { cols, rows } = this.term; const terminalId = await this.shellTerminalServer.create({ rootURI, cols, rows }); diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 84e1bbe89c9aa..3e45d98b65578 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -55,6 +55,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() @@ -101,7 +108,7 @@ export class WorkspaceCommandContribution implements CommandContribution { const parentUri = new URI(parent.uri); const vacantChildUri = this.findVacantChildUri(parentUri, parent, 'Untitled', '.txt'); const dialog = new SingleTextInputDialog({ - title: `New File`, + title: 'New File', initialValue: vacantChildUri.path.base, validate: name => this.validateFileName(name, parent) }); @@ -175,6 +182,8 @@ export class WorkspaceCommandContribution implements CommandContribution { const dialog = new ConfirmDialog({ title: `Delete File${uris.length === 1 ? '' : 's'}`, msg, + hasOk: true, + hasCancel: true }); if (await dialog.open()) { // Make sure we delete the longest paths first, they might be nested. Longer paths come first. @@ -288,7 +297,8 @@ export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler< protected readonly handler: UriCommandHandler ) { super(selectionService, handler); - workspaceService.root.then(root => { + workspaceService.workspaceRootChange.then(data => { + const root = data.root; 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 fe9fe6e79c707..6fdb55983895b 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -5,14 +5,15 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { injectable, inject } from "inversify"; -import URI from "@theia/core/lib/common/uri"; -import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from "@theia/core/lib/common"; +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 { FileSystem } from '@theia/filesystem/lib/common'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { WorkspaceService } from './workspace-service'; -import { WorkspaceCommands } from "./workspace-commands"; +import { WorkspaceCommands } from './workspace-commands'; +import { WorkspacePreferences } from './workspace-preferences'; @injectable() export class WorkspaceFrontendContribution implements CommandContribution, MenuContribution { @@ -23,18 +24,31 @@ export class WorkspaceFrontendContribution implements CommandContribution, MenuC @inject(OpenerService) protected readonly openerService: OpenerService, @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, @inject(StorageService) protected readonly workspaceStorage: StorageService, - @inject(LabelProvider) protected readonly labelProvider: LabelProvider + @inject(LabelProvider) protected readonly labelProvider: LabelProvider, + @inject(WorkspacePreferences) protected readonly preferences: WorkspacePreferences ) { } registerCommands(commands: CommandRegistry): void { commands.registerCommand(WorkspaceCommands.OPEN, { isEnabled: () => true, - execute: () => this.showFileDialog() + execute: () => this.workspaceService.workspaceRootChange.then(async data => { + const node = await this.showFileDialog(WorkspaceCommands.OPEN.label!, data.root); + this.openFile(node); + }) }); commands.registerCommand(WorkspaceCommands.CLOSE, { isEnabled: () => this.workspaceService.opened, execute: () => this.closeWorkspace() }); + this.preferences.ready.then(() => { + commands.registerCommand(WorkspaceCommands.ADD_FOLDER, { + isEnabled: () => this.workspaceService.opened && this.preferences['workspace.supportMultiRootWorkspace'], + execute: async () => { + const node = await this.showFileDialog(WorkspaceCommands.ADD_FOLDER.label!); + this.addFolderToWorkspace(node); + } + }); + }); } registerMenus(menus: MenuModelRegistry): void { @@ -46,23 +60,21 @@ 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 node = await dialog.open(); - this.openFile(node); - } + protected async showFileDialog(dialogTitle: string, folder?: FileStat): Promise { + const folderToOpen = folder || await this.fileSystem.getCurrentUserHome(); + if (folderToOpen) { + const rootUri = new URI(folderToOpen.uri).parent; + const rootStat = await this.fileSystem.getFileStat(rootUri.toString()); + const name = this.labelProvider.getName(rootUri); + const label = await this.labelProvider.getIcon(folderToOpen); + if (rootStat) { + const rootNode = DirNode.createRoot(rootStat, name, label); + const dialog = this.fileDialogFactory({ title: dialogTitle }); + dialog.model.navigateTo(rootNode); + const node = await dialog.open(); + return node; } - }); + } } protected openFile(node: Readonly | undefined): void { @@ -79,11 +91,23 @@ export class WorkspaceFrontendContribution implements CommandContribution, MenuC protected async closeWorkspace(): Promise { const dialog = new ConfirmDialog({ title: 'Close Workspace', - msg: 'Do you really want to close the workspace?' + msg: 'Do you really want to close the workspace?', + hasOk: true, + hasCancel: true }); if (await dialog.open()) { this.workspaceService.close(); } } + protected addFolderToWorkspace(node: Readonly | undefined): void { + if (!node) { + return; + } + if (node.fileStat.isDirectory) { + this.workspaceService.addRoot(node.uri); + } else { + open(this.openerService, node.uri); + } + } } diff --git a/packages/workspace/src/browser/workspace-preferences.ts b/packages/workspace/src/browser/workspace-preferences.ts index 87c3e934ad730..dfce94af05a20 100644 --- a/packages/workspace/src/browser/workspace-preferences.ts +++ b/packages/workspace/src/browser/workspace-preferences.ts @@ -5,7 +5,7 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { interfaces } from "inversify"; +import { interfaces } from 'inversify'; import { createPreferenceProxy, PreferenceProxy, @@ -15,20 +15,28 @@ import { } from '@theia/core/lib/browser/preferences'; export const workspacePreferenceSchema: PreferenceSchema = { - "type": "object", - "properties": { - "workspace.preserveWindow": { - "description": "Enable opening workspaces in current window", - "additionalProperties": { - "type": "boolean" + type: 'object', + properties: { + 'workspace.preserveWindow': { + 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 7337d2a16e82f..09e90bdd52f47 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -11,11 +11,18 @@ import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; 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 { ConfirmDialog, 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'; +export interface WorkspaceRootChange { + root: FileStat | undefined; + roots: FileStat[]; + workspaceConfigFile: FileStat | undefined; +} + /** * The workspace service. */ @@ -23,10 +30,14 @@ import { WorkspacePreferences } from './workspace-preferences'; export class WorkspaceService implements FrontendApplicationContribution { private _root: FileStat | undefined; + private _roots: FileStat[]; + private _workspaceConfigFile: FileStat | undefined; + private _workspaceRootChange: WorkspaceRootChange; - private readonly deferredRoot = new Deferred(); + private deferredRoot = new Deferred(); + private deferredWorkspaceRootChange = new Deferred(); - readonly root = this.deferredRoot.promise; + private rootWatchers: { [uri: string]: Disposable } = {}; @inject(FileSystem) protected readonly fileSystem: FileSystem; @@ -48,28 +59,81 @@ export class WorkspaceService implements FrontendApplicationContribution { @postConstruct() protected async init(): Promise { + this._workspaceRootChange = { + root: undefined, + roots: [], + workspaceConfigFile: undefined + }; + await this.setupRoots(); + this.updateTitle(); + this.deferredWorkspaceRootChange.resolve(this._workspaceRootChange); + await this.openWindow({ preserveWindow: true }); + } + + get root() { + return this.deferredRoot.promise; + } + get workspaceRootChange() { + return this.deferredWorkspaceRootChange.promise; + } + get numberOfRoots() { + return this._roots.length; + } + + protected readonly onWorkspaceRootChangeEmitter = new Emitter(); + get onWorkspaceRootChanged(): Event { + return this.onWorkspaceRootChangeEmitter.event; + } + + protected async setupRoots() { const rootUri = await this.server.getRoot(); this._root = await this.toValidRoot(rootUri); + this._workspaceRootChange.root = this._root; + this.deferredRoot.resolve(this._root); + + this._roots = []; + await this.preferences.ready; if (this._root) { - const uri = new URI(this._root.uri); - this.updateTitle(uri); - this.watcher.watchFileChanges(uri); + if (this.preferences['workspace.supportMultiRootWorkspace']) { + const roots = await this.server.getRoots(this._root.uri); + for (const r of roots) { + this.startWatch(await this.toValidRoot(r)); + } + } else { + this.startWatch(this._root); + } } - this.deferredRoot.resolve(this._root); + this._workspaceRootChange.roots = this._roots; + + if (this._root && this.preferences['workspace.supportMultiRootWorkspace']) { + const configPath = await this.server.getWorkspaceConfigFilePath(this._root.uri); + if (configPath) { + this._workspaceConfigFile = await this.fileSystem.getFileStat(configPath); + } else { + this._workspaceConfigFile = undefined; + } + } + this._workspaceRootChange.workspaceConfigFile = this._workspaceConfigFile; } - protected updateTitle(uri: URI): void { - document.title = uri.displayName; + protected updateTitle() { + if (this._root) { + const uri = new URI(this._root.uri); + document.title = uri.displayName; + } else { + document.title = window.location.href; + } } /** * on unload, we set our workspace root as the last recently used on the backend. * @param app */ - onStop(app: FrontendApplication): void { + async onStop(app: FrontendApplication) { if (this._root) { - this.server.setRoot(this._root.uri); + await this.server.setRoot(this._root.uri); } + this.stopWatch(); } /** @@ -93,22 +157,136 @@ 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. + const rootToOpen = (await this.workspaceRootChange).root; const { preserveWindow } = { - preserveWindow: this.preferences['workspace.preserveWindow'] || !(await this.root), + preserveWindow: this.preferences['workspace.preserveWindow'] || !(rootToOpen), ...options }; await this.server.setRoot(rootUri); if (preserveWindow) { - this._root = valid; + await this.setupRoots(); } - this.openWindow(uri, { preserveWindow }); + await this.openWindow({ preserveWindow }); + this.updateTitle(); return; } throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${rootUri}.`); } /** - * Clears current workspace root and reloads window. + * Adds a root folder to the workspace + * @param uri URI of the root folder being added + */ + async addRoot(uri: URI): Promise { + const workspaceRootChange = await this.workspaceRootChange; + const workspaceFolder = workspaceRootChange.root; + if (!this.opened || !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) { + if (!this._workspaceRootChange.roots.find(r => r.uri === valid.uri)) { + await this.server.addRoot(rootToAdd, workspaceFolder.uri); + this._workspaceRootChange.roots.push(valid); + const configPath = await this.server.getWorkspaceConfigFilePath(this._root!.uri); + if (configPath) { + this._workspaceConfigFile = await this.fileSystem.getFileStat(configPath); + } else { + this._workspaceConfigFile = undefined; + } + this._workspaceRootChange.workspaceConfigFile = this._workspaceConfigFile; + this.startWatch(valid); + this.onWorkspaceRootChangeEmitter.fire(this._workspaceRootChange); + } + return Promise.resolve(); + } + throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${rootToAdd}.`); + } + + /** + * Removes folder(s) from workspace. + */ + async removeFolders(uris: URI[]): Promise { + if (!this.opened) { + throw new Error('Folder cannot be removed as there is no active folder in the current workspace.'); + } + const validUris: string[] = []; + for (const uri of uris.map(u => u.toString())) { + const valid = await this.toValidRoot(uri); + if (valid) { + validUris.push(uri); + } + } + if (validUris.length > 0) { + if (this._root) { + const rootIndex = validUris.findIndex(uri => uri === this._root!.uri); + let rootName = ''; + if (rootIndex >= 0) { // first root cannot be removed from the workspace + validUris.splice(rootIndex, 1); + rootName = uris[rootIndex].displayName; + uris.splice(rootIndex, 1); + } + + const messageContainer = document.createElement('div'); + const hasRootFoldersToRemove = validUris.length > 0; + const isFirstRootFolderSelected = rootIndex >= 0; + if (hasRootFoldersToRemove) { + messageContainer.textContent = 'Do you really want to remove the following roots and their subfolders from the workspace?'; + const list = document.createElement('ul'); + list.style.listStyleType = 'none'; + uris.forEach(u => { + const listItem = document.createElement('li'); + listItem.textContent = u.displayName; + list.appendChild(listItem); + }); + messageContainer.appendChild(list); + } + if (isFirstRootFolderSelected) { + const firstRootMsg = document.createElement('div'); + const firstRootNameDiv = document.createElement('strong'); + firstRootNameDiv.textContent = rootName; + firstRootMsg.appendChild(firstRootNameDiv); + const remainder = document.createElement('span'); + remainder.textContent = ' and its subfolders cannot be removed as it is the first root folder of the workspace.'; + firstRootMsg.appendChild(remainder); + messageContainer.appendChild(firstRootMsg); + } + + if (hasRootFoldersToRemove) { + const dialog = new ConfirmDialog({ + title: 'Remove Folder from Workspace', + msg: messageContainer, + ok: 'Yes', + cancel: 'No', + hasOk: true, + hasCancel: true + }); + if (await dialog.open()) { + await validUris.reduce((prev, cur) => prev.then(() => this.server.removeRoot(cur, this._root!.uri)), Promise.resolve()); + this._roots = this._roots.filter(root => validUris.indexOf(root.uri) < 0); + this._workspaceRootChange.roots = this._roots; + validUris.forEach(u => this.stopWatch(u)); + this.openWindow({ preserveWindow: true }); + } + } else if (isFirstRootFolderSelected) { + const dialog = new ConfirmDialog({ + title: 'Remove Folder from Workspace', + msg: messageContainer, + hasOk: true, + hasCancel: false + }); + await dialog.open(); + } + } + return Promise.resolve(); + } + throw new Error(`Invalid workspace root URIs. Expected at least one existing directory location. URI: ${uris}.`); + } + + /** + * Clears current workspace root. */ close(): void { this.doClose(); @@ -116,8 +294,17 @@ export class WorkspaceService implements FrontendApplicationContribution { protected async doClose(): Promise { this._root = undefined; + this._roots = []; + this._workspaceConfigFile = undefined; + + this._workspaceRootChange.root = undefined; + this._workspaceRootChange.roots = []; + this._workspaceRootChange.workspaceConfigFile = undefined; + await this.server.setRoot(''); - this.reloadWindow(); + this.stopWatch(); + + await this.reloadWindow(); } /** @@ -128,7 +315,7 @@ export class WorkspaceService implements FrontendApplicationContribution { return undefined; } try { - if (uri && uri.endsWith("/")) { + if (uri && uri.endsWith('/')) { uri = uri.slice(0, -1); } const fileStat = await this.fileSystem.getFileStat(uri); @@ -144,21 +331,26 @@ export class WorkspaceService implements FrontendApplicationContribution { } } - protected openWindow(uri: URI, options?: WorkspaceInput): void { + protected async openWindow(options?: WorkspaceInput) { if (this.shouldPreserveWindow(options)) { - this.reloadWindow(); + await this.refreshUI(); } else { try { 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()); + await this.setupRoots(); + this.logger.error(error.toString()).then(async () => await this.reloadWindow()); } } } - protected reloadWindow(): void { + protected async refreshUI() { + const data = await this.workspaceRootChange; + this.onWorkspaceRootChangeEmitter.fire(data); + } + + protected reloadWindow() { window.location.reload(true); } @@ -170,6 +362,32 @@ export class WorkspaceService implements FrontendApplicationContribution { return options !== undefined && !!options.preserveWindow; } + protected async startWatch(validRoot: FileStat | undefined) { + if (validRoot) { + if (!this._roots.find(r => r.uri === validRoot.uri)) { + this._roots.push(validRoot); + } + if (!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) { + 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 d02a288fce2a6..cb32ef6bdade2 100644 --- a/packages/workspace/src/browser/workspace-storage-service.ts +++ b/packages/workspace/src/browser/workspace-storage-service.ts @@ -10,6 +10,7 @@ import { WorkspaceService } from './workspace-service'; import { inject, injectable } from 'inversify'; import { ILogger } from '@theia/core/lib/common'; import { LocalStorageService } from '@theia/core/lib/browser/storage-service'; +import { FileStat } from '@theia/filesystem/lib/common'; /* * Prefixes any stored data with the current workspace path. @@ -21,14 +22,13 @@ export class WorkspaceStorageService implements StorageService { private initialized: Promise; protected storageService: StorageService; - constructor( @inject(WorkspaceService) protected workspaceService: WorkspaceService, + constructor(@inject(WorkspaceService) protected workspaceService: WorkspaceService, @inject(ILogger) protected logger: ILogger) { this.initialized = this.workspaceService.root.then(stat => { - if (stat) { - this.prefix = stat.uri; - } else { - this.prefix = '_global_'; - } + this.prefix = this.getPrefix(stat); + }); + this.workspaceService.onWorkspaceRootChanged(event => { + this.prefix = this.getPrefix(event ? event.root : undefined); }); this.storageService = new LocalStorageService(this.logger); } @@ -48,6 +48,10 @@ export class WorkspaceStorageService implements StorageService { } protected prefixWorkspaceURI(originalKey: string): string { - return this.prefix + ":" + originalKey; + return `${this.prefix}:${originalKey}`; + } + + protected getPrefix(rootStat: FileStat | undefined) { + 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 87e407a571363..9f31fe115f77a 100644 --- a/packages/workspace/src/browser/workspace-uri-contribution.ts +++ b/packages/workspace/src/browser/workspace-uri-contribution.ts @@ -19,7 +19,8 @@ export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProvid constructor(@inject(WorkspaceService) wsService: WorkspaceService, @inject(FileSystem) protected fileSystem: FileSystem) { super(); - wsService.root.then(root => { + wsService.workspaceRootChange.then(data => { + const root = data.root; if (root) { this.wsRoot = new URI(root.uri).toString(true); } diff --git a/packages/workspace/src/browser/workspace-variable-contribution.ts b/packages/workspace/src/browser/workspace-variable-contribution.ts index 24665a94b3502..f743be1f19c22 100644 --- a/packages/workspace/src/browser/workspace-variable-contribution.ts +++ b/packages/workspace/src/browser/workspace-variable-contribution.ts @@ -87,7 +87,7 @@ export class WorkspaceVariableContribution implements VariableContribution { } protected async getWorkspaceRootUri(): Promise { - const wsRoot = await this.workspaceService.root; + const wsRoot = (await this.workspaceService.workspaceRootChange).root; 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 a7d3284702bf2..f1c05dea0a763 100644 --- a/packages/workspace/src/common/test/mock-workspace-server.ts +++ b/packages/workspace/src/common/test/mock-workspace-server.ts @@ -13,4 +13,12 @@ export class MockWorkspaceServer implements WorkspaceServer { getRoot(): Promise { return Promise.resolve(''); } setRoot(uri: string): Promise { return Promise.resolve(); } + + addRoot(rootUriToAdd: string, workspaceFolderUri: string): Promise { return Promise.resolve(); } + + removeRoot(rootUriToRemove: string, workspaceFolderUri: string): Promise { return Promise.resolve(); } + + getRoots(workspaceFolderUri: string): Promise { return Promise.resolve([]); } + + getWorkspaceConfigFilePath(workspaceFolderUri: string): string { return ''; } } diff --git a/packages/workspace/src/common/workspace-protocol.ts b/packages/workspace/src/common/workspace-protocol.ts index 00d45448f45d0..24b3c821d4ee0 100644 --- a/packages/workspace/src/common/workspace-protocol.ts +++ b/packages/workspace/src/common/workspace-protocol.ts @@ -23,4 +23,41 @@ export interface WorkspaceServer { */ setRoot(uri: string): Promise; + /** + * Adds the desired string representation of the URI to the workspace. + * @param rootUriToAdd string representation of the root URI to be added + * @param workspaceFolderUri string representation of the workspace folder URI + */ + addRoot(rootUriToAdd: string, workspaceFolderUri: string): Promise + + /** + * Removes the desired string representation of the URI from the workspace. + * @param rootUriToRemove string representation of the root URI to be removed + * @param workspaceFolderUri string representation of the workspace folder URI + */ + removeRoot(rootUriToRemove: string, workspaceFolderUri: string): Promise + + /** + * Returns an array of root folders added to the workspace. + * @param workspaceFolderUri string representation of the workspace folder URI + */ + getRoots(workspaceFolderUri: string): Promise; + + /** + * Returns the path of the workspace config file. + * @param workspaceFolderUri string representation of the workspace folder URI + */ + getWorkspaceConfigFilePath(workspaceFolderUri: string): string; +} + +// TODO right at the moment this interface is indentical with WorkspaceData however in future it would evolve +export interface WorkspaceConfigData { + roots: string[] +} + +export namespace WorkspaceConfigData { + // tslint:disable-next-line:no-any + export function is(data: any): data is WorkspaceConfigData { + return data.roots !== undefined; + } } diff --git a/packages/workspace/src/node/default-workspace-server.ts b/packages/workspace/src/node/default-workspace-server.ts index 3d808df8fb49f..1bb28815b8f00 100644 --- a/packages/workspace/src/node/default-workspace-server.ts +++ b/packages/workspace/src/node/default-workspace-server.ts @@ -10,11 +10,12 @@ import * as yargs from 'yargs'; import * as fs from 'fs-extra'; import * as os from 'os'; -import { injectable, inject, postConstruct } from "inversify"; +import { injectable, inject, postConstruct } from 'inversify'; import { FileUri } from '@theia/core/lib/node'; import { CliContribution } from '@theia/core/lib/node/cli'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { WorkspaceServer } from "../common"; +import { WorkspaceServer, WorkspaceConfigData } from '../common'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class WorkspaceCliContribution implements CliContribution { @@ -41,6 +42,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); } } @@ -49,6 +53,7 @@ export class WorkspaceCliContribution implements CliContribution { export class DefaultWorkspaceServer implements WorkspaceServer { protected root: Deferred = new Deferred(); + protected roots: Deferred = new Deferred(); @inject(WorkspaceCliContribution) protected readonly cliParams: WorkspaceCliContribution; @@ -57,12 +62,20 @@ export class DefaultWorkspaceServer implements WorkspaceServer { protected async init() { let root = await this.getRootURIFromCli(); if (!root) { - const data = await this.readFromUserHome(); + const data = await this.readWorkspaceDataFromUserHome(); if (data && data.recentRoots) { root = data.recentRoots[0]; } } this.root.resolve(root); + + if (root && await fs.pathExists(root)) { + const configPath = this.getWorkspaceConfigFilePath(root); + const configContent = await this.readWorkspaceConfigData(configPath); + if (configContent) { + this.roots.resolve(configContent.roots); + } + } } getRoot(): Promise { @@ -72,11 +85,50 @@ export class DefaultWorkspaceServer implements WorkspaceServer { async setRoot(uri: string): Promise { this.root = new Deferred(); this.root.resolve(uri); - this.writeToUserHome({ + await this.writeToUserHome({ recentRoots: [uri] }); } + async addRoot(rootUriToAdd: string, workspaceFolderUri: string): Promise { + const configPath = this.getWorkspaceConfigFilePath(workspaceFolderUri); + const configContent = await this.readWorkspaceConfigData(configPath); + const newConfig: WorkspaceConfigData = { roots: [] }; + if (configContent) { + if (configContent.roots.indexOf(rootUriToAdd) < 0) { + newConfig.roots = [...configContent.roots, rootUriToAdd]; + } else { + newConfig.roots = configContent.roots; + } + } else { + newConfig.roots = [workspaceFolderUri, rootUriToAdd]; + } + await this.writeToWorkspaceFolder(newConfig); + return Promise.resolve(); + } + + async removeRoot(rootUriToRemove: string, workspaceFolderUri: string): Promise { + const configPath = this.getWorkspaceConfigFilePath(workspaceFolderUri); + const configContent = await this.readWorkspaceConfigData(configPath); + if (configContent) { + await this.writeToWorkspaceFolder({ roots: configContent.roots.filter(r => r !== rootUriToRemove) }); + } + return Promise.resolve(); + } + + async getRoots(workspaceFolderUri: string): Promise { + const configPath = this.getWorkspaceConfigFilePath(workspaceFolderUri); + const configContent = await this.readWorkspaceConfigData(configPath); + if (configContent) { + return configContent.roots; + } + return [workspaceFolderUri]; + } + + getWorkspaceConfigFilePath(workspaceFolderUri: string): string { + return path.resolve(new URI(workspaceFolderUri).path.toString(), '.theia', 'roots.json'); + } + protected async getRootURIFromCli(): Promise { const arg = await this.cliParams.workspaceRoot.promise; return arg !== undefined ? FileUri.create(arg).toString() : undefined; @@ -88,30 +140,50 @@ 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 writeToWorkspaceFolder(data: WorkspaceConfigData): Promise { + if (data.roots.length === 0) { + return; + } + const file = this.getWorkspaceConfigFilePath(data.roots[0]); // config file is alway kept in the first opened root folder + 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 config = await fs.readJson(file); - if (WorkspaceData.is(config)) { - return config; - } + private async readWorkspaceDataFromUserHome(): Promise { + const data = await this.readContentFromFile(this.getUserStoragePath()); + if (data && WorkspaceData.is(data)) { + return data; + } + } + + private async readWorkspaceConfigData(configFilePath: string): Promise { + const data = await this.readContentFromFile(configFilePath); + if (data && WorkspaceConfigData.is(data)) { + return data; + } + } + + private async readContentFromFile(filePath: string): Promise { + if (await fs.pathExists(filePath)) { + return await fs.readJson(filePath); } - return undefined; } protected getUserStoragePath(): string { return path.resolve(os.homedir(), '.theia', 'recentworkspace.json'); } - } interface WorkspaceData {