Data workspace projects changes (#13466)

* Fix project context menu actions (#12541)

* delete works again

* make fewer changes

* update all sql db project commands

* cleanup

* Remove old projects view (#12563)

* remove old projects view from file explorer view

* fix tests failing

* remove projects in open folder opening up in old view

* Update db reference dialog to show projects in the workspace (#12580)

* update database reference dialog to show projects in the workspace in the project dropdown

* remove workspace stuff from sql projects extension

* undo change

* add class that implements IExtension

* undo a change

* update DataWorkspaceExtension to take workspaceService as a parameter

* add type

* Update sql database project commands (#12595)

* remove sql proj's open and create new project from comman palette

* hook up create project from database to data workspace

* rename the remaining import databases to create project from database

* remove open, new, and close commands

* expose addProjectsToWorkspace() in IExtension instead of calling command

* Addressing comments

* fix failing sql project tests (#12651)

* update SSDT projects opened in projects viewlet (#12669)

* fix action not refreshing the tree issue (#12692)

* fix adding project references in new projects viewlet (#12688)

* Remove old projects tree provider (#12702)

* Remove old projects tree provider and fix tests

* formatting

* update refreshProjectsTree() to accept workspaceTreeItem()

* Cleanup ProjectsController (#12718)

* remove openProject from ProjectController and some cleanup

* rename

* add project and open project dialogs (#12729)

* empty dialogs

* wip

* new project dialog implementation

* revert gitattributes

* open project dialog

* implement add project

* remove icon helper

* refactor

* revert script change

* adjust views

* more updates

* make data-workspace a builtin extension

* show the view only when project provider is detected (#12819)

* only show the view when proj provider is available

* update

* fix sql project tests after merge (#12793)

* Update dialogs to be closer to mockups (#12879)

* small UI changes to dialogs

* center radio card group text

* Create workspace if needed when opening/new project (#12930)

* empty dialogs

* wip

* new project dialog implementation

* revert gitattributes

* open project dialog

* implement add project

* remove icon helper

* refactor

* revert script change

* create workspace

* initial changes

* create new workspace working

* fix tests

* cleanup

* remove showWorkspaceRequiredNotification()

* Add test for no workspace open

* update blue buttons

* move loading temp project to activate() instead of workspaceService constructor

* move workspace creation warning message to before project is created

* pass uri to createWorkspace

* add tests

Co-authored-by: Alan Ren <alanren@microsoft.com>

* Additional create workspace changes (#13004)

* Dialogs workspace updates (#13010)

* adding workspace text boxes

* match new project dialog to mockups

* Add validation error message for workspace file

* add enterWorkspace api

* add warning message for opening workspace

* cleanup

* update commands to remove project so they're more generic

* remove 'empty' from string

* Move default project location setting to data workspace extension (#13022)

* remove project location setting and notification from sql database projects extension

* add default project location setting to data workspace extension

* fix typo

* Add back project name incrementing

* other merge fixes

* fix strings from other PR

* default to last opened directory instead of home directory if no specified default location

* A few small updates (#13092)

* fix build error

* update title for inputboxes

* add missing file

* Add tests for data workspace dialogs (#13324)

* add tests for dialogs

* create helper functions

* New project dialog workspace inputbox fixes (#13407)

* workspace inputbox fixes

* fix folder icons

* Update package.jsons and readme (#13451)

* update package.jsons

* update readme

* add workspace information to open existing dialog (#13455)

Co-authored-by: Alan Ren <alanren@microsoft.com>
This commit is contained in:
Kim Santiago
2020-11-18 16:13:43 -08:00
committed by GitHub
parent 34170e7741
commit ddc8c00090
63 changed files with 1835 additions and 931 deletions

View File

@@ -7,9 +7,48 @@ import { EOL } from 'os';
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
export const ExtensionActivationErrorMessage = (extensionId: string, err: any): string => { return localize('activateExtensionFailed', "Failed to load the project provider extension '{0}'. Error message: {1}", extensionId, err.message ?? err); };
export const UnknownProjectsErrorMessage = (projectFiles: string[]): string => { return localize('UnknownProjectsError', "No provider was found for the following projects: {0}", projectFiles.join(EOL)); };
export const ExtensionActivationError = (extensionId: string, err: any): string => { return localize('activateExtensionFailed', "Failed to load the project provider extension '{0}'. Error message: {1}", extensionId, err.message ?? err); };
export const UnknownProjectsError = (projectFiles: string[]): string => { return localize('UnknownProjectsError', "No provider was found for the following projects: {0}", projectFiles.join(EOL)); };
export const SelectProjectFileActionName = localize('SelectProjectFileActionName', "Select");
export const AllProjectTypes = localize('AllProjectTypes', "All Project Types");
export const ProviderNotFoundForProjectTypeError = (projectType: string): string => { return localize('UnknownProjectTypeError', "No provider was found for project type with id: '{0}'", projectType); };
export const WorkspaceRequiredMessage = localize('dataworkspace.workspaceRequiredMessage', "A workspace is required in order to use the project feature.");
export const OpenWorkspace = localize('dataworkspace.openWorkspace', "Open Workspace…");
export const CreateWorkspaceConfirmation = localize('dataworkspace.createWorkspaceConfirmation', "A new workspace will be created and opened in order to open project. The Extension Host will restart and if there is a folder currently open, it will be closed.");
export const EnterWorkspaceConfirmation = localize('dataworkspace.enterWorkspaceConfirmation', "To open this workspace, the Extension Host will restart and if there is a workspace or folder currently open, it will be closed.");
// UI
export const OkButtonText = localize('dataworkspace.ok', "OK");
export const CancelButtonText = localize('dataworkspace.cancel', "Cancel");
export const BrowseButtonText = localize('dataworkspace.browse', "Browse");
export const DefaultInputWidth = '400px';
export const DefaultButtonWidth = '80px';
// New Project Dialog
export const NewProjectDialogTitle = localize('dataworkspace.NewProjectDialogTitle', "Create new project");
export const TypeTitle = localize('dataworkspace.Type', "Type");
export const ProjectNameTitle = localize('dataworkspace.projectNameTitle', "Name");
export const ProjectNamePlaceholder = localize('dataworkspace.projectNamePlaceholder', "Enter project name");
export const ProjectLocationTitle = localize('dataworkspace.projectLocationTitle', "Location");
export const ProjectLocationPlaceholder = localize('dataworkspace.projectLocationPlaceholder', "Enter project location");
export const AddProjectToCurrentWorkspace = localize('dataworkspace.AddProjectToCurrentWorkspace', "This project will be added to the current workspace.");
export const NewWorkspaceWillBeCreated = localize('dataworkspace.NewWorkspaceWillBeCreated', "A new workspace will be created for this project.");
export const WorkspaceLocationTitle = localize('dataworkspace.workspaceLocationTitle', "Workspace location");
export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected location: '{0}' does not exist or is not a directory.", location); };
export const ProjectDirectoryAlreadyExistError = (projectName: string, location: string): string => { return localize('dataworkspace.projectDirectoryAlreadyExistError', "There is already a directory named '{0}' in the selected location: '{1}'.", projectName, location); };
//Open Existing Dialog
export const OpenExistingDialogTitle = localize('dataworkspace.openExistingDialogTitle', "Open existing");
export const ProjectFileNotExistError = (projectFilePath: string): string => { return localize('dataworkspace.projectFileNotExistError', "The selected project file '{0}' does not exist or is not a file.", projectFilePath); };
export const WorkspaceFileNotExistError = (workspaceFilePath: string): string => { return localize('dataworkspace.workspaceFileNotExistError', "The selected workspace file '{0}' does not exist or is not a file.", workspaceFilePath); };
export const Project = localize('dataworkspace.project', "Project");
export const Workspace = localize('dataworkspace.workspace', "Workspace");
export const LocationSelectorTitle = localize('dataworkspace.locationSelectorTitle', "Location");
export const ProjectFilePlaceholder = localize('dataworkspace.projectFilePlaceholder', "Enter project location");
export const WorkspacePlaceholder = localize('dataworkspace.workspacePlaceholder', "Enter workspace location");
export const WorkspaceFileExtension = 'code-workspace';
// Workspace settings for saving new projects
export const ProjectConfigurationKey = 'projects';
export const ProjectSaveLocationKey = 'defaultProjectSaveLocation';

View File

@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { IExtension } from 'dataworkspace';
import { WorkspaceService } from '../services/workspaceService';
import { defaultProjectSaveLocation } from './projectLocationHelper';
export class DataWorkspaceExtension implements IExtension {
constructor(private workspaceService: WorkspaceService) {
}
getProjectsInWorkspace(): vscode.Uri[] {
return this.workspaceService.getProjectsInWorkspace();
}
addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise<void> {
return this.workspaceService.addProjectsToWorkspace(projectFiles);
}
showProjectsView(): void {
vscode.commands.executeCommand('dataworkspace.views.main.focus');
}
get defaultProjectSaveLocation(): vscode.Uri | undefined {
return defaultProjectSaveLocation();
}
}

View File

@@ -0,0 +1,38 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export interface IconPath {
dark: string;
light: string;
}
export class IconPathHelper {
private static extensionContext: vscode.ExtensionContext;
public static folder: IconPath;
public static setExtensionContext(extensionContext: vscode.ExtensionContext) {
IconPathHelper.extensionContext = extensionContext;
IconPathHelper.folder = IconPathHelper.makeIcon('folder', true);
}
private static makeIcon(name: string, sameIcon: boolean = false) {
const folder = 'images';
if (sameIcon) {
return {
dark: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/${name}.svg`),
light: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/${name}.svg`)
};
} else {
return {
dark: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/dark/${name}.svg`),
light: IconPathHelper.extensionContext.asAbsolutePath(`${folder}/light/${name}.svg`)
};
}
}
}

View File

@@ -26,9 +26,15 @@ export interface IProjectProviderRegistry {
*/
readonly providers: IProjectProvider[];
/**
* Gets the project provider for the specified project extension
* @param extension The file extension of the project
*/
getProviderByProjectExtension(extension: string): IProjectProvider | undefined;
/**
* Gets the project provider for the specified project type
* @param projectType The project type, file extension of the project
* @param projectType The id of the project type
*/
getProviderByProjectType(projectType: string): IProjectProvider | undefined;
}
@@ -45,7 +51,7 @@ export interface IWorkspaceService {
/**
* Gets the project files in current workspace
*/
getProjectsInWorkspace(): Promise<vscode.Uri[]>;
getProjectsInWorkspace(): vscode.Uri[];
/**
* Gets the project provider by project file
@@ -65,8 +71,28 @@ export interface IWorkspaceService {
*/
removeProject(projectFile: vscode.Uri): Promise<void>;
/**
* Creates a new project from workspace
* @param name The name of the project
* @param location The location of the project
* @param projectTypeId The project type id
*/
createProject(name: string, location: vscode.Uri, projectTypeId: string): Promise<vscode.Uri>;
readonly isProjectProviderAvailable: boolean;
/**
* Event fires when projects in workspace changes
*/
readonly onDidWorkspaceProjectsChange: vscode.Event<void>;
/**
* Verify that a workspace is open or if one isn't, ask user to pick whether a workspace should be automatically created
*/
validateWorkspace(): Promise<boolean>;
/**
* Shows confirmation message that the extension host will be restarted and current workspace/file will be closed. If confirmed, the specified workspace will be entered.
*/
enterWorkspace(workspaceFile: vscode.Uri): Promise<void>;
}

View File

@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as constants from '../common/constants';
/**
* Returns the default location to save a new database project
*/
export function defaultProjectSaveLocation(): vscode.Uri | undefined {
return projectSaveLocationSettingIsValid() ? vscode.Uri.file(projectSaveLocationSetting()) : undefined;
}
/**
* Get workspace configurations for this extension
*/
function config(): vscode.WorkspaceConfiguration {
return vscode.workspace.getConfiguration(constants.ProjectConfigurationKey);
}
/**
* Returns the workspace setting on the default location to save new database projects
*/
function projectSaveLocationSetting(): string {
return config()[constants.ProjectSaveLocationKey];
}
/**
* Returns if the default save location for new database projects workspace setting exists and is
* a valid path
*/
function projectSaveLocationSettingIsValid(): boolean {
return projectSaveLocationSettingExists() && fs.existsSync(projectSaveLocationSetting());
}
/**
* Returns if a value for the default save location for new database projects exists
*/
function projectSaveLocationSettingExists(): boolean {
return projectSaveLocationSetting() !== undefined && projectSaveLocationSetting() !== null
&& projectSaveLocationSetting().trim() !== '';
}

View File

@@ -9,20 +9,24 @@ import { IProjectProviderRegistry } from './interfaces';
export const ProjectProviderRegistry: IProjectProviderRegistry = new class implements IProjectProviderRegistry {
private _providers = new Array<IProjectProvider>();
private _providerMapping: { [key: string]: IProjectProvider } = {};
private _providerFileExtensionMapping: { [key: string]: IProjectProvider } = {};
private _providerProjectTypeMapping: { [key: string]: IProjectProvider } = {};
registerProvider(provider: IProjectProvider): vscode.Disposable {
this.validateProvider(provider);
this._providers.push(provider);
provider.supportedProjectTypes.forEach(projectType => {
this._providerMapping[projectType.projectFileExtension.toUpperCase()] = provider;
this._providerFileExtensionMapping[projectType.projectFileExtension.toUpperCase()] = provider;
this._providerProjectTypeMapping[projectType.id.toUpperCase()] = provider;
});
return new vscode.Disposable(() => {
const idx = this._providers.indexOf(provider);
if (idx >= 0) {
this._providers.splice(idx, 1);
provider.supportedProjectTypes.forEach(projectType => {
delete this._providerMapping[projectType.projectFileExtension.toUpperCase()];
delete this._providerFileExtensionMapping[projectType.projectFileExtension.toUpperCase()];
delete this._providerProjectTypeMapping[projectType.id.toUpperCase()];
});
}
});
@@ -39,7 +43,11 @@ export const ProjectProviderRegistry: IProjectProviderRegistry = new class imple
validateProvider(provider: IProjectProvider): void {
}
getProviderByProjectExtension(extension: string): IProjectProvider | undefined {
return extension ? this._providerFileExtensionMapping[extension.toUpperCase()] : undefined;
}
getProviderByProjectType(projectType: string): IProjectProvider | undefined {
return projectType ? this._providerMapping[projectType.toUpperCase()] : undefined;
return projectType ? this._providerProjectTypeMapping[projectType.toUpperCase()] : undefined;
}
};

View File

@@ -0,0 +1,31 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
export async function directoryExist(directoryPath: string): Promise<boolean> {
const stats = await getFileStatus(directoryPath);
return stats ? stats.isDirectory() : false;
}
export async function fileExist(filePath: string): Promise<boolean> {
const stats = await getFileStatus(filePath);
return stats ? stats.isFile() : false;
}
async function getFileStatus(path: string): Promise<fs.Stats | undefined> {
try {
const stats = await fs.promises.stat(path);
return stats;
}
catch (e) {
if (e.code === 'ENOENT') {
return undefined;
}
else {
throw e;
}
}
}

View File

@@ -5,7 +5,7 @@
import * as vscode from 'vscode';
import { IWorkspaceService } from './interfaces';
import { UnknownProjectsErrorMessage } from './constants';
import { UnknownProjectsError } from './constants';
import { WorkspaceTreeItem } from 'dataworkspace';
/**
@@ -37,6 +37,7 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider<Worksp
else {
// if the element is undefined return the project tree items
const projects = await this._workspaceService.getProjectsInWorkspace();
await vscode.commands.executeCommand('setContext', 'isProjectsViewEmpty', projects.length === 0);
const unknownProjects: string[] = [];
const treeItems: WorkspaceTreeItem[] = [];
for (const project of projects) {
@@ -60,7 +61,7 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider<Worksp
});
}
if (unknownProjects.length > 0) {
vscode.window.showErrorMessage(UnknownProjectsErrorMessage(unknownProjects));
vscode.window.showErrorMessage(UnknownProjectsError(unknownProjects));
}
return treeItems;
}

View File

@@ -14,11 +14,25 @@ declare module 'dataworkspace' {
*/
export interface IExtension {
/**
* register a project provider
* @param provider new project provider
* @requires a disposable object, upon disposal, the provider will be unregistered.
* Returns all the projects in the workspace
*/
registerProjectProvider(provider: IProjectProvider): vscode.Disposable;
getProjectsInWorkspace(): vscode.Uri[];
/**
* Add projects to the workspace
* @param projectFiles Uris of project files to add
*/
addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise<void>
/**
* Change focus to Projects view
*/
showProjectsView(): void;
/**
* Returns the default location to save projects
*/
defaultProjectSaveLocation: vscode.Uri | undefined;
}
/**
@@ -37,6 +51,13 @@ declare module 'dataworkspace' {
*/
RemoveProject(projectFile: vscode.Uri): Promise<void>;
/**
*
* @param name Create a project
* @param location the parent directory of the project
*/
createProject(name: string, location: vscode.Uri): Promise<vscode.Uri>;
/**
* Gets the supported project types
*/
@@ -47,11 +68,21 @@ declare module 'dataworkspace' {
* Defines the project type
*/
export interface IProjectType {
/**
* id of the project type
*/
readonly id: string;
/**
* display name of the project type
*/
readonly displayName: string;
/**
* description of the project type
*/
readonly description: string;
/**
* project file extension, e.g. sqlproj
*/

View File

@@ -0,0 +1,125 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as path from 'path';
import * as constants from '../common/constants';
interface Deferred<T> {
resolve: (result: T | Promise<T>) => void;
reject: (reason: any) => void;
}
export abstract class DialogBase {
protected _toDispose: vscode.Disposable[] = [];
protected _dialogObject: azdata.window.Dialog;
protected initDialogComplete: Deferred<void> | undefined;
protected initDialogPromise: Promise<void> = new Promise<void>((resolve, reject) => this.initDialogComplete = { resolve, reject });
protected workspaceFormComponent: azdata.FormComponent | undefined;
protected workspaceInputBox: azdata.InputBoxComponent | undefined;
constructor(dialogTitle: string, dialogName: string, dialogWidth: azdata.window.DialogWidth = 600) {
this._dialogObject = azdata.window.createModelViewDialog(dialogTitle, dialogName, dialogWidth);
this._dialogObject.okButton.label = constants.OkButtonText;
this.register(this._dialogObject.cancelButton.onClick(() => this.onCancelButtonClicked()));
this.register(this._dialogObject.okButton.onClick(() => this.onOkButtonClicked()));
this._dialogObject.registerCloseValidator(async () => {
return this.validate();
});
}
protected abstract initialize(view: azdata.ModelView): Promise<void>;
protected async validate(): Promise<boolean> {
return Promise.resolve(true);
}
public async open(): Promise<void> {
const tab = azdata.window.createTab('');
tab.registerContent(async (view: azdata.ModelView) => {
return this.initialize(view);
});
this._dialogObject.content = [tab];
azdata.window.openDialog(this._dialogObject);
await this.initDialogPromise;
}
private onCancelButtonClicked(): void {
this.dispose();
}
private async onOkButtonClicked(): Promise<void> {
await this.onComplete();
this.dispose();
}
protected async onComplete(): Promise<void> {
}
protected dispose(): void {
this._toDispose.forEach(disposable => disposable.dispose());
}
protected register(disposable: vscode.Disposable): void {
this._toDispose.push(disposable);
}
protected showErrorMessage(message: string): void {
this._dialogObject.message = {
text: message,
level: azdata.window.MessageLevel.Error
};
}
protected createHorizontalContainer(view: azdata.ModelView, items: azdata.Component[]): azdata.FlexContainer {
return view.modelBuilder.flexContainer().withItems(items, { CSSStyles: { 'margin-right': '5px', 'margin-bottom': '10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component();
}
/**
* Creates container with information on which workspace the project will be added to and where the workspace will be
* created if no workspace is currently open
* @param view
*/
protected createWorkspaceContainer(view: azdata.ModelView): azdata.FormComponent {
const workspaceDescription = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
value: vscode.workspace.workspaceFile ? constants.AddProjectToCurrentWorkspace : constants.NewWorkspaceWillBeCreated,
CSSStyles: { 'margin-top': '3px', 'margin-bottom': '10px' }
}).component();
this.workspaceInputBox = view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
ariaLabel: constants.WorkspaceLocationTitle,
width: constants.DefaultInputWidth,
enabled: false,
value: vscode.workspace.workspaceFile?.fsPath ?? '',
title: vscode.workspace.workspaceFile?.fsPath ?? '' // hovertext for if file path is too long to be seen in textbox
}).component();
const container = view.modelBuilder.flexContainer()
.withItems([workspaceDescription, this.workspaceInputBox])
.withLayout({ flexFlow: 'column' })
.component();
this.workspaceFormComponent = {
title: constants.Workspace,
component: container
};
return this.workspaceFormComponent;
}
/**
* Update the workspace inputbox based on the passed in location and name if there isn't a workspace currently open
* @param location
* @param name
*/
protected updateWorkspaceInputbox(location: string, name: string): void {
if (!vscode.workspace.workspaceFile) {
const fileLocation = location && name ? path.join(location, `${name}.code-workspace`) : '';
this.workspaceInputBox!.value = fileLocation;
this.workspaceInputBox!.title = fileLocation;
}
}
}

View File

@@ -0,0 +1,171 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as path from 'path';
import { DialogBase } from './dialogBase';
import { IWorkspaceService } from '../common/interfaces';
import * as constants from '../common/constants';
import { IProjectType } from 'dataworkspace';
import { directoryExist } from '../common/utils';
import { IconPathHelper } from '../common/iconHelper';
import { defaultProjectSaveLocation } from '../common/projectLocationHelper';
class NewProjectDialogModel {
projectTypeId: string = '';
projectFileExtension: string = '';
name: string = '';
location: string = '';
}
export class NewProjectDialog extends DialogBase {
public model: NewProjectDialogModel = new NewProjectDialogModel();
constructor(private workspaceService: IWorkspaceService) {
super(constants.NewProjectDialogTitle, 'NewProject');
}
async validate(): Promise<boolean> {
try {
// the selected location should be an existing directory
const parentDirectoryExists = await directoryExist(this.model.location);
if (!parentDirectoryExists) {
this.showErrorMessage(constants.ProjectParentDirectoryNotExistError(this.model.location));
return false;
}
// there shouldn't be an existing sub directory with the same name as the project in the selected location
const projectDirectoryExists = await directoryExist(path.join(this.model.location, this.model.name));
if (projectDirectoryExists) {
this.showErrorMessage(constants.ProjectDirectoryAlreadyExistError(this.model.name, this.model.location));
return false;
}
return true;
}
catch (err) {
this.showErrorMessage(err?.message ? err.message : err);
return false;
}
}
async onComplete(): Promise<void> {
try {
const validateWorkspace = await this.workspaceService.validateWorkspace();
if (validateWorkspace) {
await this.workspaceService.createProject(this.model.name, vscode.Uri.file(this.model.location), this.model.projectTypeId);
}
}
catch (err) {
vscode.window.showErrorMessage(err?.message ? err.message : err);
}
}
protected async initialize(view: azdata.ModelView): Promise<void> {
const allProjectTypes = await this.workspaceService.getAllProjectTypes();
const projectTypeRadioCardGroup = view.modelBuilder.radioCardGroup().withProperties<azdata.RadioCardGroupComponentProperties>({
cards: allProjectTypes.map((projectType: IProjectType) => {
return <azdata.RadioCard>{
id: projectType.id,
label: projectType.displayName,
icon: projectType.icon,
descriptions: [
{
textValue: projectType.displayName,
textStyles: {
'font-size': '13px',
'font-weight': 'bold'
}
}, {
textValue: projectType.description
}
]
};
}),
iconHeight: '50px',
iconWidth: '50px',
cardWidth: '170px',
cardHeight: '170px',
ariaLabel: constants.TypeTitle,
width: '500px',
iconPosition: 'top',
selectedCardId: allProjectTypes.length > 0 ? allProjectTypes[0].id : undefined
}).component();
this.register(projectTypeRadioCardGroup.onSelectionChanged((e) => {
this.model.projectTypeId = e.cardId;
}));
const projectNameTextBox = view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
ariaLabel: constants.ProjectNameTitle,
placeHolder: constants.ProjectNamePlaceholder,
required: true,
width: constants.DefaultInputWidth
}).component();
this.register(projectNameTextBox.onTextChanged(() => {
this.model.name = projectNameTextBox.value!;
projectNameTextBox.updateProperty('title', projectNameTextBox.value);
this.updateWorkspaceInputbox(this.model.location, this.model.name);
}));
const locationTextBox = view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
ariaLabel: constants.ProjectLocationTitle,
placeHolder: constants.ProjectLocationPlaceholder,
required: true,
width: constants.DefaultInputWidth
}).component();
this.register(locationTextBox.onTextChanged(() => {
this.model.location = locationTextBox.value!;
locationTextBox.updateProperty('title', locationTextBox.value);
this.updateWorkspaceInputbox(this.model.location, this.model.name);
}));
const browseFolderButton = view.modelBuilder.button().withProperties<azdata.ButtonProperties>({
ariaLabel: constants.BrowseButtonText,
iconPath: IconPathHelper.folder,
height: '16px',
width: '18px'
}).component();
this.register(browseFolderButton.onDidClick(async () => {
let folderUris = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
defaultUri: defaultProjectSaveLocation()
});
if (!folderUris || folderUris.length === 0) {
return;
}
const selectedFolder = folderUris[0].fsPath;
locationTextBox.value = selectedFolder;
this.model.location = selectedFolder;
this.updateWorkspaceInputbox(this.model.location, this.model.name);
}));
const form = view.modelBuilder.formContainer().withFormItems([
{
title: constants.TypeTitle,
required: true,
component: projectTypeRadioCardGroup
},
{
title: constants.ProjectNameTitle,
required: true,
component: this.createHorizontalContainer(view, [projectNameTextBox])
}, {
title: constants.ProjectLocationTitle,
required: true,
component: this.createHorizontalContainer(view, [locationTextBox, browseFolderButton])
},
this.createWorkspaceContainer(view)
]).component();
await view.initializeModel(form);
this.initDialogComplete?.resolve();
}
}

View File

@@ -0,0 +1,208 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as path from 'path';
import { DialogBase } from './dialogBase';
import * as constants from '../common/constants';
import { IWorkspaceService } from '../common/interfaces';
import { fileExist } from '../common/utils';
import { IconPathHelper } from '../common/iconHelper';
export class OpenExistingDialog extends DialogBase {
public _projectFile: string = '';
public _workspaceFile: string = '';
public _targetTypeRadioCardGroup: azdata.RadioCardGroupComponent | undefined;
public _filePathTextBox: azdata.InputBoxComponent | undefined;
public formBuilder: azdata.FormBuilder | undefined;
private _targetTypes = [
{
name: constants.Project,
icon: {
dark: this.extensionContext.asAbsolutePath('images/file_inverse.svg'),
light: this.extensionContext.asAbsolutePath('images/file.svg')
}
}, {
name: constants.Workspace,
icon: {
dark: this.extensionContext.asAbsolutePath('images/file_inverse.svg'), // temporary - still waiting for real icon from UX
light: this.extensionContext.asAbsolutePath('images/file.svg')
}
}
];
constructor(private workspaceService: IWorkspaceService, private extensionContext: vscode.ExtensionContext) {
super(constants.OpenExistingDialogTitle, 'OpenProject');
}
async validate(): Promise<boolean> {
try {
// the selected location should be an existing directory
if (this._targetTypeRadioCardGroup?.selectedCardId === constants.Project) {
const fileExists = await fileExist(this._projectFile);
if (!fileExists) {
this.showErrorMessage(constants.ProjectFileNotExistError(this._projectFile));
return false;
}
} else if (this._targetTypeRadioCardGroup?.selectedCardId === constants.Workspace) {
const fileExists = await fileExist(this._workspaceFile);
if (!fileExists) {
this.showErrorMessage(constants.WorkspaceFileNotExistError(this._workspaceFile));
return false;
}
}
return true;
}
catch (err) {
this.showErrorMessage(err?.message ? err.message : err);
return false;
}
}
async onComplete(): Promise<void> {
try {
if (this._targetTypeRadioCardGroup?.selectedCardId === constants.Workspace) {
await this.workspaceService.enterWorkspace(vscode.Uri.file(this._workspaceFile));
} else {
const validateWorkspace = await this.workspaceService.validateWorkspace();
if (validateWorkspace) {
await this.workspaceService.addProjectsToWorkspace([vscode.Uri.file(this._projectFile)]);
}
}
}
catch (err) {
vscode.window.showErrorMessage(err?.message ? err.message : err);
}
}
protected async initialize(view: azdata.ModelView): Promise<void> {
this._targetTypeRadioCardGroup = view.modelBuilder.radioCardGroup().withProperties<azdata.RadioCardGroupComponentProperties>({
cards: this._targetTypes.map((targetType) => {
return <azdata.RadioCard>{
id: targetType.name,
label: targetType.name,
icon: targetType.icon,
descriptions: [
{
textValue: targetType.name,
textStyles: {
'font-size': '13px'
}
}
]
};
}),
iconHeight: '50px',
iconWidth: '50px',
cardWidth: '170px',
cardHeight: '170px',
ariaLabel: constants.TypeTitle,
width: '500px',
iconPosition: 'top',
selectedCardId: constants.Project
}).component();
this._filePathTextBox = view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
ariaLabel: constants.LocationSelectorTitle,
placeHolder: constants.ProjectFilePlaceholder,
required: true,
width: constants.DefaultInputWidth
}).component();
this.register(this._filePathTextBox.onTextChanged(() => {
this._projectFile = this._filePathTextBox!.value!;
this._filePathTextBox!.updateProperty('title', this._projectFile);
this.updateWorkspaceInputbox(path.dirname(this._projectFile), path.basename(this._projectFile, path.extname(this._projectFile)));
}));
const browseFolderButton = view.modelBuilder.button().withProperties<azdata.ButtonProperties>({
ariaLabel: constants.BrowseButtonText,
iconPath: IconPathHelper.folder,
width: '18px',
height: '16px',
}).component();
this.register(browseFolderButton.onDidClick(async () => {
if (this._targetTypeRadioCardGroup?.selectedCardId === constants.Project) {
await this.projectBrowse();
} else if (this._targetTypeRadioCardGroup?.selectedCardId === constants.Workspace) {
await this.workspaceBrowse();
}
}));
this.register(this._targetTypeRadioCardGroup.onSelectionChanged(({ cardId }) => {
if (cardId === constants.Project) {
this._filePathTextBox!.placeHolder = constants.ProjectFilePlaceholder;
this.formBuilder?.addFormItem(this.workspaceFormComponent!);
} else if (cardId === constants.Workspace) {
this._filePathTextBox!.placeHolder = constants.WorkspacePlaceholder;
this.formBuilder?.removeFormItem(this.workspaceFormComponent!);
}
// clear selected file textbox
this._filePathTextBox!.value = '';
}));
this.formBuilder = view.modelBuilder.formContainer().withFormItems([
{
title: constants.TypeTitle,
required: true,
component: this._targetTypeRadioCardGroup,
}, {
title: constants.LocationSelectorTitle,
required: true,
component: this.createHorizontalContainer(view, [this._filePathTextBox, browseFolderButton])
},
this.createWorkspaceContainer(view)
]);
await view.initializeModel(this.formBuilder?.component());
this.initDialogComplete?.resolve();
}
public async workspaceBrowse(): Promise<void> {
const filters: { [name: string]: string[] } = { [constants.Workspace]: [constants.WorkspaceFileExtension] };
const fileUris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: constants.SelectProjectFileActionName,
filters: filters
});
if (!fileUris || fileUris.length === 0) {
return;
}
const workspaceFilePath = fileUris[0].fsPath;
this._filePathTextBox!.value = workspaceFilePath;
this._workspaceFile = workspaceFilePath;
}
public async projectBrowse(): Promise<void> {
const filters: { [name: string]: string[] } = {};
const projectTypes = await this.workspaceService.getAllProjectTypes();
filters[constants.AllProjectTypes] = projectTypes.map(type => type.projectFileExtension);
projectTypes.forEach(type => {
filters[type.displayName] = [type.projectFileExtension];
});
const fileUris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: constants.SelectProjectFileActionName,
filters: filters
});
if (!fileUris || fileUris.length === 0) {
return;
}
const projectFilePath = fileUris[0].fsPath;
this._filePathTextBox!.value = projectFilePath;
this._projectFile = projectFilePath;
}
}

View File

@@ -4,48 +4,48 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as path from 'path';
import { WorkspaceTreeDataProvider } from './common/workspaceTreeDataProvider';
import { WorkspaceService } from './services/workspaceService';
import { AllProjectTypes, SelectProjectFileActionName } from './common/constants';
import { WorkspaceTreeItem } from 'dataworkspace';
import { WorkspaceTreeItem, IExtension } from 'dataworkspace';
import { DataWorkspaceExtension } from './common/dataWorkspaceExtension';
import { NewProjectDialog } from './dialogs/newProjectDialog';
import { OpenExistingDialog } from './dialogs/openExistingDialog';
import { IWorkspaceService } from './common/interfaces';
import { IconPathHelper } from './common/iconHelper';
export function activate(context: vscode.ExtensionContext): void {
const workspaceService = new WorkspaceService();
export function activate(context: vscode.ExtensionContext): Promise<IExtension> {
const workspaceService = new WorkspaceService(context);
workspaceService.loadTempProjects();
const workspaceTreeDataProvider = new WorkspaceTreeDataProvider(workspaceService);
const dataWorkspaceExtension = new DataWorkspaceExtension(workspaceService);
context.subscriptions.push(vscode.window.registerTreeDataProvider('dataworkspace.views.main', workspaceTreeDataProvider));
context.subscriptions.push(vscode.commands.registerCommand('projects.addProject', async () => {
// To Sakshi - You can replace the implementation with your complete dialog implementation
// but all the code here should be reusable by you
if (vscode.workspace.workspaceFile) {
const filters: { [name: string]: string[] } = {};
const projectTypes = await workspaceService.getAllProjectTypes();
filters[AllProjectTypes] = projectTypes.map(type => type.projectFileExtension);
projectTypes.forEach(type => {
filters[type.displayName] = [type.projectFileExtension];
});
let fileUris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
defaultUri: vscode.Uri.file(path.dirname(vscode.workspace.workspaceFile.path)),
openLabel: SelectProjectFileActionName,
filters: filters
});
if (!fileUris || fileUris.length === 0) {
return;
}
await workspaceService.addProjectsToWorkspace(fileUris);
}
context.subscriptions.push(vscode.extensions.onDidChange(() => {
setProjectProviderContextValue(workspaceService);
}));
setProjectProviderContextValue(workspaceService);
context.subscriptions.push(vscode.commands.registerCommand('projects.new', async () => {
const dialog = new NewProjectDialog(workspaceService);
await dialog.open();
}));
context.subscriptions.push(vscode.commands.registerCommand('projects.openExisting', async () => {
const dialog = new OpenExistingDialog(workspaceService, context);
await dialog.open();
}));
context.subscriptions.push(vscode.commands.registerCommand('dataworkspace.refresh', () => {
workspaceTreeDataProvider.refresh();
}));
context.subscriptions.push(vscode.commands.registerCommand('projects.removeProject', async (treeItem: WorkspaceTreeItem) => {
await workspaceService.removeProject(vscode.Uri.file(treeItem.element.project.projectFilePath));
}));
IconPathHelper.setExtensionContext(context);
return Promise.resolve(dataWorkspaceExtension);
}
function setProjectProviderContextValue(workspaceService: IWorkspaceService): void {
vscode.commands.executeCommand('setContext', 'isProjectProviderAvailable', workspaceService.isProjectProviderAvailable);
}
export function deactivate(): void {

View File

@@ -3,50 +3,137 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as dataworkspace from 'dataworkspace';
import * as path from 'path';
import * as constants from '../common/constants';
import { IWorkspaceService } from '../common/interfaces';
import { ProjectProviderRegistry } from '../common/projectProviderRegistry';
import Logger from '../common/logger';
import { ExtensionActivationErrorMessage } from '../common/constants';
const WorkspaceConfigurationName = 'dataworkspace';
const ProjectsConfigurationName = 'projects';
const TempProject = 'tempProject';
export class WorkspaceService implements IWorkspaceService {
private _onDidWorkspaceProjectsChange: vscode.EventEmitter<void> = new vscode.EventEmitter<void>();
readonly onDidWorkspaceProjectsChange: vscode.Event<void> = this._onDidWorkspaceProjectsChange?.event;
async addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise<void> {
if (vscode.workspace.workspaceFile) {
const currentProjects: vscode.Uri[] = await this.getProjectsInWorkspace();
const newWorkspaceFolders: string[] = [];
let newProjectFileAdded = false;
for (const projectFile of projectFiles) {
if (currentProjects.findIndex((p: vscode.Uri) => p.fsPath === projectFile.fsPath) === -1) {
currentProjects.push(projectFile);
newProjectFileAdded = true;
constructor(private _context: vscode.ExtensionContext) {
}
// if the relativePath and the original path is the same, that means the project file is not under
// any workspace folders, we should add the parent folder of the project file to the workspace
const relativePath = vscode.workspace.asRelativePath(projectFile, false);
if (vscode.Uri.file(relativePath).fsPath === projectFile.fsPath) {
newWorkspaceFolders.push(path.dirname(projectFile.path));
}
/**
* Load any temp project that needed to be loaded before the extension host was restarted
* which would happen if a workspace was created in order open or create a project
*/
async loadTempProjects(): Promise<void> {
const tempProjects: string[] | undefined = this._context.globalState.get(TempProject) ?? undefined;
if (tempProjects && vscode.workspace.workspaceFile) {
// add project to workspace now that the workspace has been created and saved
for (let project of tempProjects) {
await this.addProjectsToWorkspace([vscode.Uri.file(<string>project)]);
}
await this._context.globalState.update(TempProject, undefined);
}
}
/**
* Creates a new workspace in the same folder as the project. Because the extension host gets restared when
* a new workspace is created and opened, the project needs to be saved as the temp project that will be loaded
* when the extension gets restarted
* @param projectFileFsPath project to add to the workspace
*/
async CreateNewWorkspaceForProject(projectFileFsPath: string): Promise<void> {
// save temp project
await this._context.globalState.update(TempProject, [projectFileFsPath]);
// create a new workspace - the workspace file will be created in the same folder as the project
const workspaceFile = vscode.Uri.file(path.join(path.dirname(projectFileFsPath), `${path.parse(projectFileFsPath).name}.code-workspace`));
const projectFolder = vscode.Uri.file(path.dirname(projectFileFsPath));
await azdata.workspace.createWorkspace(projectFolder, workspaceFile);
}
get isProjectProviderAvailable(): boolean {
for (const extension of vscode.extensions.all) {
const projectTypes = extension.packageJSON.contributes && extension.packageJSON.contributes.projects as string[];
if (projectTypes && projectTypes.length > 0) {
return true;
}
}
return false;
}
/**
* Verify that a workspace is open or that if one isn't, it's ok to create a workspace
*/
async validateWorkspace(): Promise<boolean> {
if (!vscode.workspace.workspaceFile) {
const result = await vscode.window.showWarningMessage(constants.CreateWorkspaceConfirmation, constants.OkButtonText, constants.CancelButtonText);
if (result === constants.OkButtonText) {
return true;
} else {
return false;
}
} else {
// workspace is open
return true;
}
}
/**
* Shows confirmation message that the extension host will be restarted and current workspace/file will be closed. If confirmed, the specified workspace will be entered.
* @param workspaceFile
*/
async enterWorkspace(workspaceFile: vscode.Uri): Promise<void> {
const result = await vscode.window.showWarningMessage(constants.EnterWorkspaceConfirmation, constants.OkButtonText, constants.CancelButtonText);
if (result === constants.OkButtonText) {
await azdata.workspace.enterWorkspace(workspaceFile);
} else {
return;
}
}
async addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise<void> {
if (!projectFiles || projectFiles.length === 0) {
return;
}
// a workspace needs to be open to add projects
if (!vscode.workspace.workspaceFile) {
await this.CreateNewWorkspaceForProject(projectFiles[0].fsPath);
// this won't get hit since the extension host will get restarted, but helps with testing
return;
}
const currentProjects: vscode.Uri[] = this.getProjectsInWorkspace();
const newWorkspaceFolders: string[] = [];
let newProjectFileAdded = false;
for (const projectFile of projectFiles) {
if (currentProjects.findIndex((p: vscode.Uri) => p.fsPath === projectFile.fsPath) === -1) {
currentProjects.push(projectFile);
newProjectFileAdded = true;
// if the relativePath and the original path is the same, that means the project file is not under
// any workspace folders, we should add the parent folder of the project file to the workspace
const relativePath = vscode.workspace.asRelativePath(projectFile, false);
if (vscode.Uri.file(relativePath).fsPath === projectFile.fsPath) {
newWorkspaceFolders.push(path.dirname(projectFile.path));
}
}
}
if (newProjectFileAdded) {
// Save the new set of projects to the workspace configuration.
await this.setWorkspaceConfigurationValue(ProjectsConfigurationName, currentProjects.map(project => this.toRelativePath(project)));
this._onDidWorkspaceProjectsChange.fire();
}
if (newProjectFileAdded) {
// Save the new set of projects to the workspace configuration.
await this.setWorkspaceConfigurationValue(ProjectsConfigurationName, currentProjects.map(project => this.toRelativePath(project)));
this._onDidWorkspaceProjectsChange.fire();
}
if (newWorkspaceFolders.length > 0) {
// second parameter is null means don't remove any workspace folders
vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders!.length, null, ...(newWorkspaceFolders.map(folder => ({ uri: vscode.Uri.file(folder) }))));
}
if (newWorkspaceFolders.length > 0) {
// second parameter is null means don't remove any workspace folders
vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders!.length, null, ...(newWorkspaceFolders.map(folder => ({ uri: vscode.Uri.file(folder) }))));
}
}
@@ -59,22 +146,22 @@ export class WorkspaceService implements IWorkspaceService {
return projectTypes;
}
async getProjectsInWorkspace(): Promise<vscode.Uri[]> {
getProjectsInWorkspace(): vscode.Uri[] {
return vscode.workspace.workspaceFile ? this.getWorkspaceConfigurationValue<string[]>(ProjectsConfigurationName).map(project => this.toUri(project)) : [];
}
async getProjectProvider(projectFile: vscode.Uri): Promise<dataworkspace.IProjectProvider | undefined> {
const projectType = path.extname(projectFile.path).replace(/\./g, '');
let provider = ProjectProviderRegistry.getProviderByProjectType(projectType);
let provider = ProjectProviderRegistry.getProviderByProjectExtension(projectType);
if (!provider) {
await this.ensureProviderExtensionLoaded(projectType);
}
return ProjectProviderRegistry.getProviderByProjectType(projectType);
return ProjectProviderRegistry.getProviderByProjectExtension(projectType);
}
async removeProject(projectFile: vscode.Uri): Promise<void> {
if (vscode.workspace.workspaceFile) {
const currentProjects: vscode.Uri[] = await this.getProjectsInWorkspace();
const currentProjects: vscode.Uri[] = this.getProjectsInWorkspace();
const projectIdx = currentProjects.findIndex((p: vscode.Uri) => p.fsPath === projectFile.fsPath);
if (projectIdx !== -1) {
currentProjects.splice(projectIdx, 1);
@@ -84,6 +171,18 @@ export class WorkspaceService implements IWorkspaceService {
}
}
async createProject(name: string, location: vscode.Uri, projectTypeId: string): Promise<vscode.Uri> {
const provider = ProjectProviderRegistry.getProviderByProjectType(projectTypeId);
if (provider) {
const projectFile = await provider.createProject(name, location);
this.addProjectsToWorkspace([projectFile]);
this._onDidWorkspaceProjectsChange.fire();
return projectFile;
} else {
throw new Error(constants.ProviderNotFoundForProjectTypeError(projectTypeId));
}
}
/**
* Ensure the project provider extension for the specified project is loaded
* @param projectType The file extension of the project, if not specified, all project provider extensions will be loaded.
@@ -113,7 +212,7 @@ export class WorkspaceService implements IWorkspaceService {
await extension.activate();
}
} catch (err) {
Logger.error(ExtensionActivationErrorMessage(extension.id, err));
Logger.error(constants.ExtensionActivationError(extension.id, err));
}
if (extension.isActive && extension.exports && !ProjectProviderRegistry.providers.includes(extension.exports)) {

View File

@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as TypeMoq from 'typemoq';
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import * as sinon from 'sinon';
import { promises as fs } from 'fs';
import { NewProjectDialog } from '../../dialogs/newProjectDialog';
import { WorkspaceService } from '../../services/workspaceService';
import { testProjectType } from '../testUtils';
suite('New Project Dialog', function (): void {
test('Should validate project location', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
workspaceServiceMock.setup(x => x.getAllProjectTypes()).returns(() => Promise.resolve([testProjectType]));
const dialog = new NewProjectDialog(workspaceServiceMock.object);
await dialog.open();
dialog.model.name = 'TestProject';
dialog.model.location = '';
should.equal(await dialog.validate(), false, 'Validation should fail becausee the parent directory does not exist');
// create a folder with the same name
const folderPath = path.join(os.tmpdir(), dialog.model.name);
await fs.mkdir(folderPath, { recursive: true });
dialog.model.location = os.tmpdir();
should.equal(await dialog.validate(), false, 'Validation should fail because a folder with the same name exists');
// change project name to be unique
dialog.model.name = `TestProject_${new Date().getTime()}`;
should.equal(await dialog.validate(), true, 'Validation should pass because name is unique and parent directory exists');
});
test('Should validate workspace in onComplete', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
workspaceServiceMock.setup(x => x.validateWorkspace()).returns(() => Promise.resolve(true));
workspaceServiceMock.setup(x => x.getAllProjectTypes()).returns(() => Promise.resolve([testProjectType]));
const dialog = new NewProjectDialog(workspaceServiceMock.object);
await dialog.open();
dialog.model.name = 'TestProject';
dialog.model.location = '';
should.doesNotThrow(async () => await dialog.onComplete());
workspaceServiceMock.setup(x => x.validateWorkspace()).throws(new Error('test error'));
const spy = sinon.spy(vscode.window, 'showErrorMessage');
should.doesNotThrow(async () => await dialog.onComplete(), 'Error should be caught');
should(spy.calledOnce).be.true();
});
});

View File

@@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as TypeMoq from 'typemoq';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
import * as constants from '../../common/constants';
import { promises as fs } from 'fs';
import { WorkspaceService } from '../../services/workspaceService';
import { OpenExistingDialog } from '../../dialogs/openExistingDialog';
import { createProjectFile, generateUniqueProjectFilePath, generateUniqueWorkspaceFilePath, testProjectType } from '../testUtils';
suite('Open Existing Dialog', function (): void {
const mockExtensionContext = TypeMoq.Mock.ofType<vscode.ExtensionContext>();
this.afterEach(() => {
sinon.restore();
});
test('Should validate project file exists', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
const dialog = new OpenExistingDialog(workspaceServiceMock.object, mockExtensionContext.object);
await dialog.open();
dialog._targetTypeRadioCardGroup?.updateProperty( 'selectedCardId', constants.Project);
dialog._projectFile = '';
should.equal(await dialog.validate(), false, 'Validation fail because project file does not exist');
// create a project file
dialog._projectFile = await createProjectFile('testproj');
should.equal(await dialog.validate(), true, 'Validation pass because project file exists');
});
test('Should validate workspace file exists', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
const dialog = new OpenExistingDialog(workspaceServiceMock.object, mockExtensionContext.object);
await dialog.open();
dialog._targetTypeRadioCardGroup?.updateProperty( 'selectedCardId', constants.Workspace);
dialog._workspaceFile = '';
should.equal(await dialog.validate(), false, 'Validation fail because workspace file does not exist');
// create a workspace file
dialog._workspaceFile = generateUniqueWorkspaceFilePath();
await fs.writeFile(dialog._workspaceFile, '');
should.equal(await dialog.validate(), true, 'Validation pass because workspace file exists');
});
test('Should validate workspace in onComplete when opening project', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
workspaceServiceMock.setup(x => x.validateWorkspace()).returns(() => Promise.resolve(true));
workspaceServiceMock.setup(x => x.addProjectsToWorkspace(TypeMoq.It.isAny())).returns(() => Promise.resolve());
const dialog = new OpenExistingDialog(workspaceServiceMock.object, mockExtensionContext.object);
await dialog.open();
dialog._projectFile = generateUniqueProjectFilePath('testproj');
should.doesNotThrow(async () => await dialog.onComplete());
workspaceServiceMock.setup(x => x.validateWorkspace()).throws(new Error('test error'));
const spy = sinon.spy(vscode.window, 'showErrorMessage');
should.doesNotThrow(async () => await dialog.onComplete(), 'Error should be caught');
should(spy.calledOnce).be.true();
});
test('workspace browse', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
sinon.stub(vscode.window, 'showOpenDialog').returns(Promise.resolve([]));
const dialog = new OpenExistingDialog(workspaceServiceMock.object, mockExtensionContext.object);
await dialog.open();
should.equal(dialog._workspaceFile, '');
await dialog.workspaceBrowse();
should.equal(dialog._workspaceFile, '', 'Workspace file should not be set when no file is selected');
sinon.restore();
const workspaceFile = vscode.Uri.file(generateUniqueWorkspaceFilePath());
sinon.stub(vscode.window, 'showOpenDialog').returns(Promise.resolve([workspaceFile]));
await dialog.workspaceBrowse();
should.equal(dialog._workspaceFile, workspaceFile.fsPath, 'Workspace file should get set');
should.equal(dialog._filePathTextBox?.value, workspaceFile.fsPath);
});
test('project browse', async function (): Promise<void> {
const workspaceServiceMock = TypeMoq.Mock.ofType<WorkspaceService>();
workspaceServiceMock.setup(x => x.getAllProjectTypes()).returns(() => Promise.resolve([testProjectType]));
sinon.stub(vscode.window, 'showOpenDialog').returns(Promise.resolve([]));
const dialog = new OpenExistingDialog(workspaceServiceMock.object, mockExtensionContext.object);
await dialog.open();
should.equal(dialog._projectFile, '');
await dialog.projectBrowse();
should.equal(dialog._projectFile, '', 'Project file should not be set when no file is selected');
sinon.restore();
const projectFile = vscode.Uri.file(generateUniqueProjectFilePath('testproj'));
sinon.stub(vscode.window, 'showOpenDialog').returns(Promise.resolve([projectFile]));
await dialog.projectBrowse();
should.equal(dialog._projectFile, projectFile.fsPath, 'Project file should be set');
should.equal(dialog._filePathTextBox?.value, projectFile.fsPath);
});
});

View File

@@ -28,6 +28,9 @@ export function createProjectProvider(projectTypes: IProjectType[]): IProjectPro
},
getProjectTreeDataProvider: (projectFile: vscode.Uri): Promise<vscode.TreeDataProvider<any>> => {
return Promise.resolve(treeDataProvider);
},
createProject: (name: string, location: vscode.Uri): Promise<vscode.Uri> => {
return Promise.resolve(location);
}
};
return projectProvider;
@@ -37,51 +40,57 @@ suite('ProjectProviderRegistry Tests', function (): void {
test('register and unregister project providers', async () => {
const provider1 = createProjectProvider([
{
id: 'tp1',
projectFileExtension: 'testproj',
icon: '',
displayName: 'test project'
displayName: 'test project',
description: ''
}, {
id: 'tp2',
projectFileExtension: 'testproj1',
icon: '',
displayName: 'test project 1'
displayName: 'test project 1',
description: ''
}
]);
const provider2 = createProjectProvider([
{
id: 'sp1',
projectFileExtension: 'sqlproj',
icon: '',
displayName: 'sql project'
displayName: 'sql project',
description: ''
}
]);
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test');
const disposable1 = ProjectProviderRegistry.registerProvider(provider1);
let providerResult = ProjectProviderRegistry.getProviderByProjectType('testproj');
let providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj');
should.equal(providerResult, provider1, 'provider1 should be returned for testproj project type');
// make sure the project type is case-insensitive for getProviderByProjectType method
providerResult = ProjectProviderRegistry.getProviderByProjectType('TeStProJ');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('TeStProJ');
should.equal(providerResult, provider1, 'provider1 should be returned for testproj project type');
providerResult = ProjectProviderRegistry.getProviderByProjectType('testproj1');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj1');
should.equal(providerResult, provider1, 'provider1 should be returned for testproj1 project type');
should.strictEqual(ProjectProviderRegistry.providers.length, 1, 'there should be only one project provider at this time');
const disposable2 = ProjectProviderRegistry.registerProvider(provider2);
providerResult = ProjectProviderRegistry.getProviderByProjectType('sqlproj');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('sqlproj');
should.equal(providerResult, provider2, 'provider2 should be returned for sqlproj project type');
should.strictEqual(ProjectProviderRegistry.providers.length, 2, 'there should be 2 project providers at this time');
// unregister provider1
disposable1.dispose();
providerResult = ProjectProviderRegistry.getProviderByProjectType('testproj');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj');
should.equal(providerResult, undefined, 'undefined should be returned for testproj project type');
providerResult = ProjectProviderRegistry.getProviderByProjectType('testproj1');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj1');
should.equal(providerResult, undefined, 'undefined should be returned for testproj1 project type');
providerResult = ProjectProviderRegistry.getProviderByProjectType('sqlproj');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('sqlproj');
should.equal(providerResult, provider2, 'provider2 should be returned for sqlproj project type after provider1 is disposed');
should.strictEqual(ProjectProviderRegistry.providers.length, 1, 'there should be only one project provider after unregistering a provider');
should.strictEqual(ProjectProviderRegistry.providers[0].supportedProjectTypes[0].projectFileExtension, 'sqlproj', 'the remaining project provider should be sqlproj');
// unregister provider2
disposable2.dispose();
providerResult = ProjectProviderRegistry.getProviderByProjectType('sqlproj');
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('sqlproj');
should.equal(providerResult, undefined, 'undefined should be returned for sqlproj project type after provider2 is disposed');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider after unregistering the providers');
});
@@ -89,9 +98,11 @@ suite('ProjectProviderRegistry Tests', function (): void {
test('Clear the project provider registry', async () => {
const provider = createProjectProvider([
{
id: 'tp1',
projectFileExtension: 'testproj',
icon: '',
displayName: 'test project'
displayName: 'test project',
description: ''
}
]);
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test');

View File

@@ -0,0 +1,37 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as path from 'path';
import { IProjectType } from 'dataworkspace';
import { promises as fs } from 'fs';
export const testProjectType: IProjectType = {
id: 'tp1',
description: '',
projectFileExtension: 'testproj',
icon: '',
displayName: 'test project'
};
/**
* Creates a unique test project file
* @param fileExt
* @param contents
*/
export async function createProjectFile(fileExt: string, contents?: string): Promise<string> {
const filepath = generateUniqueProjectFilePath(fileExt);
await fs.writeFile(filepath, contents ?? '');
return filepath;
}
export function generateUniqueProjectFilePath(fileExt: string): string {
return path.join(os.tmpdir(), `TestProject_${new Date().getTime()}.${fileExt}`);
}
export function generateUniqueWorkspaceFilePath(): string {
return path.join(os.tmpdir(), `TestWorkspace_${new Date().getTime()}.code-workspace`);
}

View File

@@ -5,9 +5,11 @@
import 'mocha';
import * as vscode from 'vscode';
import * as azdata from 'azdata';
import * as sinon from 'sinon';
import * as should from 'should';
import * as path from 'path';
import * as TypeMoq from 'typemoq';
import { WorkspaceService } from '../services/workspaceService';
import { ProjectProviderRegistry } from '../common/projectProviderRegistry';
import { createProjectProvider } from './projectProviderRegistry.test';
@@ -61,7 +63,12 @@ function createMockExtension(id: string, isActive: boolean, projectTypes: string
}
suite('WorkspaceService Tests', function (): void {
const service = new WorkspaceService();
const mockExtensionContext = TypeMoq.Mock.ofType<vscode.ExtensionContext>();
const mockGlobalState = TypeMoq.Mock.ofType<vscode.Memento>();
mockGlobalState.setup(x => x.update(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve());
mockExtensionContext.setup(x => x.globalState).returns(() => mockGlobalState.object);
const service = new WorkspaceService(mockExtensionContext.object);
this.afterEach(() => {
sinon.restore();
@@ -111,10 +118,14 @@ suite('WorkspaceService Tests', function (): void {
const provider1 = createProjectProvider([
{
id: 'tp1',
description: '',
projectFileExtension: 'testproj',
icon: '',
displayName: 'test project'
}, {
id: 'tp2',
description: '',
projectFileExtension: 'testproj1',
icon: '',
displayName: 'test project 1'
@@ -122,6 +133,8 @@ suite('WorkspaceService Tests', function (): void {
]);
const provider2 = createProjectProvider([
{
id: 'sp1',
description: '',
projectFileExtension: 'sqlproj',
icon: '',
displayName: 'sql project'
@@ -149,10 +162,12 @@ suite('WorkspaceService Tests', function (): void {
const extension2 = createMockExtension('ext2', false, ['sqlproj']);
const extension3 = createMockExtension('ext3', false, ['dbproj']);
stubAllExtensions([extension1, extension2, extension3].map(ext => ext.extension));
const getProviderByProjectTypeStub = sinon.stub(ProjectProviderRegistry, 'getProviderByProjectType');
const getProviderByProjectTypeStub = sinon.stub(ProjectProviderRegistry, 'getProviderByProjectExtension');
getProviderByProjectTypeStub.onFirstCall().returns(undefined);
getProviderByProjectTypeStub.onSecondCall().returns(createProjectProvider([
{
id: 'sp1',
description: '',
projectFileExtension: 'sqlproj',
icon: '',
displayName: 'test project'
@@ -167,6 +182,8 @@ suite('WorkspaceService Tests', function (): void {
getProviderByProjectTypeStub.reset();
getProviderByProjectTypeStub.returns(createProjectProvider([{
id: 'tp2',
description: '',
projectFileExtension: 'csproj',
icon: '',
displayName: 'test cs project'
@@ -215,6 +232,45 @@ suite('WorkspaceService Tests', function (): void {
onWorkspaceProjectsChangedDisposable.dispose();
});
test('test addProjectsToWorkspace when no workspace open', async () => {
stubWorkspaceFile(undefined);
const onWorkspaceProjectsChangedStub = sinon.stub();
const onWorkspaceProjectsChangedDisposable = service.onDidWorkspaceProjectsChange(() => {
onWorkspaceProjectsChangedStub();
});
const createWorkspaceStub = sinon.stub(azdata.workspace, 'createWorkspace').resolves(undefined);
await service.addProjectsToWorkspace([
vscode.Uri.file('/test/folder/proj1.sqlproj')
]);
should.strictEqual(createWorkspaceStub.calledOnce, true, 'createWorkspace should have been called once');
should.strictEqual(onWorkspaceProjectsChangedStub.notCalled, true, 'the onDidWorkspaceProjectsChange event should not have been fired');
onWorkspaceProjectsChangedDisposable.dispose();
});
test('test loadTempProjects', async () => {
const processPath = (original: string): string => {
return original.replace(/\//g, path.sep);
};
stubWorkspaceFile('/test/folder/proj1.code-workspace');
const updateConfigurationStub = sinon.stub();
const getConfigurationStub = sinon.stub().returns([processPath('folder1/proj2.sqlproj')]);
const onWorkspaceProjectsChangedStub = sinon.stub();
const onWorkspaceProjectsChangedDisposable = service.onDidWorkspaceProjectsChange(() => {
onWorkspaceProjectsChangedStub();
});
stubGetConfigurationValue(getConfigurationStub, updateConfigurationStub);
sinon.stub(azdata.workspace, 'createWorkspace').resolves(undefined);
sinon.stub(vscode.workspace, 'workspaceFolders').value(['folder1']);
mockGlobalState.setup(x => x.get(TypeMoq.It.isAny())).returns(() => [processPath('folder1/proj2.sqlproj')]);
await service.loadTempProjects();
should.strictEqual(onWorkspaceProjectsChangedStub.calledOnce, true, 'the onDidWorkspaceProjectsChange event should have been fired');
onWorkspaceProjectsChangedDisposable.dispose();
});
test('test removeProject', async () => {
const processPath = (original: string): string => {
return original.replace(/\//g, path.sep);

View File

@@ -7,13 +7,19 @@ import 'mocha';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
import * as should from 'should';
import * as TypeMoq from 'typemoq';
import { WorkspaceTreeDataProvider } from '../common/workspaceTreeDataProvider';
import { WorkspaceService } from '../services/workspaceService';
import { IProjectProvider, WorkspaceTreeItem } from 'dataworkspace';
import { MockTreeDataProvider } from './projectProviderRegistry.test';
suite('workspaceTreeDataProvider Tests', function (): void {
const workspaceService = new WorkspaceService();
const mockExtensionContext = TypeMoq.Mock.ofType<vscode.ExtensionContext>();
const mockGlobalState = TypeMoq.Mock.ofType<vscode.Memento>();
mockGlobalState.setup(x => x.update(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve());
mockExtensionContext.setup(x => x.globalState).returns(() => mockGlobalState.object);
const workspaceService = new WorkspaceService(mockExtensionContext.object);
const treeProvider = new WorkspaceTreeDataProvider(workspaceService);
this.afterEach(() => {
@@ -66,15 +72,20 @@ suite('workspaceTreeDataProvider Tests', function (): void {
const treeDataProvider = new MockTreeDataProvider();
const projectProvider: IProjectProvider = {
supportedProjectTypes: [{
id: 'sp1',
projectFileExtension: 'sqlproj',
icon: '',
displayName: 'sql project'
displayName: 'sql project',
description: ''
}],
RemoveProject: (projectFile: vscode.Uri): Promise<void> => {
return Promise.resolve();
},
getProjectTreeDataProvider: (projectFile: vscode.Uri): Promise<vscode.TreeDataProvider<any>> => {
return Promise.resolve(treeDataProvider);
},
createProject: (name: string, location: vscode.Uri): Promise<vscode.Uri> => {
return Promise.resolve(location);
}
};
const getProjectProviderStub = sinon.stub(workspaceService, 'getProjectProvider');