From 543b119a32652f662968411370979ea002a1736a Mon Sep 17 00:00:00 2001 From: elaihau Date: Wed, 8 Aug 2018 09:52:45 -0400 Subject: [PATCH] multi-root workspace support, vsCode compatibility The patch 80f402c6 added the support of having multiple roots in the same workspace. In that patch, workspace meta data is stored in the `.theia` folder of the first root, and brings up the following issues / difficulties: - using the same first root in more than one workspace becomes impossible. - no flexibility of naming the workspace or deciding where to store the workspace meta data. - theia opens the implicit workspace folder after all roots are removed from the workspace, causing confusion. - preferences of the workspace folder is used across the all roots in the same workspace. - workspace meta data is stored in more than one file, and the format is incompatible with most other (if not all) IDEs. This is the 2nd patch for #1660. What is included in this PR: - workspace file becomes independent from the root folders. It is users' choice to decide where to store the information, and what to name the workspace. - theia prompts users to save the workspace data in a user-specified place, if the workspace does not have a user-specified place to store the meta data. - workspaces can be recreated from the workspace file - "Save workspace As..." is available for users to rename workspace or change location of the workspace file. - the format of theia workspace file is compatible with that of vsCode. Paths relative to the parent folder of the workspace file are used where applicable - a multi root workspace can be opened by theia by running `yarn start ` What is not inlcuded in this PR: - the workspace preferences should be saved as part of the meta data file. - root display names are not customizable by adding the folders -> name property in the workspace file. Signed-off-by: elaihau --- packages/filesystem/src/common/filesystem.ts | 11 +- .../src/browser/git-repository-provider.ts | 11 +- packages/git/src/browser/git-widget.tsx | 3 +- .../navigator/src/browser/navigator-model.ts | 4 +- .../src/browser/navigator-widget.tsx | 5 +- .../browser/terminal-frontend-contribution.ts | 4 +- .../src/browser/quick-open-workspace.ts | 44 ++- .../src/browser/workspace-commands.ts | 35 +- .../workspace-frontend-contribution.ts | 103 ++++- .../src/browser/workspace-service.ts | 370 ++++++++++++------ .../src/node/default-workspace-server.ts | 51 +-- 11 files changed, 433 insertions(+), 208 deletions(-) diff --git a/packages/filesystem/src/common/filesystem.ts b/packages/filesystem/src/common/filesystem.ts index 1f04213c05b45..0925c22627439 100644 --- a/packages/filesystem/src/common/filesystem.ts +++ b/packages/filesystem/src/common/filesystem.ts @@ -117,7 +117,7 @@ export interface FileSystem extends JsonRpcServer { } export interface FileMoveOptions { - overwrite?: boolean; + overwrite?: boolean; } export interface FileDeleteOptions { @@ -176,6 +176,15 @@ export namespace FileStat { && candidate.hasOwnProperty('lastModification') && candidate.hasOwnProperty('isDirectory'); } + + export function equals(one: object | undefined, other: object | undefined): boolean { + if (!one || !other || !is(one) || !is(other)) { + return false; + } + return one.uri === other.uri + && one.lastModification === other.lastModification + && one.isDirectory === other.isDirectory; + } } export namespace FileSystemError { diff --git a/packages/git/src/browser/git-repository-provider.ts b/packages/git/src/browser/git-repository-provider.ts index b6b2f446b3380..e5339d3f05425 100644 --- a/packages/git/src/browser/git-repository-provider.ts +++ b/packages/git/src/browser/git-repository-provider.ts @@ -17,6 +17,7 @@ import { Git, Repository } from '../common'; import { injectable, inject } from 'inversify'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { Event, Emitter } from '@theia/core'; export interface GitRefreshOptions { @@ -32,7 +33,8 @@ export class GitRepositoryProvider { constructor( @inject(Git) protected readonly git: Git, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, + @inject(FileSystem) protected readonly fileSystem: FileSystem ) { this.initialize(); } @@ -77,7 +79,12 @@ export class GitRepositoryProvider { } async refresh(options?: GitRefreshOptions): Promise { - const roots = await this.workspaceService.roots; + const roots: FileStat[] = []; + for (const root of await this.workspaceService.roots) { + if (await this.fileSystem.exists(root.uri)) { + roots.push(root); + } + } const repoUris = new Map(); const reposOfRoots = await Promise.all( roots.map(r => this.git.repositories(r.uri, { ...options })) diff --git a/packages/git/src/browser/git-widget.tsx b/packages/git/src/browser/git-widget.tsx index eed5bfa5d2309..b6d6960295200 100644 --- a/packages/git/src/browser/git-widget.tsx +++ b/packages/git/src/browser/git-widget.tsx @@ -19,7 +19,7 @@ import URI from '@theia/core/lib/common/uri'; import { ResourceProvider, CommandService, MenuPath } from '@theia/core'; import { ContextMenuRenderer, LabelProvider, DiffUris, StatefulWidget, Message } from '@theia/core/lib/browser'; import { EditorManager, EditorWidget, EditorOpenerOptions } from '@theia/editor/lib/browser'; -import { WorkspaceService, WorkspaceCommands } from '@theia/workspace/lib/browser'; +import { WorkspaceCommands } from '@theia/workspace/lib/browser'; import { Git, GitFileChange, GitFileStatus, Repository, WorkingDirectoryStatus, CommitWithChanges } from '../common'; import { GitWatcher, GitStatusChangeEvent } from '../common/git-watcher'; import { GIT_RESOURCE_SCHEME } from './git-resource'; @@ -76,7 +76,6 @@ export class GitWidget extends ReactWidget implements StatefulWidget { @inject(CommandService) protected readonly commandService: CommandService, @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider, @inject(LabelProvider) protected readonly labelProvider: LabelProvider, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, @inject(GitAvatarService) protected readonly avatarService: GitAvatarService, @inject(GitCommitMessageValidator) protected readonly commitMessageValidator: GitCommitMessageValidator) { diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts index d1ffe6dc0b3da..b2fa5b4a4c2d2 100644 --- a/packages/navigator/src/browser/navigator-model.ts +++ b/packages/navigator/src/browser/navigator-model.ts @@ -64,9 +64,9 @@ export class FileNavigatorModel extends FileTreeModel { } protected async createRoot(): Promise { - const roots = await this.workspaceService.roots; - if (roots.length > 0) { + if (this.workspaceService.opened) { const workspaceNode = WorkspaceNode.createRoot(); + const roots = await this.workspaceService.roots; for (const root of roots) { workspaceNode.children.push( await this.tree.createWorkspaceRoot(root, workspaceNode) diff --git a/packages/navigator/src/browser/navigator-widget.tsx b/packages/navigator/src/browser/navigator-widget.tsx index 356da036a2508..37c9c78530e01 100644 --- a/packages/navigator/src/browser/navigator-widget.tsx +++ b/packages/navigator/src/browser/navigator-widget.tsx @@ -17,8 +17,8 @@ import { injectable, inject, postConstruct } from 'inversify'; 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 { CommandService, SelectionService } from '@theia/core/lib/common'; +import { CommonCommands } from '@theia/core/lib/browser'; import { ContextMenuRenderer, ExpandableTreeNode, TreeProps, TreeModel, TreeNode, @@ -187,4 +187,5 @@ export class FileNavigatorWidget extends FileTreeWidget { ; } + } diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index b9093ec2b5a2b..110cccc6b298f 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -28,7 +28,6 @@ import { KeyModifier, KeybindingRegistry } from '@theia/core/lib/browser'; import { WidgetManager } from '@theia/core/lib/browser'; -import { WorkspaceService } from '@theia/workspace/lib/browser'; import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions } from './terminal-widget-impl'; import { TerminalKeybindingContexts } from './terminal-keybinding-contexts'; import { TerminalService } from './base/terminal-service'; @@ -50,8 +49,7 @@ export class TerminalFrontendContribution implements TerminalService, CommandCon constructor( @inject(ApplicationShell) protected readonly shell: ApplicationShell, - @inject(WidgetManager) protected readonly widgetManager: WidgetManager, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService + @inject(WidgetManager) protected readonly widgetManager: WidgetManager ) { } registerCommands(commands: CommandRegistry): void { diff --git a/packages/workspace/src/browser/quick-open-workspace.ts b/packages/workspace/src/browser/quick-open-workspace.ts index e5ea18947203a..c68757803b718 100644 --- a/packages/workspace/src/browser/quick-open-workspace.ts +++ b/packages/workspace/src/browser/quick-open-workspace.ts @@ -15,8 +15,9 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; -import { QuickOpenService, QuickOpenModel, QuickOpenItem, QuickOpenGroupItem, QuickOpenMode } from '@theia/core/lib/browser/quick-open/'; -import { WorkspaceService } from './workspace-service'; +import { QuickOpenService, QuickOpenModel, QuickOpenItem, QuickOpenGroupItem, QuickOpenMode, LabelProvider } from '@theia/core/lib/browser'; +import { WorkspaceService, getTemporaryWorkspaceFileUri } from './workspace-service'; +import { WorkspacePreferences } from './workspace-preferences'; import URI from '@theia/core/lib/common/uri'; import { MessageService } from '@theia/core/lib/common'; import { FileSystem, FileSystemUtils } from '@theia/filesystem/lib/common'; @@ -31,42 +32,45 @@ export class QuickOpenWorkspace implements QuickOpenModel { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(MessageService) protected readonly messageService: MessageService; @inject(FileSystem) protected readonly fileSystem: FileSystem; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; async open(workspaces: string[]): Promise { this.items = []; const homeStat = await this.fileSystem.getCurrentUserHome(); const home = (homeStat) ? new URI(homeStat.uri).withoutScheme().toString() : undefined; - + let tempWorkspaceFile: URI | undefined; + if (home) { + tempWorkspaceFile = getTemporaryWorkspaceFileUri(new URI(home)); + } + await this.preferences.ready; for (const workspace of workspaces) { const uri = new URI(workspace); const stat = await this.fileSystem.getFileStat(workspace); - if (!stat) { - continue; + if (!stat || + !this.preferences['workspace.supportMultiRootWorkspace'] && !stat.isDirectory) { + continue; // skip the workspace files if multi root is not supported + } + if (tempWorkspaceFile && uri.toString() === tempWorkspaceFile.toString()) { + continue; // skip the temporary workspace files } const lastModification = moment(stat.lastModification).fromNow(); this.items.push(new QuickOpenGroupItem({ label: uri.path.base, description: (home) ? FileSystemUtils.tildifyPath(uri.path.toString(), home) : uri.path.toString(), groupLabel: (workspace === workspaces[0]) ? 'Current Workspace' : `Modified ${lastModification}`, + iconClass: await this.labelProvider.getIcon(uri) + ' file-icon', run: (mode: QuickOpenMode): boolean => { if (mode !== QuickOpenMode.OPEN) { return false; } - this.workspaceService.roots.then(roots => { - const current = roots[0]; - if (current === undefined) { // Available recent workspace(s) but closed - if (workspace && workspace.length > 0) { - this.workspaceService.open(new URI(workspace)); - } - } else { - if (current.uri !== workspace) { - this.workspaceService.open(new URI(workspace)); - } else { - this.messageService.info(`Using the same workspace [ ${name} ]`); - } - - } - }); + const current = this.workspaceService.workspace; + const uriToOpen = new URI(workspace); + if (current && current.uri === workspace) { + this.messageService.info(`Using the same workspace [ ${uriToOpen.displayName} ]`); + } else { + this.workspaceService.open(uriToOpen); + } return true; }, })); diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 8a5e341aa0c58..ad811a07fc573 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -83,6 +83,10 @@ export namespace WorkspaceCommands { id: 'workspace:removeFolder', label: 'Remove Folder from Workspace' }; + export const SAVE_WORKSPACE_AS: Command = { + id: 'workspace:saveAs', + label: 'Save Workspace As...' + }; } @injectable() @@ -230,14 +234,33 @@ export class WorkspaceCommandContribution implements CommandContribution { isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, isVisible: uris => !uris.length || this.areWorkspaceRoots(uris), execute: async uris => { - const node = await this.fileDialogService.showOpenDialog({ title: WorkspaceCommands.ADD_FOLDER.label! }); - this.addFolderToWorkspace(node); + const node = await this.fileDialogService.showOpenDialog({ + title: WorkspaceCommands.ADD_FOLDER.label!, + canSelectFiles: false, + canSelectFolders: true + }); + if (!node) { + return; + } + const workspaceSavedBeforeAdding = this.workspaceService.saved; + await this.addFolderToWorkspace(node); + if (!workspaceSavedBeforeAdding) { + const saveCommand = registry.getCommand(WorkspaceCommands.SAVE_WORKSPACE_AS.id); + if (saveCommand && await new ConfirmDialog({ + title: 'Folder added to Workspace', + msg: 'A workspace with multiple roots was created. Do you want to save your workspace configuration as a file?', + ok: 'Yes', + cancel: 'No' + }).open()) { + registry.executeCommand(saveCommand.id); + } + } } })); registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, this.newMultiUriAwareCommandHandler({ + execute: uris => this.removeFolderFromWorkspace(uris), isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, - isVisible: uris => this.areWorkspaceRoots(uris), - execute: uris => this.removeFolderFromWorkspace(uris) + isVisible: uris => this.areWorkspaceRoots(uris) && this.workspaceService.saved })); }); } @@ -298,9 +321,9 @@ export class WorkspaceCommandContribution implements CommandContribution { return parentUri.resolve(base); } - protected addFolderToWorkspace(node: Readonly | undefined): void { + protected async addFolderToWorkspace(node: Readonly | undefined): Promise { if (node && node.fileStat.isDirectory) { - this.workspaceService.addRoot(node.uri); + await this.workspaceService.addRoot(node.uri); } } diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index ea53281861028..3ad8f0399ee58 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -19,9 +19,11 @@ import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegist import { open, OpenerService, CommonMenus, StorageService, LabelProvider, ConfirmDialog, KeybindingRegistry, KeybindingContribution } from '@theia/core/lib/browser'; import { FileStatNode, FileDialogService, OpenFileDialogProps } from '@theia/filesystem/lib/browser'; import { FileSystem } from '@theia/filesystem/lib/common'; -import { WorkspaceService } from './workspace-service'; +import { WorkspaceService, THEIA_EXT, VSCODE_EXT } from './workspace-service'; import { WorkspaceCommands } from './workspace-commands'; import { QuickOpenWorkspace } from './quick-open-workspace'; +import { WorkspacePreferences } from './workspace-preferences'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class WorkspaceFrontendContribution implements CommandContribution, KeybindingContribution, MenuContribution { @@ -33,25 +35,18 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi @inject(StorageService) protected readonly workspaceStorage: StorageService, @inject(LabelProvider) protected readonly labelProvider: LabelProvider, @inject(QuickOpenWorkspace) protected readonly quickOpenWorkspace: QuickOpenWorkspace, - @inject(FileDialogService) protected readonly fileDialogService: FileDialogService + @inject(FileDialogService) protected readonly fileDialogService: FileDialogService, + @inject(WorkspacePreferences) protected preferences: WorkspacePreferences ) { } registerCommands(commands: CommandRegistry): void { commands.registerCommand(WorkspaceCommands.OPEN, { isEnabled: () => true, - execute: () => this.showFileDialog({ - title: WorkspaceCommands.OPEN.label!, - canSelectFolders: true, - canSelectFiles: true - }) + execute: () => this.doOpen() }); commands.registerCommand(WorkspaceCommands.OPEN_WORKSPACE, { isEnabled: () => true, - execute: () => this.showFileDialog({ - title: WorkspaceCommands.OPEN_WORKSPACE.label!, - canSelectFolders: true, - canSelectFiles: false - }) + execute: () => this.openWorkspace() }); commands.registerCommand(WorkspaceCommands.CLOSE, { isEnabled: () => this.workspaceService.opened, @@ -61,6 +56,10 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi isEnabled: () => this.workspaceService.hasHistory, execute: () => this.quickOpenWorkspace.select() }); + commands.registerCommand(WorkspaceCommands.SAVE_WORKSPACE_AS, { + isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, + execute: () => this.saveWorkspaceAs() + }); } registerMenus(menus: MenuModelRegistry): void { @@ -72,13 +71,18 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi commandId: WorkspaceCommands.OPEN_WORKSPACE.id, order: 'a10' }); - menus.registerMenuAction(CommonMenus.FILE_CLOSE, { - commandId: WorkspaceCommands.CLOSE.id - }); menus.registerMenuAction(CommonMenus.FILE_OPEN, { commandId: WorkspaceCommands.OPEN_RECENT_WORKSPACE.id, order: 'a20' }); + menus.registerMenuAction(CommonMenus.FILE_OPEN, { + commandId: WorkspaceCommands.SAVE_WORKSPACE_AS.id, + order: 'a30' + }); + + menus.registerMenuAction(CommonMenus.FILE_CLOSE, { + commandId: WorkspaceCommands.CLOSE.id + }); } registerKeybindings(keybindings: KeybindingRegistry): void { @@ -96,14 +100,18 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi }); } - protected showFileDialog(props: OpenFileDialogProps): void { + protected doOpen(): void { this.workspaceService.roots.then(async roots => { - const node = await this.fileDialogService.showOpenDialog(props, roots[0]); - this.openFile(node); + const node = await this.fileDialogService.showOpenDialog({ + title: WorkspaceCommands.OPEN.label!, + canSelectFolders: true, + canSelectFiles: true + }, roots[0]); + this.doOpenFileOrFolder(node); }); } - protected openFile(node: Readonly | undefined): void { + protected doOpenFileOrFolder(node: Readonly | undefined): void { if (!node) { return; } @@ -114,6 +122,27 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi } } + protected async openWorkspace(): Promise { + const option: OpenFileDialogProps = { + title: WorkspaceCommands.OPEN_WORKSPACE.label!, + canSelectFiles: false, + canSelectFolders: true, + }; + await this.preferences.ready; + if (this.preferences['workspace.supportMultiRootWorkspace']) { + option.canSelectFiles = true; + option.filters = { + 'Theia Workspace (*.theia-workspace)': [THEIA_EXT], + 'VS Code Workspace (*.code-workspace)': [VSCODE_EXT] + }; + } + const selected = await this.fileDialogService.showOpenDialog(option); + if (selected) { + // open the selected directory, or recreate a workspace from the selected file + this.workspaceService.open(selected.uri); + } + } + protected async closeWorkspace(): Promise { const dialog = new ConfirmDialog({ title: WorkspaceCommands.CLOSE.label!, @@ -124,4 +153,40 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi } } + protected async saveWorkspaceAs(): Promise { + let exist: boolean = false; + let overwrite: boolean = false; + let selected: URI | undefined; + do { + selected = await this.fileDialogService.showSaveDialog({ + title: WorkspaceCommands.SAVE_WORKSPACE_AS.label!, + filters: { + 'Theia Workspace (*.theia-workspace)': [THEIA_EXT], + 'VS Code Workspace (*.code-workspace)': [VSCODE_EXT] + } + }); + if (selected) { + const displayName = selected.displayName; + if (!displayName.endsWith(`.${THEIA_EXT}`) && !displayName.endsWith(`.${VSCODE_EXT}`)) { + selected = selected.parent.resolve(`${displayName}.${THEIA_EXT}`); + } + exist = await this.fileSystem.exists(selected.toString()); + if (exist) { + overwrite = await this.confirmOverwrite(selected); + } + } + } while (selected && exist && !overwrite); + + if (selected) { + this.workspaceService.save(selected); + } + } + + private async confirmOverwrite(uri: URI): Promise { + const confirmed = await new ConfirmDialog({ + title: 'Overwrite', + msg: `Do you really want to overwrite "${uri.toString()}"?` + }).open(); + return !!confirmed; + } } diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index c4b3d08a301c5..7f1fd279fe361 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -21,10 +21,14 @@ import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browse import { WorkspaceServer } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; -import { Disposable, Emitter, Event, DisposableCollection } from '@theia/core/lib/common'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { ILogger } from '@theia/core/lib/common/logger'; +import { ILogger, Disposable, DisposableCollection, Emitter, Event } from '@theia/core'; import { WorkspacePreferences } from './workspace-preferences'; +import * as jsoncparser from 'jsonc-parser'; +import * as Ajv from 'ajv'; + +export const THEIA_EXT = 'theia-workspace'; +export const VSCODE_EXT = 'code-workspace'; /** * The workspace service. @@ -32,8 +36,8 @@ import { WorkspacePreferences } from './workspace-preferences'; @injectable() export class WorkspaceService implements FrontendApplicationContribution { - // TODO remove it with the patch where the config file becomes independent from the workspace - private _workspaceFolder: FileStat | undefined; + private _workspace: FileStat | undefined; + private _roots: FileStat[] = []; private deferredRoots = new Deferred(); @@ -59,18 +63,18 @@ export class WorkspaceService implements FrontendApplicationContribution { @postConstruct() protected async init(): Promise { - await this.updateWorkspace(); - this.updateTitle(); - const configUri = this.getWorkspaceConfigFileUri(); - if (configUri) { - this.watcher.onFilesChanged(event => { - if (FileChangeEvent.isAffected(event, configUri)) { - this.updateWorkspace(); - } - }); - } + const workspaceUri = await this.server.getMostRecentlyUsedWorkspace(); + const workspaceFileStat = await this.toFileStat(workspaceUri); + await this.setWorkspace(workspaceFileStat); + + this.watcher.onFilesChanged(event => { + if (this._workspace && FileChangeEvent.isAffected(event, new URI(this._workspace.uri))) { + this.updateWorkspace(); + } + }); this.preferences.onPreferenceChanged(event => { - if (event.preferenceName === 'workspace.supportMultiRootWorkspace') { + const multiRootPrefName = 'workspace.supportMultiRootWorkspace'; + if (event.preferenceName === multiRootPrefName) { this.updateWorkspace(); } }); @@ -82,28 +86,32 @@ export class WorkspaceService implements FrontendApplicationContribution { tryGetRoots(): FileStat[] { return this._roots; } + get workspace(): FileStat | undefined { + return this._workspace; + } protected readonly onWorkspaceChangeEmitter = new Emitter(); get onWorkspaceChanged(): Event { return this.onWorkspaceChangeEmitter.event; } - protected async updateWorkspace(): Promise { - await this.ensureWorkspaceInitialized(); - await this.updateRoots(); - this.watchRoots(); - } - - protected async ensureWorkspaceInitialized(): Promise { - if (this._workspaceFolder) { + protected readonly toDisposeOnWorkspace = new DisposableCollection(); + protected async setWorkspace(workspaceStat: FileStat | undefined): Promise { + if (FileStat.equals(this._workspace, workspaceStat)) { return; } - const rootUri = await this.server.getMostRecentlyUsedWorkspace(); - this._workspaceFolder = await this.toValidRoot(rootUri); - const configUri = this.getWorkspaceConfigFileUri(); - if (configUri) { - this.watcher.watchFileChanges(configUri); + this.toDisposeOnWorkspace.dispose(); + this._workspace = workspaceStat; + if (this._workspace) { + this.toDisposeOnWorkspace.push(await this.watcher.watchFileChanges(new URI(this._workspace.uri))); } + this.updateTitle(); + await this.updateWorkspace(); + } + + protected async updateWorkspace(): Promise { + await this.updateRoots(); + this.watchRoots(); } protected async updateRoots(): Promise { @@ -115,51 +123,58 @@ export class WorkspaceService implements FrontendApplicationContribution { } protected async computeRoots(): Promise { - const config = await this.getWorkspaceConfig(); - if (!config || !config.roots.length) { - return this._workspaceFolder ? [this._workspaceFolder] : []; - } const roots: FileStat[] = []; - for (const rootUri of config.roots) { - const root = await this.toValidRoot(rootUri); - if (root) { - roots.push(root); + if (this._workspace) { + if (this._workspace.isDirectory) { + return [this._workspace]; } - } - return roots; - } - protected async getWorkspaceConfig(): Promise<{ stat: FileStat, roots: string[] } | undefined> { - if (!this.preferences['workspace.supportMultiRootWorkspace']) { - return undefined; - } - const configUri = this.getWorkspaceConfigFileUri(); - if (configUri) { - const uriStr = configUri.path.toString(); - if (await this.fileSystem.exists(uriStr)) { - const { stat, content } = await this.fileSystem.resolveContent(uriStr); - if (content) { - // FIXME use jsonc + ajx to parse & validate content - const roots: string[] = JSON.parse(content).roots || []; - return { stat, roots }; + const workspaceData = await this.getWorkspaceDataFromFile(); + if (workspaceData) { + for (const { path } of workspaceData.folders) { + const valid = await this.toValidRoot(path); + if (valid) { + roots.push(valid); + } else { + roots.push({ + uri: path, + lastModification: Date.now(), + isDirectory: true + }); + } } } } - return undefined; + return roots; } - protected getWorkspaceConfigFileUri(): URI | undefined { - if (this._workspaceFolder) { - const rootUri = new URI(this._workspaceFolder.uri); - return rootUri.resolve('.theia').resolve('root.json'); + protected async getWorkspaceDataFromFile(): Promise { + if (this._workspace && await this.fileSystem.exists(this._workspace.uri)) { + if (this._workspace.isDirectory) { + return { + folders: [{ path: this._workspace.uri }] + }; + } + const { stat, content } = await this.fileSystem.resolveContent(this._workspace.uri); + const strippedContent = jsoncparser.stripComments(content); + const data = jsoncparser.parse(strippedContent); + if (data && WorkspaceData.is(data)) { + return WorkspaceData.transformToAbsolute(data, stat); + } + this.logger.error(`Unable to retrieve workspace data from the file: '${this._workspace.uri}'. Please check if the file is corrupted.`); } - return undefined; } protected updateTitle(): void { - if (this._workspaceFolder) { - const uri = new URI(this._workspaceFolder.uri); - document.title = uri.displayName; + if (this._workspace) { + const uri = new URI(this._workspace.uri); + const displayName = uri.displayName; + if (!this._workspace.isDirectory && + (displayName.endsWith(`.${THEIA_EXT}`) || displayName.endsWith(`.${VSCODE_EXT}`))) { + document.title = displayName.slice(0, displayName.lastIndexOf('.')); + } else { + document.title = displayName; + } } else { document.title = window.location.href; } @@ -170,9 +185,7 @@ export class WorkspaceService implements FrontendApplicationContribution { * @param app */ onStop(app: FrontendApplication): void { - if (this._workspaceFolder) { - this.server.setMostRecentlyUsedWorkspace(this._workspaceFolder.uri); - } + this.server.setMostRecentlyUsedWorkspace(this._workspace ? this._workspace.uri : ''); } async onStart() { @@ -195,7 +208,7 @@ export class WorkspaceService implements FrontendApplicationContribution { * @returns {boolean} */ get opened(): boolean { - return !!this._workspaceFolder; + return !!this._workspace; } /** @@ -207,7 +220,7 @@ export class WorkspaceService implements FrontendApplicationContribution { } /** - * Opens the given URI as the current workspace root. + * Opens directory, or recreates a workspace from the file that `uri` points to. */ open(uri: URI, options?: WorkspaceInput): void { this.doOpen(uri, options); @@ -215,21 +228,20 @@ export class WorkspaceService implements FrontendApplicationContribution { protected async doOpen(uri: URI, options?: WorkspaceInput): Promise { const rootUri = uri.toString(); - const valid = await this.toValidRoot(rootUri); - if (valid) { + const stat = await this.toFileStat(rootUri); + if (stat) { // The same window has to be preserved too (instead of opening a new one), if the workspace root is not yet available and we are setting it for the first time. // Option passed as parameter has the highest priority (for api developers), then the preference, then the default. await this.roots; - const rootToOpen = this._workspaceFolder; const { preserveWindow } = { - preserveWindow: this.preferences['workspace.preserveWindow'] || !(rootToOpen), + preserveWindow: this.preferences['workspace.preserveWindow'] || !this.opened, ...options }; await this.server.setMostRecentlyUsedWorkspace(rootUri); if (preserveWindow) { - this._workspaceFolder = valid; + this._workspace = stat; } - await this.openWindow({ preserveWindow }); + await this.openWindow(stat, { preserveWindow }); return; } throw new Error('Invalid workspace root URI. Expected an existing directory location.'); @@ -241,26 +253,23 @@ export class WorkspaceService implements FrontendApplicationContribution { */ async addRoot(uri: URI): Promise { await this.roots; - if (!this.opened || !this._workspaceFolder) { - throw new Error('Folder cannot be added as there is no active folder in the current workspace.'); - } - const rootToAdd = uri.toString(); - const valid = await this.toValidRoot(rootToAdd); + if (!this.opened) { + throw new Error('Folder cannot be added as there is no active workspace or opened folder.'); + } + const valid = await this.toValidRoot(uri); if (!valid) { - throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${rootToAdd}.`); + throw new Error(`Invalid workspace root URI. Expected an existing directory location. URI: ${uri.toString()}.`); } - if (this._workspaceFolder && !this._roots.find(r => r.uri === valid.uri)) { - const configUri = this.getWorkspaceConfigFileUri(); - if (configUri) { - if (!await this.fileSystem.exists(configUri.toString())) { - await this.fileSystem.createFile(configUri.toString()); + + if (this._workspace && !this._roots.find(r => r.uri === valid.uri)) { + if (this._workspace.isDirectory) { // save the workspace data in a temporary file + const tempFile = await this.getTemporaryWorkspaceFile(); + if (tempFile) { + await this.save(tempFile); } - await this.writeRootFolderConfigFile( - (await this.fileSystem.getFileStat(configUri.toString()))!, - [...this._roots, valid] - ); } + this._workspace = await this.writeWorkspaceFile(this._workspace, [...this._roots, valid]); } } @@ -271,27 +280,41 @@ export class WorkspaceService implements FrontendApplicationContribution { if (!this.opened) { throw new Error('Folder cannot be removed as there is no active folder in the current workspace.'); } - const config = await this.getWorkspaceConfig(); - if (config) { - await this.writeRootFolderConfigFile( - config.stat, this._roots.filter(root => uris.findIndex(u => u.toString() === root.uri) < 0) + if (this._workspace) { + this._workspace = await this.writeWorkspaceFile( + this._workspace, this._roots.filter(root => uris.findIndex(u => u.toString() === root.uri) < 0) + ); + } + } + + private async writeWorkspaceFile(workspaceFile: FileStat | undefined, rootFolders: FileStat[]): Promise { + if (workspaceFile) { + const workspaceData = WorkspaceData.transformToRelative( + WorkspaceData.buildWorkspaceData(rootFolders.map(f => f.uri)), workspaceFile ); + if (workspaceData) { + const stat = await this.fileSystem.setContent(workspaceFile, JSON.stringify(workspaceData)); + return stat; + } } } - private async writeRootFolderConfigFile(rootConfigFile: FileStat, rootFolders: FileStat[]): Promise { - const folders = rootFolders.slice(); - if (folders.length === 0 && this._workspaceFolder) { - folders.push(this._workspaceFolder); + private async getTemporaryWorkspaceFile(): Promise { + const home = await this.fileSystem.getCurrentUserHome(); + if (home) { + const tempWorkspaceUri = getTemporaryWorkspaceFileUri(new URI(home.uri)); + if (!await this.fileSystem.exists(tempWorkspaceUri.toString())) { + return await this.fileSystem.createFile(tempWorkspaceUri.toString()); + } + return this.toFileStat(tempWorkspaceUri); } - await this.fileSystem.setContent(rootConfigFile, JSON.stringify({ roots: folders.map(f => f.uri) })); } /** * Clears current workspace root. */ close(): void { - this._workspaceFolder = undefined; + this._workspace = undefined; this._roots.length = 0; this.server.setMostRecentlyUsedWorkspace(''); @@ -301,28 +324,37 @@ export class WorkspaceService implements FrontendApplicationContribution { /** * returns a FileStat if the argument URI points to an existing directory. Otherwise, `undefined`. */ - protected async toValidRoot(uri: string | undefined): Promise { + protected async toValidRoot(uri: URI | string | undefined): Promise { + const fileStat = await this.toFileStat(uri); + if (fileStat && fileStat.isDirectory) { + return fileStat; + } + return undefined; + } + + /** + * returns a FileStat if the argument URI points to a file or directory. Otherwise, `undefined`. + */ + protected async toFileStat(uri: URI | string | undefined): Promise { if (!uri) { return undefined; } + let uriStr = uri.toString(); try { - if (uri && uri.endsWith('/')) { - uri = uri.slice(0, -1); + if (uriStr.endsWith('/')) { + uriStr = uriStr.slice(0, -1); } - const fileStat = await this.fileSystem.getFileStat(uri); + const fileStat = await this.fileSystem.getFileStat(uriStr); if (!fileStat) { return undefined; } - if (fileStat.isDirectory) { - return fileStat; - } - return undefined; + return fileStat; } catch (error) { return undefined; } } - protected openWindow(options?: WorkspaceInput): void { + protected openWindow(uri: FileStat, options?: WorkspaceInput): void { if (this.shouldPreserveWindow(options)) { this.reloadWindow(); } else { @@ -330,7 +362,7 @@ export class WorkspaceService implements FrontendApplicationContribution { this.openNewWindow(); } catch (error) { // Fall back to reloading the current window in case the browser has blocked the new window - this._workspaceFolder = undefined; + this._workspace = uri; this.logger.error(error.toString()).then(async () => await this.reloadWindow()); } } @@ -354,24 +386,45 @@ export class WorkspaceService implements FrontendApplicationContribution { */ async containsSome(paths: string[]): Promise { await this.roots; - if (this._workspaceFolder) { - const uri = new URI(this._workspaceFolder.uri); - for (const path of paths) { - const fileUri = uri.resolve(path).toString(); - const exists = await this.fileSystem.exists(fileUri); - if (exists) { - return exists; + if (this.opened) { + for (const root of this._roots) { + const uri = new URI(root.uri); + for (const path of paths) { + const fileUri = uri.resolve(path).toString(); + const exists = await this.fileSystem.exists(fileUri); + if (exists) { + return exists; + } } } } return false; } - protected readonly watchers = new Map(); + get saved(): boolean { + return !!this._workspace && !this._workspace.isDirectory; + } + + /** + * Save workspace data into a file + * @param uri URI or FileStat of the workspace file + */ + async save(uri: URI | FileStat): Promise { + const uriStr = uri instanceof URI ? uri.toString() : uri.uri; + if (!await this.fileSystem.exists(uriStr)) { + await this.fileSystem.createFile(uriStr); + } + let stat = await this.toFileStat(uriStr); + stat = await this.writeWorkspaceFile(stat, await this.roots); + await this.server.setMostRecentlyUsedWorkspace(uriStr); + await this.setWorkspace(stat); + } + + protected readonly rootWatchers = new Map(); protected async watchRoots(): Promise { const rootUris = new Set(this._roots.map(r => r.uri)); - for (const [uri, watcher] of this.watchers.entries()) { + for (const [uri, watcher] of this.rootWatchers.entries()) { if (!rootUris.has(uri)) { watcher.dispose(); } @@ -382,17 +435,23 @@ export class WorkspaceService implements FrontendApplicationContribution { } protected async watchRoot(root: FileStat): Promise { - if (this.watchers.has(root.uri)) { + const uriStr = root.uri; + if (this.rootWatchers.has(uriStr)) { return; } - this.watchers.set(root.uri, new DisposableCollection( - await this.watcher.watchFileChanges(new URI(root.uri)), - Disposable.create(() => this.watchers.delete(root.uri)) - )); + const watcher = this.watcher.watchFileChanges(new URI(uriStr)); + this.rootWatchers.set(uriStr, Disposable.create(() => { + watcher.then(disposable => disposable.dispose()); + this.rootWatchers.delete(uriStr); + })); } } +export function getTemporaryWorkspaceFileUri(home: URI): URI { + return home.resolve('.theia').resolve(`Untitled.${THEIA_EXT}`).withScheme('file'); +} + export interface WorkspaceInput { /** @@ -401,3 +460,72 @@ export interface WorkspaceInput { preserveWindow?: boolean; } + +interface WorkspaceData { + folders: Array<{ path: string }>; + // TODO add workspace settings settings?: { [id: string]: any }; +} + +namespace WorkspaceData { + const validateSchema = new Ajv().compile({ + type: 'object', + properties: { + folders: { + description: 'Root folders in the workspace', + type: 'array', + items: { + type: 'object', + properties: { + path: { + type: 'string', + } + }, + required: ['path'] + } + } + } + }); + + // tslint:disable-next-line:no-any + export function is(data: any): data is WorkspaceData { + return !!validateSchema(data); + } + + export function buildWorkspaceData(folders: string[]): WorkspaceData { + return { + folders: folders.map(f => ({ path: f })) + }; + } + + export function transformToRelative(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData { + const folderUris: string[] = []; + const workspaceFileUri = new URI(workspaceFile ? workspaceFile.uri : '').withScheme('file'); + for (const { path } of data.folders) { + const folderUri = new URI(path).withScheme('file'); + const rel = workspaceFileUri.parent.relative(folderUri); + if (rel) { + folderUris.push(rel.toString()); + } else { + folderUris.push(folderUri.toString()); + } + } + return buildWorkspaceData(folderUris); + } + + export function transformToAbsolute(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData { + if (workspaceFile) { + const folders: string[] = []; + for (const folder of data.folders) { + const path = folder.path; + if (path.startsWith('file:///')) { + folders.push(path); + } else { + folders.push(new URI(workspaceFile.uri).withScheme('file').parent.resolve(path).toString()); + } + + } + return Object.assign(data, buildWorkspaceData(folders)); + } + return data; + } +} diff --git a/packages/workspace/src/node/default-workspace-server.ts b/packages/workspace/src/node/default-workspace-server.ts index 82bc614fbb55e..269cf1a022b3a 100644 --- a/packages/workspace/src/node/default-workspace-server.ts +++ b/packages/workspace/src/node/default-workspace-server.ts @@ -18,6 +18,7 @@ import * as path from 'path'; import * as yargs from 'yargs'; import * as fs from 'fs-extra'; import * as os from 'os'; +import * as jsoncparser from 'jsonc-parser'; import { injectable, inject, postConstruct } from 'inversify'; import { FileUri } from '@theia/core/lib/node'; @@ -77,7 +78,7 @@ export class DefaultWorkspaceServer implements WorkspaceServer { protected async init() { let root = await this.getWorkspaceURIFromCli(); if (!root) { - const data = await this.readMostRecentWorkspaceRootFromUserHome(); + const data = await this.readRecentWorkspacePathsFromUserHome(); if (data && data.recentRoots) { root = data.recentRoots[0]; } @@ -109,7 +110,7 @@ export class DefaultWorkspaceServer implements WorkspaceServer { async getRecentWorkspaces(): Promise { const listUri: string[] = []; - const data = await this.readMostRecentWorkspaceRootFromUserHome(); + const data = await this.readRecentWorkspacePathsFromUserHome(); if (data && data.recentRoots) { data.recentRoots.forEach(element => { if (element.length > 0) { @@ -139,7 +140,7 @@ export class DefaultWorkspaceServer implements WorkspaceServer { * Writes the given uri as the most recently used workspace root to the user's home directory. * @param uri most recently used uri */ - private async writeToUserHome(data: WorkspaceData): Promise { + private async writeToUserHome(data: RecentWorkspacePathsData): Promise { const file = this.getUserStoragePath(); await this.writeToFile(file, data); } @@ -154,37 +155,27 @@ export class DefaultWorkspaceServer implements WorkspaceServer { /** * Reads the most recently used workspace root from the user's home directory. */ - private async readMostRecentWorkspaceRootFromUserHome(): Promise { - const data = await this.readJsonFromFile(this.getUserStoragePath()); - if (data && WorkspaceData.is(data)) { + private async readRecentWorkspacePathsFromUserHome(): Promise { + const filePath = this.getUserStoragePath(); + const data = await this.readJsonFromFile(filePath); + if (data && RecentWorkspacePathsData.is(data)) { return data; } + fs.exists(filePath, exists => { + if (exists) { + const message = `Unable to retrieve recent workspaces from the file: '${filePath}'. Please check if the file is corrupted.`; + this.messageService.error(message); + this.logger.error('[CAUGHT]', message); + } + }); } private async readJsonFromFile(filePath: string): Promise { if (await fs.pathExists(filePath)) { const rawContent = await fs.readFile(filePath, 'utf-8'); - const content = rawContent.trim(); - if (!content) { - return undefined; - } - - let config; - try { - config = JSON.parse(content); - } catch (error) { - this.messageService.warn(`Parse error in '${filePath}':\nFile will be ignored...`); - error.message = `${filePath}:\n${error.message}`; - this.logger.warn('[CAUGHT]', error); - return undefined; - } - - if (WorkspaceData.is(config)) { - return config; - } + const strippedContent = jsoncparser.stripComments(rawContent); + return jsoncparser.parse(strippedContent); } - - return undefined; } protected getUserStoragePath(): string { @@ -192,13 +183,13 @@ export class DefaultWorkspaceServer implements WorkspaceServer { } } -interface WorkspaceData { +interface RecentWorkspacePathsData { recentRoots: string[]; } -namespace WorkspaceData { +namespace RecentWorkspacePathsData { // tslint:disable-next-line:no-any - export function is(data: any): data is WorkspaceData { - return data.recentRoots !== undefined; + export function is(data: any): data is RecentWorkspacePathsData { + return data.recentRoots !== undefined && Array.isArray(data.recentRoots); } }