Skip to content

Commit

Permalink
multi-root workspace support, minimal patch
Browse files Browse the repository at this point in the history
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 <liang.huang@ericsson.com>
  • Loading branch information
elaihau committed Aug 3, 2018
1 parent f6c71be commit a0fe5c5
Show file tree
Hide file tree
Showing 39 changed files with 732 additions and 222 deletions.
32 changes: 17 additions & 15 deletions packages/core/src/browser/tree/tree-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompositeTreeNode | undefined, number>();
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();
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/browser/tree/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface Tree extends Disposable {
*/
refresh(parent: Readonly<CompositeTreeNode>): Promise<void>;
/**
* Emit when the children of the give node are refreshed.
* Emit when the children of the given node are refreshed.
*/
readonly onNodeRefreshed: Event<Readonly<CompositeTreeNode>>;
}
Expand Down
34 changes: 17 additions & 17 deletions packages/file-search/src/browser/quick-file-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand All @@ -67,7 +66,7 @@ export class QuickFileOpenService implements QuickOpenModel {
protected currentLookFor: string = '';

isEnabled(): boolean {
return this.wsRoot !== undefined;
return this.workspaceService.opened;
}

open(): void {
Expand Down Expand Up @@ -117,7 +116,8 @@ export class QuickFileOpenService implements QuickOpenModel {
private cancelIndicator = new CancellationTokenSource();

public async onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): Promise<void> {
if (!this.wsRoot) {
const workspaceFolder = this.workspaceService.tryGetRoots()[0];
if (!workspaceFolder) {
return;
}

Expand All @@ -127,7 +127,7 @@ export class QuickFileOpenService implements QuickOpenModel {
this.cancelIndicator = new CancellationTokenSource();
const token = this.cancelIndicator.token;
const proposed = new Set<string>();
const rootUri = this.wsRoot.uri;
const rootUri = workspaceFolder.uri;
const handler = async (result: string[]) => {
if (!token.isCancellationRequested) {
const root = new URI(rootUri);
Expand Down
49 changes: 49 additions & 0 deletions packages/filesystem/src/browser/file-dialog-service.ts
Original file line number Diff line number Diff line change
@@ -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<FileStatNode | undefined> {
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;
}
}
}
}
14 changes: 9 additions & 5 deletions packages/filesystem/src/browser/file-tree/file-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const affectedNodes = await this.getAffectedNodes(changes);
if (affectedNodes.length !== 0) {
affectedNodes.forEach(node => this.refresh(node));
} else if (this.isRootAffected(changes)) {
Expand All @@ -83,15 +83,15 @@ export class FileTreeModel extends TreeModelImpl implements LocationService {
return false;
}

protected getAffectedNodes(changes: FileChange[]): CompositeTreeNode[] {
protected async getAffectedNodes(changes: FileChange[]): Promise<CompositeTreeNode[]> {
const nodes = new Map<string, CompositeTreeNode>();
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<void> {
if (this.isFileContentChanged(change)) {
return;
}
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions packages/filesystem/src/browser/file-tree/file-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,7 +33,6 @@ export class FileTree extends TreeImpl {
if (fileStat) {
return this.toNodes(fileStat, parent);
}

return [];
}
return super.resolveChildren(parent);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/filesystem/src/browser/filesystem-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
});
1 change: 1 addition & 0 deletions packages/filesystem/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
14 changes: 9 additions & 5 deletions packages/git/src/browser/git-decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, GitFileChange> {
Expand Down
18 changes: 14 additions & 4 deletions packages/git/src/browser/git-repository-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export class GitRepositoryProvider {
}

protected async initialize(): Promise<void> {
this.workspaceService.onWorkspaceChanged(event => {
this.refresh();
});
await this.refresh({ maxCount: 1 });
await this.refresh();
}
Expand Down Expand Up @@ -74,14 +77,21 @@ export class GitRepositoryProvider {
}

async refresh(options?: GitRefreshOptions): Promise<void> {
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<string, Repository>();
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];
Expand Down
24 changes: 15 additions & 9 deletions packages/git/src/browser/git-view-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,23 @@ export class GitViewContribution extends AbstractViewContribution<GitWidget> 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 => {
Expand Down
2 changes: 2 additions & 0 deletions packages/git/src/browser/history/git-history-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export class GitHistoryWidget extends GitNavigableListWidget<GitHistoryListNode>
this.scrollContainer = 'git-history-list-container';
this.title.label = 'Git History';
this.addClass('theia-git');
this.options = {};
this.commits = [];
}

protected onAfterAttach(msg: Message) {
Expand Down
Loading

0 comments on commit a0fe5c5

Please sign in to comment.