diff --git a/build/lib/extensions.js b/build/lib/extensions.js index 67c9db4158..fd9d1ecc04 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -216,7 +216,6 @@ const externalExtensions = [ 'azurehybridtoolkit', 'cms', 'dacpac', - 'data-workspace', 'import', 'kusto', 'liveshare', diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 40ef95d7e6..37c10c0a5a 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -250,7 +250,6 @@ const externalExtensions = [ 'azurehybridtoolkit', 'cms', 'dacpac', - 'data-workspace', 'import', 'kusto', 'liveshare', diff --git a/extensions/dacpac/src/test/testDacFxService.ts b/extensions/dacpac/src/test/testDacFxService.ts index 517d7dbf1b..fe6a225fe0 100644 --- a/extensions/dacpac/src/test/testDacFxService.ts +++ b/extensions/dacpac/src/test/testDacFxService.ts @@ -35,7 +35,7 @@ export class DacFxTestService implements mssql.IDacFxService { this.dacfxResult.operationId = extractOperationId; return Promise.resolve(this.dacfxResult); } - importDatabaseProject(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: mssql.ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Promise { + createProjectFromDatabase(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: mssql.ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Promise { this.dacfxResult.operationId = importOperationId; return Promise.resolve(this.dacfxResult); } diff --git a/extensions/data-workspace/images/file.svg b/extensions/data-workspace/images/file.svg new file mode 100644 index 0000000000..69412f5c61 --- /dev/null +++ b/extensions/data-workspace/images/file.svg @@ -0,0 +1 @@ +file_16x16 \ No newline at end of file diff --git a/extensions/data-workspace/images/file_inverse.svg b/extensions/data-workspace/images/file_inverse.svg new file mode 100644 index 0000000000..8276c545aa --- /dev/null +++ b/extensions/data-workspace/images/file_inverse.svg @@ -0,0 +1 @@ +file_inverse_16x16 \ No newline at end of file diff --git a/extensions/data-workspace/images/folder.svg b/extensions/data-workspace/images/folder.svg new file mode 100644 index 0000000000..64cbba1769 --- /dev/null +++ b/extensions/data-workspace/images/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/data-workspace/package.json b/extensions/data-workspace/package.json index 25d64b581b..91631102fa 100644 --- a/extensions/data-workspace/package.json +++ b/extensions/data-workspace/package.json @@ -10,10 +10,10 @@ "aiKey": "AIF-37eefaf0-8022-4671-a3fb-64752724682e", "engines": { "vscode": "*", - "azdata": ">=1.22.0" + "azdata": ">=1.25.0" }, "activationEvents": [ - "onView:dataworkspace.views.main" + "*" ], "main": "./out/main", "repository": { @@ -30,17 +30,27 @@ "type": "array", "default": [], "description": "" + }, + "projects.defaultProjectSaveLocation": { + "type": "string", + "description": "%projects.defaultProjectSaveLocation%" } } } ], "commands": [ { - "command": "projects.addProject", - "title": "%add-project-command%", - "category": "", + "command": "projects.new", + "title": "%new-command%", + "category": "%data-workspace-view-container-name%", "icon": "$(add)" }, + { + "command": "projects.openExisting", + "title": "%open-existing-command%", + "category": "%data-workspace-view-container-name%", + "icon": "$(folder-opened)" + }, { "command": "dataworkspace.refresh", "title": "%refresh-workspace-command%", @@ -57,18 +67,22 @@ { "command": "dataworkspace.refresh", "when": "view == dataworkspace.views.main", + "group": "secondary" + }, + { + "command": "projects.new", + "when": "view == dataworkspace.views.main", "group": "navigation" }, { - "command": "projects.addProject", + "command": "projects.openExisting", "when": "view == dataworkspace.views.main", "group": "navigation" } ], "commandPalette": [ { - "command": "projects.addProject", - "when": "false" + "command": "projects.new" }, { "command": "dataworkspace.refresh", @@ -77,6 +91,9 @@ { "command": "projects.removeProject", "when": "false" + }, + { + "command": "projects.openExisting" } ], "view/item/context": [ @@ -102,7 +119,8 @@ "id": "dataworkspace.views.main", "name": "%main-view-name%", "contextualTitle": "%data-workspace-view-container-name%", - "icon": "images/data-workspace.svg" + "icon": "images/data-workspace.svg", + "when": "isProjectProviderAvailable" } ] }, @@ -111,6 +129,11 @@ "view": "dataworkspace.views.main", "contents": "%projects-view-no-workspace-content%", "when": "workbenchState != workspace" + }, + { + "view": "dataworkspace.views.main", + "contents": "%projects-view-no-project-content%", + "when": "workbenchState == workspace && isProjectsViewEmpty" } ] }, diff --git a/extensions/data-workspace/package.nls.json b/extensions/data-workspace/package.nls.json index 62eb92f848..02d1fd9427 100644 --- a/extensions/data-workspace/package.nls.json +++ b/extensions/data-workspace/package.nls.json @@ -3,8 +3,11 @@ "extension-description": "Data workspace", "data-workspace-view-container-name": "Projects", "main-view-name": "Projects", - "add-project-command": "Add Project", + "new-command": "New", "refresh-workspace-command": "Refresh", "remove-project-command": "Remove Project", - "projects-view-no-workspace-content": "To use projects, open a workspace and add projects to it, or use the 'Add Project' feature and we will create a workspace for you.\n[Open Workspace](command:workbench.action.openWorkspace)\n[Add Project](command:projects.addProject)" + "projects-view-no-workspace-content": "[Create new](command:projects.new)\n[Open existing](command:projects.openExisting)\n", + "projects-view-no-project-content": "No projects found in current workspace.\n[Create new](command:projects.new)\n[Open existing](command:projects.openExisting)\n", + "open-existing-command": "Open existing", + "projects.defaultProjectSaveLocation": "Full path to folder where new projects are saved by default." } diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index ee95189a5f..5022b23620 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -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'; diff --git a/extensions/data-workspace/src/common/dataWorkspaceExtension.ts b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts new file mode 100644 index 0000000000..11f0430e33 --- /dev/null +++ b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts @@ -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 { + return this.workspaceService.addProjectsToWorkspace(projectFiles); + } + + showProjectsView(): void { + vscode.commands.executeCommand('dataworkspace.views.main.focus'); + } + + get defaultProjectSaveLocation(): vscode.Uri | undefined { + return defaultProjectSaveLocation(); + } +} diff --git a/extensions/data-workspace/src/common/iconHelper.ts b/extensions/data-workspace/src/common/iconHelper.ts new file mode 100644 index 0000000000..eef61bb913 --- /dev/null +++ b/extensions/data-workspace/src/common/iconHelper.ts @@ -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`) + }; + } + } +} diff --git a/extensions/data-workspace/src/common/interfaces.ts b/extensions/data-workspace/src/common/interfaces.ts index fab6e1e1f5..f47d7ecf76 100644 --- a/extensions/data-workspace/src/common/interfaces.ts +++ b/extensions/data-workspace/src/common/interfaces.ts @@ -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; + getProjectsInWorkspace(): vscode.Uri[]; /** * Gets the project provider by project file @@ -65,8 +71,28 @@ export interface IWorkspaceService { */ removeProject(projectFile: vscode.Uri): Promise; + /** + * 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; + + readonly isProjectProviderAvailable: boolean; + /** * Event fires when projects in workspace changes */ readonly onDidWorkspaceProjectsChange: vscode.Event; + + /** + * Verify that a workspace is open or if one isn't, ask user to pick whether a workspace should be automatically created + */ + validateWorkspace(): Promise; + + /** + * 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; } diff --git a/extensions/data-workspace/src/common/projectLocationHelper.ts b/extensions/data-workspace/src/common/projectLocationHelper.ts new file mode 100644 index 0000000000..8de44a8f08 --- /dev/null +++ b/extensions/data-workspace/src/common/projectLocationHelper.ts @@ -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() !== ''; +} + diff --git a/extensions/data-workspace/src/common/projectProviderRegistry.ts b/extensions/data-workspace/src/common/projectProviderRegistry.ts index c091a25112..55209bec44 100644 --- a/extensions/data-workspace/src/common/projectProviderRegistry.ts +++ b/extensions/data-workspace/src/common/projectProviderRegistry.ts @@ -9,20 +9,24 @@ import { IProjectProviderRegistry } from './interfaces'; export const ProjectProviderRegistry: IProjectProviderRegistry = new class implements IProjectProviderRegistry { private _providers = new Array(); - 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; } }; diff --git a/extensions/data-workspace/src/common/utils.ts b/extensions/data-workspace/src/common/utils.ts new file mode 100644 index 0000000000..2081758155 --- /dev/null +++ b/extensions/data-workspace/src/common/utils.ts @@ -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 { + const stats = await getFileStatus(directoryPath); + return stats ? stats.isDirectory() : false; +} + +export async function fileExist(filePath: string): Promise { + const stats = await getFileStatus(filePath); + return stats ? stats.isFile() : false; +} + +async function getFileStatus(path: string): Promise { + try { + const stats = await fs.promises.stat(path); + return stats; + } + catch (e) { + if (e.code === 'ENOENT') { + return undefined; + } + else { + throw e; + } + } +} diff --git a/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts b/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts index 099e645e1d..d6fde04186 100644 --- a/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts +++ b/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts @@ -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 0) { - vscode.window.showErrorMessage(UnknownProjectsErrorMessage(unknownProjects)); + vscode.window.showErrorMessage(UnknownProjectsError(unknownProjects)); } return treeItems; } diff --git a/extensions/data-workspace/src/dataworkspace.d.ts b/extensions/data-workspace/src/dataworkspace.d.ts index 89e00dd387..57e6b1712f 100644 --- a/extensions/data-workspace/src/dataworkspace.d.ts +++ b/extensions/data-workspace/src/dataworkspace.d.ts @@ -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 + + /** + * 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; + /** + * + * @param name Create a project + * @param location the parent directory of the project + */ + createProject(name: string, location: vscode.Uri): Promise; + /** * 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 */ diff --git a/extensions/data-workspace/src/dialogs/dialogBase.ts b/extensions/data-workspace/src/dialogs/dialogBase.ts new file mode 100644 index 0000000000..7cdbfdb176 --- /dev/null +++ b/extensions/data-workspace/src/dialogs/dialogBase.ts @@ -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 { + resolve: (result: T | Promise) => void; + reject: (reason: any) => void; +} + +export abstract class DialogBase { + protected _toDispose: vscode.Disposable[] = []; + protected _dialogObject: azdata.window.Dialog; + protected initDialogComplete: Deferred | undefined; + protected initDialogPromise: Promise = new Promise((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; + + protected async validate(): Promise { + return Promise.resolve(true); + } + + public async open(): Promise { + 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 { + await this.onComplete(); + this.dispose(); + } + + protected async onComplete(): Promise { + } + + 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({ + value: vscode.workspace.workspaceFile ? constants.AddProjectToCurrentWorkspace : constants.NewWorkspaceWillBeCreated, + CSSStyles: { 'margin-top': '3px', 'margin-bottom': '10px' } + }).component(); + + this.workspaceInputBox = view.modelBuilder.inputBox().withProperties({ + 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; + } + } +} diff --git a/extensions/data-workspace/src/dialogs/newProjectDialog.ts b/extensions/data-workspace/src/dialogs/newProjectDialog.ts new file mode 100644 index 0000000000..3fde3b11ab --- /dev/null +++ b/extensions/data-workspace/src/dialogs/newProjectDialog.ts @@ -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 { + 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 { + 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 { + const allProjectTypes = await this.workspaceService.getAllProjectTypes(); + const projectTypeRadioCardGroup = view.modelBuilder.radioCardGroup().withProperties({ + cards: allProjectTypes.map((projectType: IProjectType) => { + return { + 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({ + 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({ + 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({ + 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(); + } +} diff --git a/extensions/data-workspace/src/dialogs/openExistingDialog.ts b/extensions/data-workspace/src/dialogs/openExistingDialog.ts new file mode 100644 index 0000000000..bd7dc759fd --- /dev/null +++ b/extensions/data-workspace/src/dialogs/openExistingDialog.ts @@ -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 { + 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 { + 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 { + this._targetTypeRadioCardGroup = view.modelBuilder.radioCardGroup().withProperties({ + cards: this._targetTypes.map((targetType) => { + return { + 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({ + 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({ + 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 { + 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 { + 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; + } +} diff --git a/extensions/data-workspace/src/main.ts b/extensions/data-workspace/src/main.ts index 3c3cf032e0..a5238e6ff1 100644 --- a/extensions/data-workspace/src/main.ts +++ b/extensions/data-workspace/src/main.ts @@ -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 { + 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 { diff --git a/extensions/data-workspace/src/services/workspaceService.ts b/extensions/data-workspace/src/services/workspaceService.ts index b9b2d94b94..cb3a2ad19d 100644 --- a/extensions/data-workspace/src/services/workspaceService.ts +++ b/extensions/data-workspace/src/services/workspaceService.ts @@ -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 = new vscode.EventEmitter(); readonly onDidWorkspaceProjectsChange: vscode.Event = this._onDidWorkspaceProjectsChange?.event; - async addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise { - 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 { + 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(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 { + // 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 { + 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 { + 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 { + 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 { + getProjectsInWorkspace(): vscode.Uri[] { return vscode.workspace.workspaceFile ? this.getWorkspaceConfigurationValue(ProjectsConfigurationName).map(project => this.toUri(project)) : []; } async getProjectProvider(projectFile: vscode.Uri): Promise { 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 { 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 { + 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)) { diff --git a/extensions/data-workspace/src/test/dialogs/newProjectDialog.test.ts b/extensions/data-workspace/src/test/dialogs/newProjectDialog.test.ts new file mode 100644 index 0000000000..dbd0495a5a --- /dev/null +++ b/extensions/data-workspace/src/test/dialogs/newProjectDialog.test.ts @@ -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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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(); + }); +}); + diff --git a/extensions/data-workspace/src/test/dialogs/openExistingDialog.test.ts b/extensions/data-workspace/src/test/dialogs/openExistingDialog.test.ts new file mode 100644 index 0000000000..4504e49d60 --- /dev/null +++ b/extensions/data-workspace/src/test/dialogs/openExistingDialog.test.ts @@ -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(); + + this.afterEach(() => { + sinon.restore(); + }); + + test('Should validate project file exists', async function (): Promise { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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 { + const workspaceServiceMock = TypeMoq.Mock.ofType(); + 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); + }); +}); + diff --git a/extensions/data-workspace/src/test/projectProviderRegistry.test.ts b/extensions/data-workspace/src/test/projectProviderRegistry.test.ts index 6f3100be14..430b91d9c1 100644 --- a/extensions/data-workspace/src/test/projectProviderRegistry.test.ts +++ b/extensions/data-workspace/src/test/projectProviderRegistry.test.ts @@ -28,6 +28,9 @@ export function createProjectProvider(projectTypes: IProjectType[]): IProjectPro }, getProjectTreeDataProvider: (projectFile: vscode.Uri): Promise> => { return Promise.resolve(treeDataProvider); + }, + createProject: (name: string, location: vscode.Uri): Promise => { + 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'); diff --git a/extensions/data-workspace/src/test/testUtils.ts b/extensions/data-workspace/src/test/testUtils.ts new file mode 100644 index 0000000000..1c03dfa8cf --- /dev/null +++ b/extensions/data-workspace/src/test/testUtils.ts @@ -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 { + 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`); +} diff --git a/extensions/data-workspace/src/test/workspaceService.test.ts b/extensions/data-workspace/src/test/workspaceService.test.ts index 4213ae3931..12cd66d091 100644 --- a/extensions/data-workspace/src/test/workspaceService.test.ts +++ b/extensions/data-workspace/src/test/workspaceService.test.ts @@ -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(); + const mockGlobalState = TypeMoq.Mock.ofType(); + 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); diff --git a/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts b/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts index 411c62c664..81b6b6b0c3 100644 --- a/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts +++ b/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts @@ -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(); + const mockGlobalState = TypeMoq.Mock.ofType(); + 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 => { return Promise.resolve(); }, getProjectTreeDataProvider: (projectFile: vscode.Uri): Promise> => { return Promise.resolve(treeDataProvider); + }, + createProject: (name: string, location: vscode.Uri): Promise => { + return Promise.resolve(location); } }; const getProjectProviderStub = sinon.stub(workspaceService, 'getProjectProvider'); diff --git a/extensions/mssql/src/dacfx/dacFxService.ts b/extensions/mssql/src/dacfx/dacFxService.ts index d53e761d1e..0314733e77 100644 --- a/extensions/mssql/src/dacfx/dacFxService.ts +++ b/extensions/mssql/src/dacfx/dacFxService.ts @@ -65,7 +65,7 @@ export class DacFxService implements mssql.IDacFxService { ); } - public importDatabaseProject(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: mssql.ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + public createProjectFromDatabase(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: mssql.ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable { const params: contracts.ExtractParams = { databaseName: databaseName, packageFilePath: targetFilePath, applicationName: applicationName, applicationVersion: applicationVersion, ownerUri: ownerUri, extractTarget: extractTarget, taskExecutionMode: taskExecutionMode }; return this.client.sendRequest(contracts.ExtractRequest.type, params).then( undefined, diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index 07a6d9f1c0..1519eef5a1 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -334,7 +334,7 @@ export interface IDacFxService { exportBacpac(databaseName: string, packageFilePath: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; importBacpac(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; extractDacpac(databaseName: string, packageFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; - importDatabaseProject(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable; + createProjectFromDatabase(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable; deployDacpac(packageFilePath: string, databaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record, deploymentOptions?: DeploymentOptions): Thenable; generateDeployScript(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record, deploymentOptions?: DeploymentOptions): Thenable; generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; diff --git a/extensions/sql-database-projects/README.md b/extensions/sql-database-projects/README.md index 3b38a45255..016d745eb3 100644 --- a/extensions/sql-database-projects/README.md +++ b/extensions/sql-database-projects/README.md @@ -9,8 +9,8 @@ Please report issues and feature requests [here.](https://github.com/microsoft/a ## Getting Started with Database Projects -* Create a new database project by going to the **Projects** viewlet under Explorer, or by searching for **New Database Project** in the command palette. -* Existing database projects can be opened via **Open Database Project** in the command palette. +* Create a new database project by going to the **Projects** viewlet or by searching **Projects: New** in the command palette. +* Existing database projects can be opened by going to the **Projects** viewlet or by searching **Projects: Open Existing** in the command palette. * Start from an existing database by using **Create Project From Database** from the command palette or database context menu. ## Code of Conduct diff --git a/extensions/sql-database-projects/images/colorfulSqlProject.svg b/extensions/sql-database-projects/images/colorfulSqlProject.svg new file mode 100644 index 0000000000..aa8452edd6 --- /dev/null +++ b/extensions/sql-database-projects/images/colorfulSqlProject.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index a833694cc5..74705c6629 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -7,7 +7,7 @@ "preview": true, "engines": { "vscode": "^1.30.1", - "azdata": ">=1.24.0" + "azdata": ">=1.25.0" }, "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", "icon": "images/sqlDatabaseProjects.png", @@ -15,7 +15,7 @@ "activationEvents": [ "onCommand:sqlDatabaseProjects.new", "onCommand:sqlDatabaseProjects.open", - "onCommand:sqlDatabaseProjects.importDatabase", + "onCommand:sqlDatabaseProjects.createProjectFromDatabase", "workspaceContains:**/*.sqlproj", "onView:dataworkspace.views.main" ], @@ -39,37 +39,11 @@ "sqlDatabaseProjects.netCoreSDKLocation": { "type": "string", "description": "%sqlDatabaseProjects.netCoreInstallLocation%" - }, - "sqlDatabaseProjects.defaultProjectSaveLocation": { - "type": "string", - "description": "%sqlDatabaseProjects.defaultProjectSaveLocation%" - }, - "sqlDatabaseProjects.showUpdateSaveLocationPrompt": { - "type": "boolean", - "description": "%sqlDatabaseProjects.showUpdateSaveLocationPrompt%", - "default": true } } } ], "commands": [ - { - "command": "sqlDatabaseProjects.new", - "title": "%sqlDatabaseProjects.new%", - "category": "%sqlDatabaseProjects.displayName%", - "icon": "$(add)" - }, - { - "command": "sqlDatabaseProjects.open", - "title": "%sqlDatabaseProjects.open%", - "category": "%sqlDatabaseProjects.displayName%", - "icon": "$(folder-opened)" - }, - { - "command": "sqlDatabaseProjects.close", - "title": "%sqlDatabaseProjects.close%", - "category": "%sqlDatabaseProjects.displayName%" - }, { "command": "sqlDatabaseProjects.newScript", "title": "%sqlDatabaseProjects.newScript%", @@ -146,8 +120,8 @@ "category": "%sqlDatabaseProjects.displayName%" }, { - "command": "sqlDatabaseProjects.importDatabase", - "title": "%sqlDatabaseProjects.importDatabase%", + "command": "sqlDatabaseProjects.createProjectFromDatabase", + "title": "%sqlDatabaseProjects.createProjectFromDatabase%", "category": "%sqlDatabaseProjects.displayName%", "icon": "images/databaseProjectToolbar.svg" }, @@ -179,16 +153,6 @@ ], "menus": { "commandPalette": [ - { - "command": "sqlDatabaseProjects.new" - }, - { - "command": "sqlDatabaseProjects.open" - }, - { - "command": "sqlDatabaseProjects.close", - "when": "false" - }, { "command": "sqlDatabaseProjects.newScript", "when": "false" @@ -246,7 +210,7 @@ "when": "false" }, { - "command": "sqlDatabaseProjects.importDatabase" + "command": "sqlDatabaseProjects.createProjectFromDatabase" }, { "command": "sqlDatabaseProjects.addDatabaseReference", @@ -273,57 +237,45 @@ "when": "false" } ], - "view/title": [ - { - "command": "sqlDatabaseProjects.new", - "when": "view == sqlDatabaseProjectsView", - "group": "navigation@1" - }, - { - "command": "sqlDatabaseProjects.open", - "when": "view == sqlDatabaseProjectsView", - "group": "navigation@2" - } - ], "view/item/context": [ { "command": "sqlDatabaseProjects.build", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", "group": "1_dbProjectsFirst@1" }, { "command": "sqlDatabaseProjects.publish", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", "group": "1_dbProjectsFirst@2" }, { "command": "sqlDatabaseProjects.schemaCompare", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", "group": "1_dbProjectsFirst@3" }, { "command": "sqlDatabaseProjects.newItem", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "2_dbProjects_newMain@1" }, { "command": "sqlDatabaseProjects.newFolder", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "2_dbProjects_newMain@2" }, { "command": "sqlDatabaseProjects.newTable", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@1" }, { "command": "sqlDatabaseProjects.newView", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@2" }, { "command": "sqlDatabaseProjects.newStoredProcedure", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@3" }, { @@ -333,22 +285,22 @@ }, { "command": "sqlDatabaseProjects.newScript", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@7" }, { "command": "sqlDatabaseProjects.newPreDeploymentScript", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@8" }, { "command": "sqlDatabaseProjects.newPostDeploymentScript", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@9" }, { "command": "sqlDatabaseProjects.addDatabaseReference", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.referencesRoot", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.referencesRoot", "group": "4_dbProjects_addDatabaseReference" }, { @@ -373,66 +325,36 @@ }, { "command": "sqlDatabaseProjects.editProjectFile", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", "group": "9_dbProjectsLast@7" }, { "command": "sqlDatabaseProjects.openContainingFolder", - "when": "view =~ /^(sqlDatabaseProjectsView|dataworkspace.views.main)$/ && viewItem == databaseProject.itemType.project", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", "group": "9_dbProjectsLast@8" - }, - { - "command": "sqlDatabaseProjects.close", - "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.project", - "group": "9_dbProjectsLast@9" } ], "objectExplorer/item/context": [ { - "command": "sqlDatabaseProjects.importDatabase", - "when": "connectionProvider == MSSQL && nodeType && nodeType == Database && mssql:engineedition != 11", - "group": "export" - }, - { - "command": "sqlDatabaseProjects.importDatabase", - "when": "connectionProvider == MSSQL && nodeType && nodeType == Server && mssql:engineedition != 11", + "command": "sqlDatabaseProjects.createProjectFromDatabase", + "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", "group": "export" } ], "dataExplorer/context": [ { - "command": "sqlDatabaseProjects.importDatabase", - "when": "connectionProvider == MSSQL && nodeType && nodeType == Database && mssql:engineedition != 11", - "group": "export" - }, - { - "command": "sqlDatabaseProjects.importDatabase", - "when": "connectionProvider == MSSQL && nodeType && nodeType == Server && mssql:engineedition != 11", + "command": "sqlDatabaseProjects.createProjectFromDatabase", + "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", "group": "export" } ], "dashboard/toolbar": [ { - "command": "sqlDatabaseProjects.importDatabase", + "command": "sqlDatabaseProjects.createProjectFromDatabase", "when": "connectionProvider == 'MSSQL' && mssql:engineedition != 11" } ] - }, - "views": { - "explorer": [ - { - "id": "sqlDatabaseProjectsView", - "name": "%title.projectsView%", - "when": "" - } - ] - }, - "viewsWelcome": [ - { - "view": "sqlDatabaseProjectsView", - "contents": "%sqlDatabaseProjects.welcome%" - } - ] + } }, "dependencies": { "@types/xml-formatter": "^1.1.0", diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 1c4ef4c48a..42698f47a4 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -8,7 +8,7 @@ "sqlDatabaseProjects.close": "Close Database Project", "sqlDatabaseProjects.build": "Build", "sqlDatabaseProjects.publish": "Publish", - "sqlDatabaseProjects.importDatabase": "Create Project From Database", + "sqlDatabaseProjects.createProjectFromDatabase": "Create Project From Database", "sqlDatabaseProjects.properties": "Properties", "sqlDatabaseProjects.schemaCompare": "Schema Compare", "sqlDatabaseProjects.delete": "Delete", @@ -33,7 +33,5 @@ "sqlDatabaseProjects.Settings": "Database Projects", "sqlDatabaseProjects.netCoreInstallLocation": "Full path to .Net Core SDK on the machine.", - "sqlDatabaseProjects.defaultProjectSaveLocation": "Full path to folder where new database projects are saved by default.", - "sqlDatabaseProjects.showUpdateSaveLocationPrompt": "After creating a new database project, always prompt to update the location where new projects are saved by default.", "sqlDatabaseProjects.welcome": "No database projects currently open.\n[New Project](command:sqlDatabaseProjects.new)\n[Open Project](command:sqlDatabaseProjects.open)\n[Create Project From Database](command:sqlDatabaseProjects.importDatabase)" } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index dae78ae355..0b788a47b1 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -23,12 +23,13 @@ export const MicrosoftDatatoolsSchemaSqlSql = 'Microsoft.Data.Tools.Schema.Sql.S export const databaseSchemaProvider = 'DatabaseSchemaProvider'; // Project Provider -export const projectTypeDisplayName = localize('projectTypeDisplayName', 'Database Project'); +export const sqlDatabaseProjectTypeId = 'sqldbproj'; +export const projectTypeDisplayName = localize('projectTypeDisplayName', "SQL Database"); +export const projectTypeDescription = localize('projectTypeDescription', "Design, edit, and publish schemas for SQL databases"); // commands export const revealFileInOsCommand = 'revealFileInOS'; export const schemaCompareStartCommand = 'schemaCompare.start'; -export const sqlDatabaseProjectsViewFocusCommand = 'sqlDatabaseProjectsView.focus'; export const vscodeOpenCommand = 'vscode.open'; // UI Strings @@ -54,10 +55,6 @@ export const objectType = localize('objectType', "Object Type"); export const schema = localize('schema', "Schema"); export const schemaObjectType = localize('schemaObjectType', "Schema/Object Type"); export const defaultProjectNameStarter = localize('defaultProjectNameStarter', "DatabaseProject"); -export const newDefaultProjectSaveLocation = localize('newDefaultProjectSaveLocation', "Would you like to update the default location to save new database projects?"); -export const invalidDefaultProjectSaveLocation = localize('invalidDefaultProjectSaveLocation', "Default location to save new database projects is invalid. Would you like to update it?"); -export const openWorkspaceSettings = localize('openWorkspaceSettings', "Yes, open Settings"); -export const doNotPromptAgain = localize('doNotPromptAgain', "Don't ask again"); export const reloadProject = localize('reloadProject', "Would you like to reload your database project?"); export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); } export function deleteConfirmation(toDelete: string) { return localize('deleteConfirmation', "Are you sure you want to delete {0}?", toDelete); } @@ -95,7 +92,7 @@ export const defaultUser = localize('default', "default"); export const addDatabaseReferenceDialogName = localize('addDatabaseReferencedialogName', "Add database reference"); export const addDatabaseReferenceOkButtonText = localize('addDatabaseReferenceOkButtonText', "Add reference"); export const referenceRadioButtonsGroupTitle = localize('referenceRadioButtonsGroupTitle', "Type"); -export const projectRadioButtonTitle = localize('projectRadioButtonTitle', "Database project in folder"); +export const projectRadioButtonTitle = localize('projectRadioButtonTitle', "Project"); export const systemDatabaseRadioButtonTitle = localize('systemDatabaseRadioButtonTitle', "System database"); export const dacpacText = localize('dacpacText', "Data-tier application (.dacpac)"); export const dacpacPlaceholder = localize('dacpacPlaceholder', "Select .dacpac"); @@ -259,11 +256,6 @@ export const activeDirectoryInteractive = 'active directory interactive'; export const userIdSetting = 'User ID'; export const passwordSetting = 'Password'; -// Workspace settings for saving new database projects -export const dbProjectConfigurationKey = 'sqlDatabaseProjects'; -export const projectSaveLocationKey = 'defaultProjectSaveLocation'; -export const showUpdatePromptKey = 'showUpdateSaveLocationPrompt'; - // Authentication types export const integratedAuth = 'Integrated'; export const azureMfaAuth = 'AzureMFA'; diff --git a/extensions/sql-database-projects/src/common/iconHelper.ts b/extensions/sql-database-projects/src/common/iconHelper.ts index b92bb4d2ec..e048477661 100644 --- a/extensions/sql-database-projects/src/common/iconHelper.ts +++ b/extensions/sql-database-projects/src/common/iconHelper.ts @@ -13,6 +13,7 @@ export interface IconPath { export class IconPathHelper { private static extensionContext: vscode.ExtensionContext; public static databaseProject: IconPath; + public static colorfulSqlProject: IconPath; public static dataSourceGroup: IconPath; public static dataSourceSql: IconPath; @@ -31,6 +32,7 @@ export class IconPathHelper { IconPathHelper.extensionContext = extensionContext; IconPathHelper.databaseProject = IconPathHelper.makeIcon('databaseProject'); + IconPathHelper.colorfulSqlProject = IconPathHelper.makeIcon('colorfulSqlProject', true); IconPathHelper.dataSourceGroup = IconPathHelper.makeIcon('dataSourceGroup'); IconPathHelper.dataSourceSql = IconPathHelper.makeIcon('dataSource-sql'); diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index ac7738d5ea..1a2c87760f 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -8,6 +8,7 @@ import * as os from 'os'; import * as constants from './constants'; import * as path from 'path'; import * as glob from 'fast-glob'; +import * as dataworkspace from 'dataworkspace'; import * as mssql from '../../../mssql'; import { promises as fs } from 'fs'; @@ -217,7 +218,7 @@ export function isValidSqlCmdVariableName(name: string | undefined): boolean { return true; } -/* +/** * Recursively gets all the sqlproj files at any depth in a folder * @param folderPath */ @@ -231,6 +232,19 @@ export async function getSqlProjectFilesInFolder(folderPath: string): Promise path.extname(p.fsPath) === constants.sqlprojExtension); +} + +export function getDataWorkspaceExtensionApi(): dataworkspace.IExtension { + const extension = vscode.extensions.getExtension(dataworkspace.extension.name)!; + return extension.exports; +} + +/* * Returns the default deployment options from DacFx */ export async function GetDefaultDeploymentOptions(): Promise { diff --git a/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts b/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts index 46aa583d84..cd0eebb906 100644 --- a/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts +++ b/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts @@ -28,6 +28,10 @@ export class SqlDatabaseProjectTreeViewProvider implements vscode.TreeDataProvid this.roots = []; } + public notifyTreeDataChanged() { + this._onDidChangeTreeData.fire(undefined); + } + public getTreeItem(element: BaseProjectTreeItem): vscode.TreeItem { return element.treeItem; } diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index b10073ea4e..2595167c66 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -6,33 +6,23 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as templates from '../templates/templates'; -import * as constants from '../common/constants'; import * as path from 'path'; -import * as newProjectTool from '../tools/newProjectTool'; -import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; -import { getErrorMessage, getSqlProjectFilesInFolder } from '../common/utils'; import { ProjectsController } from './projectController'; -import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { NetCoreTool } from '../tools/netcoreTool'; -import { Project } from '../models/project'; -import { ExternalStreamingJobFileNode, FileNode, FolderNode } from '../models/tree/fileFolderTreeItem'; import { IconPathHelper } from '../common/iconHelper'; -import { IProjectProvider } from 'dataworkspace'; +import { IProjectProvider, WorkspaceTreeItem } from 'dataworkspace'; import { SqlDatabaseProjectProvider } from '../projectProvider/projectProvider'; -const SQL_DATABASE_PROJECTS_VIEW_ID = 'sqlDatabaseProjectsView'; - /** * The main controller class that initializes the extension */ export default class MainController implements vscode.Disposable { - protected dbProjectTreeViewProvider: SqlDatabaseProjectTreeViewProvider = new SqlDatabaseProjectTreeViewProvider(); protected projectsController: ProjectsController; protected netcoreTool: NetCoreTool; public constructor(private context: vscode.ExtensionContext) { - this.projectsController = new ProjectsController(this.dbProjectTreeViewProvider); + this.projectsController = new ProjectsController(); this.netcoreTool = new NetCoreTool(); } @@ -49,147 +39,42 @@ export default class MainController implements vscode.Disposable { public async activate(): Promise { await this.initializeDatabaseProjects(); - return new SqlDatabaseProjectProvider(); + return new SqlDatabaseProjectProvider(this.projectsController); } private async initializeDatabaseProjects(): Promise { // init commands - vscode.commands.registerCommand('sqlDatabaseProjects.new', async () => { await this.createNewProject(); }); - vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { await this.openProjectFromFile(); }); - vscode.commands.registerCommand('sqlDatabaseProjects.close', (node: BaseProjectTreeItem) => { this.projectsController.closeProject(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.properties', async (node: BaseProjectTreeItem) => { await vscode.window.showErrorMessage(`Properties not yet implemented: ${node.uri.path}`); }); // TODO + vscode.commands.registerCommand('sqlDatabaseProjects.properties', async (node: WorkspaceTreeItem) => { await vscode.window.showErrorMessage(`Properties not yet implemented: ${node.element.uri.path}`); }); // TODO - vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: BaseProjectTreeItem) => { await this.projectsController.buildProject(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: BaseProjectTreeItem) => { await this.projectsController.publishProject(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: BaseProjectTreeItem) => { await this.projectsController.schemaCompare(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.importDatabase', async (profile: azdata.IConnectionProfile) => { await this.projectsController.importNewDatabaseProject(profile); }); + vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: WorkspaceTreeItem) => { await this.projectsController.buildProject(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: WorkspaceTreeItem) => { await this.projectsController.publishProject(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: WorkspaceTreeItem) => { await this.projectsController.schemaCompare(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.createProjectFromDatabase', async (profile: azdata.IConnectionProfile) => { await this.projectsController.createProjectFromDatabase(profile); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.script); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newPreDeploymentScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.preDeployScript); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newPostDeploymentScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.postDeployScript); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newTable', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.table); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newView', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.view); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.storedProcedure); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newExternalStreamingJob', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.externalStreamingJob); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.script); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newPreDeploymentScript', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.preDeployScript); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newPostDeploymentScript', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.postDeployScript); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newTable', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.table); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newView', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.view); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.storedProcedure); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newExternalStreamingJob', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.externalStreamingJob); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newItem', async (node: WorkspaceTreeItem) => { await this.projectsController.addItemPromptFromNode(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newFolder', async (node: WorkspaceTreeItem) => { await this.projectsController.addFolderPrompt(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.addDatabaseReference', async (node: BaseProjectTreeItem) => { await this.projectsController.addDatabaseReference(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.openContainingFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.openContainingFolder(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.editProjectFile', async (node: BaseProjectTreeItem) => { await this.projectsController.editProjectFile(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.delete', async (node: BaseProjectTreeItem) => { await this.projectsController.delete(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.exclude', async (node: FileNode | FolderNode) => { await this.projectsController.exclude(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.changeTargetPlatform', async (node: BaseProjectTreeItem) => { await this.projectsController.changeTargetPlatform(node); }); - vscode.commands.registerCommand('sqlDatabaseProjects.validateExternalStreamingJob', async (node: ExternalStreamingJobFileNode) => { await this.projectsController.validateExternalStreamingJob(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.addDatabaseReference', async (node: WorkspaceTreeItem) => { await this.projectsController.addDatabaseReference(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.openContainingFolder', async (node: WorkspaceTreeItem) => { await this.projectsController.openContainingFolder(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.editProjectFile', async (node: WorkspaceTreeItem) => { await this.projectsController.editProjectFile(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.delete', async (node: WorkspaceTreeItem) => { await this.projectsController.delete(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.exclude', async (node: WorkspaceTreeItem) => { await this.projectsController.exclude(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.changeTargetPlatform', async (node: WorkspaceTreeItem) => { await this.projectsController.changeTargetPlatform(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.validateExternalStreamingJob', async (node: WorkspaceTreeItem) => { await this.projectsController.validateExternalStreamingJob(node); }); IconPathHelper.setExtensionContext(this.extensionContext); - // init view - const treeView = vscode.window.createTreeView(SQL_DATABASE_PROJECTS_VIEW_ID, { - treeDataProvider: this.dbProjectTreeViewProvider, - showCollapseAll: true - }); - this.dbProjectTreeViewProvider.setTreeView(treeView); - - this.extensionContext.subscriptions.push(treeView); - await templates.loadTemplates(path.join(this.context.extensionPath, 'resources', 'templates')); // ensure .net core is installed await this.netcoreTool.findOrInstallNetCore(); - - // set the user settings around saving new projects to default value - await newProjectTool.initializeSaveLocationSetting(); - - // load any sql projects that are open in workspace folder - await this.loadProjectsInWorkspace(); - } - - public async loadProjectsInWorkspace(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders?.length) { - await Promise.all(workspaceFolders.map(async (workspaceFolder) => { - await this.loadProjectsInFolder(workspaceFolder.uri.fsPath); - })); - } - } - - public async loadProjectsInFolder(folderPath: string): Promise { - const results = await getSqlProjectFilesInFolder(folderPath); - - for (let f in results) { - // open the project, but don't switch focus to the file explorer viewlet - await this.projectsController.openProject(vscode.Uri.file(results[f]), false); - } - } - - /** - * Prompts the user to select a .sqlproj file to open - * TODO: define behavior once projects are automatically opened from workspace - */ - public async openProjectFromFile(): Promise { - try { - let filter: { [key: string]: string[] } = {}; - - filter[constants.sqlDatabaseProject] = ['sqlproj']; - - let files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ filters: filter }); - - if (files) { - for (const file of files) { - await this.projectsController.openProject(file); - } - } - } - catch (err) { - vscode.window.showErrorMessage(getErrorMessage(err)); - } - } - - /** - * Creates a new SQL database project from a template, prompting the user for a name and location - */ - public async createNewProject(): Promise { - try { - let newProjName = await vscode.window.showInputBox({ - prompt: constants.newDatabaseProjectName, - value: newProjectTool.defaultProjectNameNewProj() - }); - - newProjName = newProjName?.trim(); - - if (!newProjName) { - // TODO: is this case considered an intentional cancellation (shouldn't warn) or an error case (should warn)? - vscode.window.showErrorMessage(constants.projectNameRequired); - return undefined; - } - - let selectionResult = await vscode.window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: newProjectTool.defaultProjectSaveLocation() - }); - - if (!selectionResult) { - vscode.window.showErrorMessage(constants.projectLocationRequired); - return undefined; - } - - // TODO: what if the selected folder is outside the workspace? - - const newProjFolderUri = (selectionResult as vscode.Uri[])[0]; - const newProjFilePath = await this.projectsController.createNewProject(newProjName, newProjFolderUri, true); - const proj = await this.projectsController.openProject(vscode.Uri.file(newProjFilePath)); - - newProjectTool.updateSaveLocationSetting(); - - return proj; - } - catch (err) { - vscode.window.showErrorMessage(getErrorMessage(err)); - return undefined; - } } public dispose(): void { diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index e5d33fec64..f591ce20ad 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as constants from '../common/constants'; -import * as dataSources from '../models/dataSources/dataSources'; import * as mssql from '../../../mssql'; import * as os from 'os'; import * as path from 'path'; @@ -14,12 +13,13 @@ import * as templates from '../templates/templates'; import * as newProjectTool from '../tools/newProjectTool'; import * as vscode from 'vscode'; import * as azdata from 'azdata'; +import * as dataworkspace from 'dataworkspace'; import { promises as fs } from 'fs'; import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog'; import { Project, reservedProjectFolders, FileProjectEntry, SqlProjectReferenceProjectEntry, IDatabaseReferenceProjectEntry } from '../models/project'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; -import { FolderNode, FileNode, ExternalStreamingJobFileNode } from '../models/tree/fileFolderTreeItem'; +import { FolderNode, FileNode } from '../models/tree/fileFolderTreeItem'; import { IPublishSettings, IGenerateScriptSettings } from '../models/IPublishSettings'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; @@ -30,97 +30,24 @@ import { PublishProfile, load } from '../models/publishProfile/publishProfile'; import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialog'; import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectReferenceSettings } from '../models/IDatabaseReferenceSettings'; import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem'; -import { WorkspaceTreeItem } from 'dataworkspace'; /** * Controller for managing project lifecycle */ export class ProjectsController { - private projectTreeViewProvider: SqlDatabaseProjectTreeViewProvider; private netCoreTool: NetCoreTool; private buildHelper: BuildHelper; projects: Project[] = []; projFileWatchers = new Map(); - constructor(projTreeViewProvider: SqlDatabaseProjectTreeViewProvider) { - this.projectTreeViewProvider = projTreeViewProvider; + constructor() { this.netCoreTool = new NetCoreTool(); this.buildHelper = new BuildHelper(); } - public refreshProjectsTree() { - this.projectTreeViewProvider.load(this.projects); - } - - public async openProject(projectFile: vscode.Uri, focusProject: boolean = true, isReferencedProject: boolean = false): Promise { - for (const proj of this.projects) { - if (proj.projectFilePath === projectFile.fsPath) { - if (!isReferencedProject) { - vscode.window.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath)); - return proj; - } else { - throw new Error(constants.projectAlreadyOpened(projectFile.fsPath)); - } - } - } - - let newProject: Project; - - try { - // Read project file - newProject = await Project.openProject(projectFile.fsPath); - this.projects.push(newProject); - - // open any reference projects (don't need to worry about circular dependencies because those aren't allowed) - const referencedProjects = newProject.databaseReferences.filter(r => r instanceof SqlProjectReferenceProjectEntry); - for (const proj of referencedProjects) { - const projUri = vscode.Uri.file(path.join(newProject.projectFolderPath, proj.fsUri.fsPath)); - try { - await this.openProject(projUri, false, true); - } catch (e) { - } - } - - // Update for round tripping as needed - await this.updateProjectForRoundTrip(newProject); - - // Read datasources.json (if present) - const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName); - - try { - newProject.dataSources = await dataSources.load(dataSourcesFilePath); - } - catch (err) { - if (err instanceof dataSources.NoDataSourcesFileError) { - // TODO: prompt to create new datasources.json; for now, swallow - } - else { - throw err; - } - } - - this.refreshProjectsTree(); - - if (focusProject) { - await this.focusProject(newProject); - } - } - catch (err) { - // if the project didnt load - remove it from the list of open projects - this.projects = this.projects.filter((e) => { return e !== newProject; }); - - throw err; - } - - return newProject!; - } - - public async focusProject(project?: Project): Promise { - if (project && this.projects.includes(project)) { - await this.projectTreeViewProvider.focus(project); - await vscode.commands.executeCommand(constants.sqlDatabaseProjectsViewFocusCommand); - } + public refreshProjectsTree(workspaceTreeItem: dataworkspace.WorkspaceTreeItem): void { + (workspaceTreeItem.treeDataProvider as SqlDatabaseProjectTreeViewProvider).notifyTreeDataChanged(); } /** @@ -166,31 +93,19 @@ export class ProjectsController { return newProjFilePath; } - public closeProject(treeNode: BaseProjectTreeItem) { - const project = this.getProjectFromContext(treeNode); - this.projects = this.projects.filter((e) => { return e !== project; }); - - if (this.projFileWatchers.has(project.projectFilePath)) { - this.projFileWatchers.get(project.projectFilePath)!.dispose(); - this.projFileWatchers.delete(project.projectFilePath); - } - - this.refreshProjectsTree(); - } - /** * Builds a project, producing a dacpac * @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project * @returns path of the built dacpac */ - public async buildProject(treeNode: BaseProjectTreeItem): Promise; + public async buildProject(treeNode: dataworkspace.WorkspaceTreeItem): Promise; /** * Builds a project, producing a dacpac * @param project Project to be built * @returns path of the built dacpac */ public async buildProject(project: Project): Promise; - public async buildProject(context: Project | BaseProjectTreeItem | WorkspaceTreeItem): Promise { + public async buildProject(context: Project | dataworkspace.WorkspaceTreeItem): Promise { const project: Project = this.getProjectFromContext(context); // Check mssql extension for project dlls (tracking issue #10273) @@ -208,7 +123,7 @@ export class ProjectsController { } catch (err) { vscode.window.showErrorMessage(constants.projBuildFailed(utils.getErrorMessage(err))); - return undefined; + return ''; } } @@ -216,18 +131,18 @@ export class ProjectsController { * Builds and publishes a project * @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project */ - public publishProject(treeNode: BaseProjectTreeItem): PublishDatabaseDialog; + public publishProject(treeNode: dataworkspace.WorkspaceTreeItem): PublishDatabaseDialog; /** * Builds and publishes a project * @param project Project to be built and published */ public publishProject(project: Project): PublishDatabaseDialog; - public publishProject(context: Project | BaseProjectTreeItem): PublishDatabaseDialog { + public publishProject(context: Project | dataworkspace.WorkspaceTreeItem): PublishDatabaseDialog { const project: Project = this.getProjectFromContext(context); let publishDatabaseDialog = this.getPublishDialog(project); - publishDatabaseDialog.publish = async (proj, prof) => await this.executionCallback(proj, prof); - publishDatabaseDialog.generateScript = async (proj, prof) => await this.executionCallback(proj, prof); + publishDatabaseDialog.publish = async (proj, prof) => await this.publishProjectCallback(proj, prof); + publishDatabaseDialog.generateScript = async (proj, prof) => await this.publishProjectCallback(proj, prof); publishDatabaseDialog.readPublishProfile = async (profileUri) => await this.readPublishProfileCallback(profileUri); publishDatabaseDialog.openDialog(); @@ -235,7 +150,7 @@ export class ProjectsController { return publishDatabaseDialog; } - public async executionCallback(project: Project, settings: IPublishSettings | IGenerateScriptSettings): Promise { + public async publishProjectCallback(project: Project, settings: IPublishSettings | IGenerateScriptSettings): Promise { const dacpacPath = await this.buildProject(project); if (!dacpacPath) { @@ -268,7 +183,7 @@ export class ProjectsController { } } - public async schemaCompare(treeNode: BaseProjectTreeItem): Promise { + public async schemaCompare(treeNode: dataworkspace.WorkspaceTreeItem): Promise { // check if schema compare extension is installed if (vscode.extensions.getExtension(constants.schemaCompareExtensionId)) { // build project @@ -285,9 +200,9 @@ export class ProjectsController { } } - public async addFolderPrompt(treeNode: BaseProjectTreeItem) { + public async addFolderPrompt(treeNode: dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(treeNode); - const relativePathToParent = this.getRelativePath(treeNode); + const relativePathToParent = this.getRelativePath(treeNode.element); const absolutePathToParent = path.join(project.projectFolderPath, relativePathToParent); const newFolderName = await this.promptForNewObjectName(new templates.ProjectScriptType(templates.folder, constants.folderFriendlyName, ''), project, absolutePathToParent); @@ -308,7 +223,7 @@ export class ProjectsController { } await project.addFolderItem(relativeFolderPath); - this.refreshProjectsTree(); + this.refreshProjectsTree(treeNode); } catch (err) { vscode.window.showErrorMessage(utils.getErrorMessage(err)); } @@ -320,11 +235,11 @@ export class ProjectsController { return sameName && sameLocation; } - public async addItemPromptFromNode(treeNode: BaseProjectTreeItem, itemTypeName?: string) { - await this.addItemPrompt(this.getProjectFromContext(treeNode), this.getRelativePath(treeNode), itemTypeName); + public async addItemPromptFromNode(treeNode: dataworkspace.WorkspaceTreeItem, itemTypeName?: string): Promise { + await this.addItemPrompt(this.getProjectFromContext(treeNode), this.getRelativePath(treeNode.element), itemTypeName, treeNode.treeDataProvider as SqlDatabaseProjectTreeViewProvider); } - public async addItemPrompt(project: Project, relativePath: string, itemTypeName?: string) { + public async addItemPrompt(project: Project, relativePath: string, itemTypeName?: string, treeDataProvider?: SqlDatabaseProjectTreeViewProvider): Promise { if (!itemTypeName) { const items: vscode.QuickPickItem[] = []; @@ -366,37 +281,38 @@ export class ProjectsController { const newEntry = await project.addScriptItem(relativeFilePath, newFileText, itemType.type); await vscode.commands.executeCommand(constants.vscodeOpenCommand, newEntry.fsUri); - - this.refreshProjectsTree(); + treeDataProvider?.notifyTreeDataChanged(); } catch (err) { vscode.window.showErrorMessage(utils.getErrorMessage(err)); } } - public async exclude(context: FileNode | FolderNode): Promise { - const project = this.getProjectFromContext(context); + public async exclude(context: dataworkspace.WorkspaceTreeItem): Promise { + const node = context.element as BaseProjectTreeItem; + const project = this.getProjectFromContext(node); - const fileEntry = this.getFileProjectEntry(project, context); + const fileEntry = this.getFileProjectEntry(project, node); if (fileEntry) { await project.exclude(fileEntry); } else { - vscode.window.showErrorMessage(constants.unableToPerformAction(constants.excludeAction, context.uri.path)); + vscode.window.showErrorMessage(constants.unableToPerformAction(constants.excludeAction, node.uri.path)); } - this.refreshProjectsTree(); + this.refreshProjectsTree(context); } - public async delete(context: BaseProjectTreeItem): Promise { - const project = this.getProjectFromContext(context); + public async delete(context: dataworkspace.WorkspaceTreeItem): Promise { + const node = context.element as BaseProjectTreeItem; + const project = this.getProjectFromContext(node); let confirmationPrompt; - if (context instanceof DatabaseReferenceTreeItem) { - confirmationPrompt = constants.deleteReferenceConfirmation(context.friendlyName); - } else if (context instanceof FolderNode) { - confirmationPrompt = constants.deleteConfirmationContents(context.friendlyName); + if (node instanceof DatabaseReferenceTreeItem) { + confirmationPrompt = constants.deleteReferenceConfirmation(node.friendlyName); + } else if (node instanceof FolderNode) { + confirmationPrompt = constants.deleteConfirmationContents(node.friendlyName); } else { - confirmationPrompt = constants.deleteConfirmation(context.friendlyName); + confirmationPrompt = constants.deleteConfirmation(node.friendlyName); } const response = await vscode.window.showWarningMessage(confirmationPrompt, { modal: true }, constants.yesString); @@ -407,15 +323,15 @@ export class ProjectsController { let success = false; - if (context instanceof DatabaseReferenceTreeItem) { - const databaseReference = this.getDatabaseReference(project, context); + if (node instanceof DatabaseReferenceTreeItem) { + const databaseReference = this.getDatabaseReference(project, node); if (databaseReference) { await project.deleteDatabaseReference(databaseReference); success = true; } - } else if (context instanceof FileNode || FolderNode) { - const fileEntry = this.getFileProjectEntry(project, context); + } else if (node instanceof FileNode || FolderNode) { + const fileEntry = this.getFileProjectEntry(project, node); if (fileEntry) { await project.deleteFileFolder(fileEntry); @@ -424,9 +340,9 @@ export class ProjectsController { } if (success) { - this.refreshProjectsTree(); + this.refreshProjectsTree(context); } else { - vscode.window.showErrorMessage(constants.unableToPerformAction(constants.deleteAction, context.uri.path)); + vscode.window.showErrorMessage(constants.unableToPerformAction(constants.deleteAction, node.uri.path)); } } @@ -457,7 +373,7 @@ export class ProjectsController { * Opens the folder containing the project * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async openContainingFolder(context: BaseProjectTreeItem): Promise { + public async openContainingFolder(context: dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(context); await vscode.commands.executeCommand(constants.revealFileInOsCommand, vscode.Uri.file(project.projectFilePath)); } @@ -467,7 +383,7 @@ export class ProjectsController { * reload their project. * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async editProjectFile(context: BaseProjectTreeItem): Promise { + public async editProjectFile(context: dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(context); try { @@ -475,11 +391,11 @@ export class ProjectsController { const projFileWatcher: vscode.FileSystemWatcher = vscode.workspace.createFileSystemWatcher(project.projectFilePath); this.projFileWatchers.set(project.projectFilePath, projFileWatcher); - projFileWatcher.onDidChange(async (projectFileUri: vscode.Uri) => { + projFileWatcher.onDidChange(async () => { const result = await vscode.window.showInformationMessage(constants.reloadProject, constants.yesString, constants.noString); if (result === constants.yesString) { - this.reloadProject(projectFileUri); + this.reloadProject(context); } }); @@ -500,12 +416,12 @@ export class ProjectsController { * Reloads the given project. Throws an error if given project is not a valid open project. * @param projectFileUri the uri of the project to be reloaded */ - public async reloadProject(projectFileUri: vscode.Uri) { - const project = this.projects.find((e) => e.projectFilePath === projectFileUri.fsPath); + public async reloadProject(context: dataworkspace.WorkspaceTreeItem): Promise { + const project = this.getProjectFromContext(context); if (project) { // won't open any newly referenced projects, but otherwise matches the behavior of reopening the project await project.readProjFile(); - this.refreshProjectsTree(); + this.refreshProjectsTree(context); } else { throw new Error(constants.invalidProjectReload); } @@ -515,7 +431,7 @@ export class ProjectsController { * Changes the project's DSP to the selected target platform * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async changeTargetPlatform(context: Project | BaseProjectTreeItem): Promise { + public async changeTargetPlatform(context: Project | dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(context); const selectedTargetPlatform = (await vscode.window.showQuickPick((Array.from(constants.targetPlatformToVersion.keys())).map(version => { return { label: version }; }), { @@ -533,23 +449,24 @@ export class ProjectsController { * Adds a database reference to the project * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async addDatabaseReference(context: Project | BaseProjectTreeItem): Promise { + public async addDatabaseReference(context: Project | dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(context); const addDatabaseReferenceDialog = this.getAddDatabaseReferenceDialog(project); - addDatabaseReferenceDialog.addReference = async (proj, prof) => await this.addDatabaseReferenceCallback(proj, prof); + addDatabaseReferenceDialog.addReference = async (proj, prof) => await this.addDatabaseReferenceCallback(proj, prof, context as dataworkspace.WorkspaceTreeItem); addDatabaseReferenceDialog.openDialog(); return addDatabaseReferenceDialog; } - public async addDatabaseReferenceCallback(project: Project, settings: ISystemDatabaseReferenceSettings | IDacpacReferenceSettings | IProjectReferenceSettings): Promise { + public async addDatabaseReferenceCallback(project: Project, settings: ISystemDatabaseReferenceSettings | IDacpacReferenceSettings | IProjectReferenceSettings, context: dataworkspace.WorkspaceTreeItem): Promise { try { if ((settings).projectName !== undefined) { // get project path and guid const projectReferenceSettings = settings as IProjectReferenceSettings; - const referencedProject = this.projects.find(p => p.projectFileName === projectReferenceSettings.projectName); + const workspaceProjects = await utils.getSqlProjectsInWorkspace(); + const referencedProject = await Project.openProject(workspaceProjects.filter(p => path.parse(p.fsPath).name === projectReferenceSettings.projectName)[0].fsPath); const relativePath = path.relative(project.projectFolderPath, referencedProject?.projectFilePath!); projectReferenceSettings.projectRelativePath = vscode.Uri.file(relativePath); projectReferenceSettings.projectGuid = referencedProject?.projectGuid!; @@ -571,23 +488,64 @@ export class ProjectsController { await project.addDatabaseReference(settings); } - this.refreshProjectsTree(); + this.refreshProjectsTree(context); } catch (err) { vscode.window.showErrorMessage(utils.getErrorMessage(err)); } } + /** + * Creates a new SQL database project from the existing database, + * prompting the user for a name, file path location and extract target + */ + public async createProjectFromDatabase(context: azdata.IConnectionProfile | any): Promise { - public async validateExternalStreamingJob(node: ExternalStreamingJobFileNode): Promise { + // TODO: Refactor code + try { + const workspaceApi = utils.getDataWorkspaceExtensionApi(); + + const model: ImportDataModel | undefined = await this.getModelFromContext(context); + + if (!model) { + return; // cancelled by user + } + model.projName = await this.getProjectName(model.database); + let newProjFolderUri = (await this.getFolderLocation()).fsPath; + model.extractTarget = await this.getExtractTarget(); + model.version = '1.0.0.0'; + + const newProjFilePath = await this.createNewProject(model.projName, vscode.Uri.file(newProjFolderUri), true); + model.filePath = path.dirname(newProjFilePath); + + if (model.extractTarget === mssql.ExtractTarget.file) { + model.filePath = path.join(model.filePath, model.projName + '.sql'); // File extractTarget specifies the exact file rather than the containing folder + } + + const project = await Project.openProject(newProjFilePath); + await this.createProjectFromDatabaseApiCall(model); // Call ExtractAPI in DacFx Service + let fileFolderList: string[] = model.extractTarget === mssql.ExtractTarget.file ? [model.filePath] : await this.generateList(model.filePath); // Create a list of all the files and directories to be added to project + + await project.addToProject(fileFolderList); // Add generated file structure to the project + + // add project to workspace + workspaceApi.showProjectsView(); + await workspaceApi.addProjectsToWorkspace([vscode.Uri.file(newProjFilePath)]); + } + catch (err) { + vscode.window.showErrorMessage(utils.getErrorMessage(err)); + } + } + + public async validateExternalStreamingJob(node: dataworkspace.WorkspaceTreeItem): Promise { const project: Project = this.getProjectFromContext(node); let dacpacPath: string = project.dacpacOutputPath; if (!await utils.exists(dacpacPath)) { - dacpacPath = await this.buildProject(node); + dacpacPath = await this.buildProject(project); } - const streamingJobDefinition: string = (await fs.readFile(node.fileSystemUri.fsPath)).toString(); + const streamingJobDefinition: string = (await fs.readFile(node.element.fileSystemUri.fsPath)).toString(); const dacFxService = await this.getDaxFxService(); const result: mssql.ValidateStreamingJobResult = await dacFxService.validateStreamingJob(dacpacPath, streamingJobDefinition); @@ -631,9 +589,9 @@ export class ProjectsController { } } - private getProjectFromContext(context: Project | BaseProjectTreeItem | WorkspaceTreeItem): Project { + private getProjectFromContext(context: Project | BaseProjectTreeItem | dataworkspace.WorkspaceTreeItem): Project { if ('element' in context) { - return context.element.project; + return context.element.root.project; } if (context instanceof Project) { @@ -642,8 +600,7 @@ export class ProjectsController { if (context.root instanceof ProjectRootTreeItem) { return (context.root).project; - } - else { + } else { throw new Error(constants.unexpectedProjectContext(context.uri.path)); } } @@ -692,45 +649,6 @@ export class ProjectsController { return treeNode instanceof FolderNode ? utils.trimUri(treeNode.root.uri, treeNode.uri) : ''; } - /** - * Imports a new SQL database project from the existing database, - * prompting the user for a name, file path location and extract target - */ - public async importNewDatabaseProject(context: azdata.IConnectionProfile | any): Promise { - - // TODO: Refactor code - try { - const model: ImportDataModel | undefined = await this.getModelFromContext(context); - - if (!model) { - return; // cancelled by user - } - model.projName = await this.getProjectName(model.database); - let newProjFolderUri = (await this.getFolderLocation()).fsPath; - model.extractTarget = await this.getExtractTarget(); - model.version = '1.0.0.0'; - - newProjectTool.updateSaveLocationSetting(); - - const newProjFilePath = await this.createNewProject(model.projName, vscode.Uri.file(newProjFolderUri), true); - model.filePath = path.dirname(newProjFilePath); - - if (model.extractTarget === mssql.ExtractTarget.file) { - model.filePath = path.join(model.filePath, model.projName + '.sql'); // File extractTarget specifies the exact file rather than the containing folder - } - - const project = await Project.openProject(newProjFilePath); - await this.importApiCall(model); // Call ExtractAPI in DacFx Service - let fileFolderList: string[] = model.extractTarget === mssql.ExtractTarget.file ? [model.filePath] : await this.generateList(model.filePath); // Create a list of all the files and directories to be added to project - - await project.addToProject(fileFolderList); // Add generated file structure to the project - await this.openProject(vscode.Uri.file(newProjFilePath)); - } - catch (err) { - vscode.window.showErrorMessage(utils.getErrorMessage(err)); - } - } - public async getModelFromContext(context: any): Promise { let model = {}; @@ -861,13 +779,13 @@ export class ProjectsController { return projUri; } - public async importApiCall(model: ImportDataModel): Promise { + public async createProjectFromDatabaseApiCall(model: ImportDataModel): Promise { let ext = vscode.extensions.getExtension(mssql.extension.name)!; const service = (await ext.activate() as mssql.IExtension).dacFx; const ownerUri = await azdata.connection.getUriForConnection(model.serverId); - await service.importDatabaseProject(model.database, model.filePath, model.projName, model.version, ownerUri, model.extractTarget, azdata.TaskExecutionMode.execute); + await service.createProjectFromDatabase(model.database, model.filePath, model.projName, model.version, ownerUri, model.extractTarget, azdata.TaskExecutionMode.execute); // TODO: Check for success; throw error } diff --git a/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts index df5e153788..15263dee60 100644 --- a/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts @@ -261,26 +261,12 @@ export class AddDatabaseReferenceDialog { this.setDefaultDatabaseValues(); }); - // get projects in workspace - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders?.length) { - let projectFiles = await utils.getSqlProjectFilesInFolder(workspaceFolders[0].uri.fsPath); + // get projects in workspace and filter to only sql projects + let projectFiles: vscode.Uri[] = utils.getSqlProjectsInWorkspace(); - // check if current project is in same open folder (should only be able to add a reference to another project in - // the folder if the current project is also in the folder) - if (projectFiles.find(p => p === utils.getPlatformSafeFileEntryPath(this.project.projectFilePath))) { - // filter out current project - projectFiles = projectFiles.filter(p => p !== utils.getPlatformSafeFileEntryPath(this.project.projectFilePath)); - - projectFiles.forEach(p => { - projectFiles[projectFiles.indexOf(p)] = path.parse(p).name; - }); - - this.projectDropdown.values = projectFiles; - } else { - this.projectDropdown.values = []; - } - } + // filter out current project + projectFiles = projectFiles.filter(p => p.fsPath !== this.project.projectFilePath); + this.projectDropdown.values = projectFiles.map(p => path.parse(p.fsPath).name); return { component: this.projectDropdown, diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts index d1978482dd..3db9a1c46a 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts @@ -510,7 +510,7 @@ export class PublishDatabaseDialog { private async updateConnectionComponents(connectionTextboxValue: string, connectionId: string) { this.targetConnectionTextBox!.value = connectionTextboxValue; - this.targetConnectionTextBox!.placeHolder = connectionTextboxValue; + this.targetConnectionTextBox!.updateProperty('title', connectionTextboxValue); // populate database dropdown with the databases for this connection if (connectionId) { @@ -583,7 +583,7 @@ export class PublishDatabaseDialog { // show file path in text box and hover text this.loadProfileTextBox!.value = fileUris[0].fsPath; - this.loadProfileTextBox!.placeHolder = fileUris[0].fsPath; + this.loadProfileTextBox!.updateProperty('title', fileUris[0].fsPath); } }); diff --git a/extensions/sql-database-projects/src/models/dataSources/dataSources.ts b/extensions/sql-database-projects/src/models/dataSources/dataSources.ts index ce2339fd78..c4380bb590 100644 --- a/extensions/sql-database-projects/src/models/dataSources/dataSources.ts +++ b/extensions/sql-database-projects/src/models/dataSources/dataSources.ts @@ -5,7 +5,6 @@ import { promises as fs } from 'fs'; import * as constants from '../../common/constants'; -import { SqlConnectionDataSource } from './sqlConnectionStringSource'; /** * Abstract class for a datasource in a project @@ -53,11 +52,11 @@ export async function load(dataSourcesFilePath: string): Promise { // TODO: do we have a construct for parsing version numbers? switch (rawJsonContents.version) { case '0.0.0': - const dataSources: DataSourceFileJson = rawJsonContents as DataSourceFileJson; + // const dataSources: DataSourceFileJson = rawJsonContents as DataSourceFileJson; - for (const source of dataSources.datasources) { - output.push(createDataSource(source)); - } + // for (const source of dataSources.datasources) { + // output.push(createDataSource(source)); + // } break; default: @@ -70,11 +69,12 @@ export async function load(dataSourcesFilePath: string): Promise { /** * Creates DataSource object from JSON */ -function createDataSource(json: DataSourceJson): DataSource { - switch (json.type) { - case SqlConnectionDataSource.type: - return SqlConnectionDataSource.fromJson(json); - default: - throw new Error(constants.unknownDataSourceType + json.type); - } -} +// Commenting this out because circular dependency with SqlConnectionDataSource was causing extension to not activate +// function createDataSource(json: DataSourceJson): DataSource { +// switch (json.type) { +// case SqlConnectionDataSource.type: +// return SqlConnectionDataSource.fromJson(json); +// default: +// throw new Error(constants.unknownDataSourceType + json.type); +// } +// } diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 057c008286..a5718c8130 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -53,6 +53,7 @@ export class Project { public static async openProject(projectFilePath: string): Promise { const proj = new Project(projectFilePath); await proj.readProjFile(); + await proj.updateProjectForRoundTrip(); return proj; } @@ -60,7 +61,7 @@ export class Project { /** * Reads the project setting and contents from the file */ - public async readProjFile() { + public async readProjFile(): Promise { this.resetProject(); const projFileText = await fs.readFile(this.projectFilePath); @@ -178,7 +179,7 @@ export class Project { } } - private resetProject() { + private resetProject(): void { this.files = []; this.importedTargets = []; this.databaseReferences = []; @@ -189,11 +190,27 @@ export class Project { this.projFileXmlDoc = undefined; } - public async updateProjectForRoundTrip() { - await fs.copyFile(this.projectFilePath, this.projectFilePath + '_backup'); - await this.updateImportToSupportRoundTrip(); - await this.updatePackageReferenceInProjFile(); - await this.updateAfterCleanTargetInProjFile(); + public async updateProjectForRoundTrip(): Promise { + if (this.importedTargets.includes(constants.NetCoreTargets) && !this.containsSSDTOnlySystemDatabaseReferences()) { + return; + } + + if (!this.importedTargets.includes(constants.NetCoreTargets)) { + const result = await window.showWarningMessage(constants.updateProjectForRoundTrip, constants.yesString, constants.noString); + if (result === constants.yesString) { + await fs.copyFile(this.projectFilePath, this.projectFilePath + '_backup'); + await this.updateImportToSupportRoundTrip(); + await this.updatePackageReferenceInProjFile(); + await this.updateAfterCleanTargetInProjFile(); + await this.updateSystemDatabaseReferencesInProjFile(); + } + } else if (this.containsSSDTOnlySystemDatabaseReferences()) { + const result = await window.showWarningMessage(constants.updateProjectDatabaseReferencesForRoundTrip, constants.yesString, constants.noString); + if (result === constants.yesString) { + await fs.copyFile(this.projectFilePath, this.projectFilePath + '_backup'); + await this.updateSystemDatabaseReferencesInProjFile(); + } + } } private async updateImportToSupportRoundTrip(): Promise { @@ -460,7 +477,7 @@ export class Project { * @param name name of the variable * @param defaultValue */ - public async addSqlCmdVariable(name: string, defaultValue: string) { + public async addSqlCmdVariable(name: string, defaultValue: string): Promise { const sqlCmdVariableEntry = new SqlCmdVariableProjectEntry(name, defaultValue); await this.addToProjFile(sqlCmdVariableEntry); } @@ -851,7 +868,7 @@ export class Project { await this.serializeToProjFile(this.projFileXmlDoc); } - private async removeFromProjFile(entries: ProjectEntry | ProjectEntry[]) { + private async removeFromProjFile(entries: ProjectEntry | ProjectEntry[]): Promise { if (entries instanceof ProjectEntry) { entries = [entries]; } @@ -876,7 +893,7 @@ export class Project { await this.serializeToProjFile(this.projFileXmlDoc); } - private async serializeToProjFile(projFileContents: any) { + private async serializeToProjFile(projFileContents: any): Promise { let xml = new xmldom.XMLSerializer().serializeToString(projFileContents); xml = xmlFormat(xml, { collapseContent: true, indentation: ' ', lineSeparator: os.EOL }); // TODO: replace diff --git a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts index 871db897ee..42d2cc50ba 100644 --- a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts +++ b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts @@ -5,14 +5,19 @@ import * as dataworkspace from 'dataworkspace'; import * as vscode from 'vscode'; -import { sqlprojExtension, projectTypeDisplayName } from '../common/constants'; +import { sqlprojExtension, projectTypeDisplayName, projectTypeDescription, sqlDatabaseProjectTypeId } from '../common/constants'; import { IconPathHelper } from '../common/iconHelper'; import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider'; +import { ProjectsController } from '../controllers/projectController'; import { Project } from '../models/project'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvider { + constructor(private projectController: ProjectsController) { + + } + /** * Gets the project tree data provider * @param projectFile The project file Uri @@ -39,9 +44,22 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide */ get supportedProjectTypes(): dataworkspace.IProjectType[] { return [{ + id: sqlDatabaseProjectTypeId, projectFileExtension: sqlprojExtension.replace(/\./g, ''), displayName: projectTypeDisplayName, - icon: IconPathHelper.databaseProject + description: projectTypeDescription, + icon: IconPathHelper.colorfulSqlProject }]; } + + /** + * Create a project + * @param name name of the project + * @param location the parent directory + * @returns Uri of the newly created project file + */ + async createProject(name: string, location: vscode.Uri): Promise { + const projectFile = await this.projectController.createNewProject(name, location, true); + return vscode.Uri.file(projectFile); + } } diff --git a/extensions/sql-database-projects/src/test/dialogs/addDatabaseReferenceDialog.test.ts b/extensions/sql-database-projects/src/test/dialogs/addDatabaseReferenceDialog.test.ts index e1554d6ce8..cb0d2687d1 100644 --- a/extensions/sql-database-projects/src/test/dialogs/addDatabaseReferenceDialog.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/addDatabaseReferenceDialog.test.ts @@ -5,6 +5,10 @@ import * as should from 'should'; import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as TypeMoq from 'typemoq'; +import * as dataworkspace from 'dataworkspace'; import * as baselines from '../baselines/baselines'; import * as templates from '../../templates/templates'; import * as testUtils from '../testUtils'; @@ -17,6 +21,16 @@ describe('Add Database Reference Dialog', () => { await baselines.loadBaselines(); }); + beforeEach(function (): void { + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object }); + }); + + afterEach(function (): void { + sinon.restore(); + }); + it('Should open dialog successfully', async function (): Promise { const project = await testUtils.createTestProject(baselines.newProjectFileBaseline); const dialog = new AddDatabaseReferenceDialog(project); diff --git a/extensions/sql-database-projects/src/test/dialogs/publishDatabaseDialog.test.ts b/extensions/sql-database-projects/src/test/dialogs/publishDatabaseDialog.test.ts index 0853f246ac..fa24f04a77 100644 --- a/extensions/sql-database-projects/src/test/dialogs/publishDatabaseDialog.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/publishDatabaseDialog.test.ts @@ -14,7 +14,6 @@ import * as TypeMoq from 'typemoq'; import { PublishDatabaseDialog } from '../../dialogs/publishDatabaseDialog'; import { Project } from '../../models/project'; -import { SqlDatabaseProjectTreeViewProvider } from '../../controllers/databaseProjectTreeViewProvider'; import { ProjectsController } from '../../controllers/projectController'; import { IPublishSettings, IGenerateScriptSettings } from '../../models/IPublishSettings'; @@ -25,7 +24,7 @@ describe.skip('Publish Database Dialog', () => { }); it('Should open dialog successfully ', async function (): Promise { - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), true, 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); @@ -36,7 +35,7 @@ describe.skip('Publish Database Dialog', () => { }); it('Should create default database name correctly ', async function (): Promise { - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const projFolder = `TestProject_${new Date().getTime()}`; const projFileDir = path.join(os.tmpdir(), projFolder); diff --git a/extensions/sql-database-projects/src/test/mainController.test.ts b/extensions/sql-database-projects/src/test/mainController.test.ts index 9e9bef3868..cfdd0e597d 100644 --- a/extensions/sql-database-projects/src/test/mainController.test.ts +++ b/extensions/sql-database-projects/src/test/mainController.test.ts @@ -5,15 +5,11 @@ import * as should from 'should'; import * as path from 'path'; -import * as os from 'os'; -import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as baselines from './baselines/baselines'; import * as templates from '../templates/templates'; -import * as constants from '../common/constants'; import { createContext, TestContext } from './testContext'; import MainController from '../controllers/mainController'; -import { generateTestFolderPath, createTestProject } from './testUtils'; let testContext: TestContext; @@ -28,42 +24,6 @@ describe('MainController: main controller operations', function (): void { sinon.restore(); }); - it('Should create new project through MainController', async function (): Promise { - const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); - - sinon.stub(vscode.window, 'showInputBox').resolves('MyProjectName'); - sinon.stub(vscode.window, 'showOpenDialog').resolves([vscode.Uri.file(projFileDir)]); - sinon.replaceGetter(vscode.workspace, 'workspaceFolders', () => undefined); - - const controller = new MainController(testContext.context); - const proj = await controller.createNewProject(); - - should(proj).not.equal(undefined); - }); - - it('Should show error when no project name', async function (): Promise { - for (const name of ['', ' ', undefined]) { - const stub = sinon.stub(vscode.window, 'showInputBox').resolves(name); - const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const controller = new MainController(testContext.context); - await controller.createNewProject(); - should(spy.calledOnce).be.true('showErrorMessage should have been called exactly once'); - should(spy.calledWith(constants.projectNameRequired)).be.true(`showErrorMessage not called with expected message '${constants.projectNameRequired}' Actual '${spy.getCall(0).args[0]}'`); - stub.restore(); - spy.restore(); - } - }); - - it('Should show error when no location name', async function (): Promise { - sinon.stub(vscode.window, 'showInputBox').resolves('MyProjectName'); - sinon.stub(vscode.window, 'showOpenDialog').resolves(undefined); - const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const controller = new MainController(testContext.context); - await controller.createNewProject(); - should(spy.calledOnce).be.true('showErrorMessage should be called exactly once'); - should(spy.calledWith(constants.projectLocationRequired)).be.true(`showErrorMessage not called with expected message '${constants.projectLocationRequired}' Actual '${spy.getCall(0).args[0]}'`); - }); - it('Should create new instance without error', async function (): Promise { should.doesNotThrow(() => new MainController(testContext.context), 'Creating controller should not throw an error'); }); @@ -75,27 +35,4 @@ describe('MainController: main controller operations', function (): void { should.doesNotThrow(() => controller.activate(), 'activate() should not throw an error'); should.doesNotThrow(() => controller.dispose(), 'dispose() should not throw an error'); }); - - it('Should load projects in workspace', async function (): Promise { - const rootFolderPath = await generateTestFolderPath(); - const project = await createTestProject(baselines.openProjectFileBaseline, rootFolderPath); - const nestedFolder = path.join(rootFolderPath, 'nestedProject'); - const nestedProject = await createTestProject(baselines.openProjectFileBaseline, nestedFolder); - - const workspaceFolder: vscode.WorkspaceFolder = { - uri: vscode.Uri.file(rootFolderPath), - name: '', - index: 0 - }; - sinon.replaceGetter(vscode.workspace, 'workspaceFolders', () => [workspaceFolder]); - - const controller = new MainController(testContext.context); - should(controller.projController.projects.length).equal(0); - - await controller.loadProjectsInWorkspace(); - - should(controller.projController.projects.length).equal(2); - should(controller.projController.projects[0].projectFolderPath).equal(project.projectFolderPath); - should(controller.projController.projects[1].projectFolderPath).equal(nestedProject.projectFolderPath); - }); }); diff --git a/extensions/sql-database-projects/src/test/newProjectTool.test.ts b/extensions/sql-database-projects/src/test/newProjectTool.test.ts index 92d5c29752..cab9c66487 100644 --- a/extensions/sql-database-projects/src/test/newProjectTool.test.ts +++ b/extensions/sql-database-projects/src/test/newProjectTool.test.ts @@ -5,31 +5,41 @@ import * as vscode from 'vscode'; import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sinon from 'sinon'; +import * as dataworkspace from 'dataworkspace'; import * as newProjectTool from '../tools/newProjectTool'; -import * as constants from '../common/constants'; import { generateTestFolderPath, createTestFile } from './testUtils'; let previousSetting : string; let testFolderPath : string; describe('NewProjectTool: New project tool tests', function (): void { + const projectConfigurationKey = 'projects'; + const projectSaveLocationKey= 'defaultProjectSaveLocation'; + beforeEach(async function () { - previousSetting = await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey)[constants.projectSaveLocationKey]; + previousSetting = await vscode.workspace.getConfiguration(projectConfigurationKey)[projectSaveLocationKey]; testFolderPath = await generateTestFolderPath(); + + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file(testFolderPath)); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); }); afterEach(async function () { - await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, previousSetting, true); + await vscode.workspace.getConfiguration(projectConfigurationKey).update(projectSaveLocationKey, previousSetting, true); + sinon.restore(); }); it('Should generate correct default project names', async function (): Promise { - await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, testFolderPath, true); + await vscode.workspace.getConfiguration(projectConfigurationKey).update(projectSaveLocationKey, testFolderPath, true); should(newProjectTool.defaultProjectNameNewProj()).equal('DatabaseProject1'); should(newProjectTool.defaultProjectNameFromDb('master')).equal('DatabaseProjectmaster'); }); it('Should auto-increment default project names for new projects', async function (): Promise { - await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, testFolderPath, true); + await vscode.workspace.getConfiguration(projectConfigurationKey).update(projectSaveLocationKey, testFolderPath, true); should(newProjectTool.defaultProjectNameNewProj()).equal('DatabaseProject1'); await createTestFile('', 'DatabaseProject1', testFolderPath); @@ -40,7 +50,7 @@ describe('NewProjectTool: New project tool tests', function (): void { }); it('Should auto-increment default project names for import projects', async function (): Promise { - await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, testFolderPath, true); + await vscode.workspace.getConfiguration(projectConfigurationKey).update(projectSaveLocationKey, testFolderPath, true); should(newProjectTool.defaultProjectNameFromDb("master")).equal('DatabaseProjectmaster'); await createTestFile('', 'DatabaseProjectmaster', testFolderPath); diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index eec2709422..077eb848c0 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -91,6 +91,8 @@ describe('Project: sqlproj content operations', function (): void { should(project.postDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Script.PostDeployment1.sql')).not.equal(undefined, 'File Script.PostDeployment1.sql not read'); should(project.preDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Script.PreDeployment2.sql')).not.equal(undefined, 'File Script.PostDeployment2.sql not read'); should(project.noneDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Tables\\Script.PostDeployment1.sql')).not.equal(undefined, 'File Tables\\Script.PostDeployment1.sql not read'); + + sinon.restore(); }); it('Should add Folder and Build entries to sqlproj', async function (): Promise { @@ -580,43 +582,58 @@ describe('Project: round trip updates', function (): void { }); it('Should update SSDT project to work in ADS', async function (): Promise { - await testUpdateInRoundTrip(baselines.SSDTProjectFileBaseline, baselines.SSDTProjectAfterUpdateBaseline, true, true); + await testUpdateInRoundTrip(baselines.SSDTProjectFileBaseline, baselines.SSDTProjectAfterUpdateBaseline); }); it('Should update SSDT project with new system database references', async function (): Promise { - await testUpdateInRoundTrip(baselines.SSDTUpdatedProjectBaseline, baselines.SSDTUpdatedProjectAfterSystemDbUpdateBaseline, false, true); + await testUpdateInRoundTrip(baselines.SSDTUpdatedProjectBaseline, baselines.SSDTUpdatedProjectAfterSystemDbUpdateBaseline); }); it('Should update SSDT project to work in ADS handling pre-exsiting targets', async function (): Promise { - await testUpdateInRoundTrip(baselines.SSDTProjectBaselineWithCleanTarget, baselines.SSDTProjectBaselineWithCleanTargetAfterUpdate, true, false); + await testUpdateInRoundTrip(baselines.SSDTProjectBaselineWithCleanTarget, baselines.SSDTProjectBaselineWithCleanTargetAfterUpdate); + }); + + it('Should not update project and no backup file should be created when update to project is rejected', async function (): Promise { + sinon.stub(window, 'showWarningMessage').returns(Promise.resolve(constants.noString)); + // setup test files + const folderPath = await testUtils.generateTestFolderPath(); + const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.SSDTProjectFileBaseline, folderPath); + await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); + + const project = await Project.openProject(Uri.file(sqlProjPath).fsPath); + + should(await exists(sqlProjPath + '_backup')).equal(false); // backup file should not be generated + should(project.importedTargets.length).equal(2); // additional target should not be added by updateProjectForRoundTrip method + + sinon.restore(); + }); + + it('Should not show warning message for non-SSDT projects that have the additional information for Build', async function (): Promise { + // setup test files + const folderPath = await testUtils.generateTestFolderPath(); + const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, folderPath); + await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); + + const project = await Project.openProject(Uri.file(sqlProjPath).fsPath); // no error thrown + + should(project.importedTargets.length).equal(3); // additional target should exist by default }); }); -async function testUpdateInRoundTrip(fileBeforeupdate: string, fileAfterUpdate: string, testTargets: boolean, testReferences: boolean): Promise { +async function testUpdateInRoundTrip(fileBeforeupdate: string, fileAfterUpdate: string): Promise { + const stub = sinon.stub(window, 'showWarningMessage').returns(Promise.resolve(constants.yesString)); + projFilePath = await testUtils.createTestSqlProjFile(fileBeforeupdate); - const project = await Project.openProject(projFilePath); + const project = await Project.openProject(projFilePath); // project gets updated if needed in openProject() - if (testTargets) { - await testUpdateTargetsImportsRoundTrip(project); - } - - if (testReferences) { - await testAddReferencesInRoundTrip(project); - } + should(await exists(projFilePath + '_backup')).equal(true, 'Backup file should have been generated before the project was updated'); + should(project.importedTargets.length).equal(3); // additional target added by updateProjectForRoundTrip method let projFileText = (await fs.readFile(projFilePath)).toString(); should(projFileText).equal(fileAfterUpdate.trim()); + + should(stub.calledOnce).be.true('showWarningMessage should have been called exactly once'); + sinon.restore(); } -async function testUpdateTargetsImportsRoundTrip(project: Project): Promise { - should(project.importedTargets.length).equal(2); - await project.updateProjectForRoundTrip(); - should(await exists(projFilePath + '_backup')).equal(true); // backup file should be generated before the project is updated - should(project.importedTargets.length).equal(3); // additional target added by updateProjectForRoundTrip method -} -async function testAddReferencesInRoundTrip(project: Project): Promise { - // updating system db refs is separate from updating for roundtrip because new db refs could be added even after project is updated for roundtrip - should(project.containsSSDTOnlySystemDatabaseReferences()).equal(true); - await project.updateSystemDatabaseReferencesInProjFile(); -} diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index e2904624ac..06e474e197 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -10,6 +10,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as TypeMoq from 'typemoq'; import * as sinon from 'sinon'; +import * as dataworkspace from 'dataworkspace'; import * as baselines from './baselines/baselines'; import * as templates from '../templates/templates'; import * as testUtils from './testUtils'; @@ -65,7 +66,7 @@ describe('ProjectsController', function (): void { describe('project controller operations', function (): void { describe('Project file operations and prompting', function (): void { it('Should create new sqlproj file with correct values', async function (): Promise { - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), false, 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); @@ -75,54 +76,11 @@ describe('ProjectsController', function (): void { should(projFileText).equal(baselines.newProjectFileBaseline); }); - it('Should load Project and associated DataSources', async function (): Promise { - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); - - should(project.files.length).equal(10); // detailed sqlproj tests in their own test file - should(project.dataSources.length).equal(3); // detailed datasources tests in their own test file - }); - - it('Should load both project and referenced project', async function (): Promise { - // setup test projects - const folderPath = await testUtils.generateTestFolderPath(); - await fs.mkdir(path.join(folderPath, 'proj1')); - await fs.mkdir(path.join(folderPath, 'ReferencedProject')); - - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectWithProjectReferencesBaseline, path.join(folderPath, 'proj1')); - await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, path.join(folderPath, 'ReferencedProject')); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - await projController.openProject(vscode.Uri.file(sqlProjPath)); - - should(projController.projects.length).equal(2, 'Referenced project should have been opened when the project referencing it was opened'); - }); - - it('Should not keep failed to load project in project list.', async function (): Promise { - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile('empty file with no valid xml', folderPath); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - try { - await projController.openProject(vscode.Uri.file(sqlProjPath)); - should.fail(null, null, 'The given project not expected to open'); - } - catch { - should(projController.projects.length).equal(0, 'The added project should be removed'); - } - }); - it('Should return silently when no SQL object name provided in prompts', async function (): Promise { for (const name of ['', ' ', undefined]) { const showInputBoxStub = sinon.stub(vscode.window, 'showInputBox').resolves(name); const showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const project = new Project('FakePath'); should(project.files.length).equal(0); @@ -138,7 +96,7 @@ describe('ProjectsController', function (): void { const tableName = 'table1'; sinon.stub(vscode.window, 'showInputBox').resolves(tableName); const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const project = await testUtils.createTestProject(baselines.newProjectFileBaseline); should(project.files.length).equal(0, 'There should be no files'); @@ -154,14 +112,13 @@ describe('ProjectsController', function (): void { const folderName = 'folder1'; const stub = sinon.stub(vscode.window, 'showInputBox').resolves(folderName); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const project = await testUtils.createTestProject(baselines.newProjectFileBaseline); const projectRoot = new ProjectRootTreeItem(project); should(project.files.length).equal(0, 'There should be no other folders'); - await projController.addFolderPrompt(projectRoot); + await projController.addFolderPrompt(createWorkspaceTreeItem(projectRoot)); should(project.files.length).equal(1, 'Folder should be successfully added'); - projController.refreshProjectsTree(); stub.restore(); await verifyFolderNotAdded(folderName, projController, project, projectRoot); @@ -175,7 +132,7 @@ describe('ProjectsController', function (): void { const folderName = 'folder1'; const stub = sinon.stub(vscode.window, 'showInputBox').resolves(folderName); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const project = await testUtils.createTestProject(baselines.openProjectFileBaseline); const projectRoot = new ProjectRootTreeItem(project); @@ -190,7 +147,7 @@ describe('ProjectsController', function (): void { async function verifyFolderAdded(folderName: string, projController: ProjectsController, project: Project, node: BaseProjectTreeItem): Promise { const beforeFileCount = project.files.length; const stub = sinon.stub(vscode.window, 'showInputBox').resolves(folderName); - await projController.addFolderPrompt(node); + await projController.addFolderPrompt(createWorkspaceTreeItem(node)); should(project.files.length).equal(beforeFileCount + 1, `File count should be increased by one after adding the folder ${folderName}`); stub.restore(); } @@ -199,7 +156,7 @@ describe('ProjectsController', function (): void { const beforeFileCount = project.files.length; const showInputBoxStub = sinon.stub(vscode.window, 'showInputBox').resolves(folderName); const showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); - await projController.addFolderPrompt(node); + await projController.addFolderPrompt(createWorkspaceTreeItem(node)); should(showErrorMessageSpy.calledOnce).be.true('showErrorMessage should have been called exactly once'); const msg = constants.folderAlreadyExists(folderName); should(showErrorMessageSpy.calledWith(msg)).be.true(`showErrorMessage not called with expected message '${msg}' Actual '${showErrorMessageSpy.getCall(0).args[0]}'`); @@ -213,13 +170,13 @@ describe('ProjectsController', function (): void { const setupResult = await setupDeleteExcludeTest(proj); const scriptEntry = setupResult[0], projTreeRoot = setupResult[1], preDeployEntry = setupResult[2], postDeployEntry = setupResult[3], noneEntry = setupResult[4]; - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); - await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); - await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'anotherScript.sql')!); - await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment1.sql')!); - await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment2.sql')!); - await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'Script.PostDeployment1.sql')!); + await projController.delete(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0]) /* LowerFolder */); + await projController.delete(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'anotherScript.sql')!)); + await projController.delete(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment1.sql')!)); + await projController.delete(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment2.sql')!)); + await projController.delete(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PostDeployment1.sql')!)); proj = await Project.openProject(proj.projectFilePath); // reload edited sqlproj from disk @@ -239,7 +196,7 @@ describe('ProjectsController', function (): void { it('Should delete database references', async function (): Promise { // setup - openProject baseline has a system db reference to master const proj = await testUtils.createTestProject(baselines.openProjectFileBaseline); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.yesString)); // add dacpac reference @@ -261,9 +218,9 @@ describe('ProjectsController', function (): void { should(proj.databaseReferences.length).equal(3, 'Should start with 3 database references'); const databaseReferenceNodeChildren = projTreeRoot.children.find(x => x.friendlyName === constants.databaseReferencesNodeName)?.children; - await projController.delete(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'master')!); // system db reference - await projController.delete(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'test2')!); // dacpac reference - await projController.delete(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'project1')!); // project reference + await projController.delete(createWorkspaceTreeItem(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'master')!)); // system db reference + await projController.delete(createWorkspaceTreeItem(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'test2')!)); // dacpac reference + await projController.delete(createWorkspaceTreeItem(databaseReferenceNodeChildren?.find(x => x.friendlyName === 'project1')!)); // project reference // confirm result should(proj.databaseReferences.length).equal(0, 'All database references should have been deleted'); @@ -274,13 +231,13 @@ describe('ProjectsController', function (): void { const setupResult = await setupDeleteExcludeTest(proj); const scriptEntry = setupResult[0], projTreeRoot = setupResult[1], preDeployEntry = setupResult[2], postDeployEntry = setupResult[3], noneEntry = setupResult[4]; - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); - await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); - await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'anotherScript.sql')!); - await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment1.sql')!); - await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment2.sql')!); - await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'Script.PostDeployment1.sql')!); + await projController.exclude(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0]) /* LowerFolder */); + await projController.exclude(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'anotherScript.sql')!)); + await projController.exclude(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment1.sql')!)); + await projController.exclude(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PreDeployment2.sql')!)); + await projController.exclude(createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'Script.PostDeployment1.sql')!)); proj = await Project.openProject(proj.projectFilePath); // reload edited sqlproj from disk @@ -302,18 +259,22 @@ describe('ProjectsController', function (): void { const folderPath = await testUtils.generateTestFolderPath(); const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline, folderPath); const treeProvider = new SqlDatabaseProjectTreeViewProvider(); - const projController = new ProjectsController(treeProvider); - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); + const projController = new ProjectsController(); + const project = await Project.openProject(vscode.Uri.file(sqlProjPath).fsPath); + treeProvider.load([project]); // change the sql project file await fs.writeFile(sqlProjPath, baselines.newProjectFileWithScriptBaseline); should(project.files.length).equal(0); // call reload project - await projController.reloadProject(vscode.Uri.file(project.projectFilePath)); - should(project.files.length).equal(1); + await projController.reloadProject({ treeDataProvider: treeProvider, element: { root: { project: project } } }); + // calling this because this gets called in the projectProvider.getProjectTreeDataProvider(), which is called by workspaceTreeDataProvider + // when notifyTreeDataChanged() happens + treeProvider.load([project]); // check that the new project is in the tree + should(project.files.length).equal(1); should(treeProvider.getChildren()[0].children.find(c => c.friendlyName === 'Script1.sql')).not.equal(undefined); }); @@ -321,7 +282,7 @@ describe('ProjectsController', function (): void { const preDeployScriptName = 'PreDeployScript1.sql'; const postDeployScriptName = 'PostDeployScript1.sql'; - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const project = await testUtils.createTestProject(baselines.newProjectFileBaseline); sinon.stub(vscode.window, 'showInputBox').resolves(preDeployScriptName); @@ -339,9 +300,9 @@ describe('ProjectsController', function (): void { it('Should change target platform', async function (): Promise { sinon.stub(vscode.window, 'showQuickPick').resolves({ label: constants.sqlAzure }); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline); - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); + const project = await Project.openProject(sqlProjPath); should(project.getProjectTargetVersion()).equal(constants.targetPlatformToVersion.get(constants.sqlServer2019)); should(project.databaseReferences.length).equal(1, 'Project should have one database reference to master'); should(project.databaseReferences[0].fsUri.fsPath).containEql(constants.targetPlatformToVersion.get(constants.sqlServer2019)); @@ -387,12 +348,12 @@ describe('ProjectsController', function (): void { let projController = TypeMoq.Mock.ofType(ProjectsController); projController.callBase = true; projController.setup(x => x.getPublishDialog(TypeMoq.It.isAny())).returns(() => publishDialog.object); - projController.setup(x => x.executionCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IPublishSettings => true))).returns(() => { + projController.setup(x => x.publishProjectCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IPublishSettings => true))).returns(() => { holler = publishHoller; return Promise.resolve(undefined); }); - projController.setup(x => x.executionCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IGenerateScriptSettings => true))).returns(() => { + projController.setup(x => x.publishProjectCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IGenerateScriptSettings => true))).returns(() => { holler = generateHoller; return Promise.resolve(undefined); }); @@ -430,7 +391,7 @@ describe('ProjectsController', function (): void { projController.setup(x => x.getDaxFxService()).returns(() => Promise.resolve(testContext.dacFxService.object)); - await projController.object.executionCallback(new Project(''), { connectionUri: '', databaseName: '' }); + await projController.object.publishProjectCallback(new Project(''), { connectionUri: '', databaseName: '' }); should(builtDacpacPath).not.equal('', 'built dacpac path should be set'); should(publishedDacpacPath).not.equal('', 'published dacpac path should be set'); @@ -440,11 +401,15 @@ describe('ProjectsController', function (): void { }); }); - describe('import operations', function (): void { + describe('Create project from database', function (): void { + afterEach(() => { + sinon.restore(); + }); + it('Should create list of all files and folders correctly', async function (): Promise { const testFolderPath = await testUtils.createDummyFileStructure(); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); const fileList = await projController.generateList(testFolderPath); should(fileList.length).equal(15); // Parent folder + 2 files under parent folder + 2 directories with 5 files each @@ -456,7 +421,7 @@ describe('ProjectsController', function (): void { let testFolderPath = await testUtils.generateTestFolderPath(); testFolderPath += '_nonexistentFolder'; // Modify folder path to point to a nonexistent location - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); await projController.generateList(testFolderPath); should(spy.calledOnce).be.true('showErrorMessage should have been called'); @@ -466,11 +431,16 @@ describe('ProjectsController', function (): void { it('Should show error when no project name provided', async function (): Promise { for (const name of ['', ' ', undefined]) { + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file('/test/folder')); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); + sinon.stub(vscode.workspace, 'workspaceFile').value(vscode.Uri.file('/test/folder/ws.code-workspace')); sinon.stub(vscode.window, 'showInputBox').resolves(name); const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + const projController = new ProjectsController(); + await projController.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(spy.calledOnce).be.true('showErrorMessage should have been called'); should(spy.calledWith(constants.projectNameRequired)).be.true(`showErrorMessage not called with expected message '${constants.projectNameRequired}' Actual '${spy.getCall(0).args[0]}'`); sinon.restore(); @@ -478,37 +448,52 @@ describe('ProjectsController', function (): void { }); it('Should show error when no target information provided', async function (): Promise { + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file('/test/folder')); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); + sinon.stub(vscode.workspace, 'workspaceFile').value(vscode.Uri.file('/test/folder/ws.code-workspace')); sinon.stub(vscode.window, 'showInputBox').resolves('MyProjectName'); sinon.stub(vscode.window, 'showQuickPick').resolves(undefined); sinon.stub(vscode.window, 'showOpenDialog').resolves([vscode.Uri.file('fakePath')]); const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + const projController = new ProjectsController(); + await projController.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(spy.calledOnce).be.true('showErrorMessage should have been called'); should(spy.calledWith(constants.extractTargetRequired)).be.true(`showErrorMessage not called with expected message '${constants.extractTargetRequired}' Actual '${spy.getCall(0).args[0]}'`); }); it('Should show error when no location provided with ExtractTarget = File', async function (): Promise { + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file('/test/folder')); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); + sinon.stub(vscode.workspace, 'workspaceFile').value(vscode.Uri.file('/test/folder/ws.code-workspace')); sinon.stub(vscode.window, 'showInputBox').resolves('MyProjectName'); sinon.stub(vscode.window, 'showOpenDialog').resolves(undefined); sinon.stub(vscode.window, 'showQuickPick').resolves({ label: constants.file }); const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + const projController = new ProjectsController(); + await projController.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(spy.calledOnce).be.true('showErrorMessage should have been called'); should(spy.calledWith(constants.projectLocationRequired)).be.true(`showErrorMessage not called with expected message '${constants.projectLocationRequired}' Actual '${spy.getCall(0).args[0]}'`); }); it('Should show error when no location provided with ExtractTarget = SchemaObjectType', async function (): Promise { + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file('/test/folder')); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); + sinon.stub(vscode.workspace, 'workspaceFile').value(vscode.Uri.file('/test/folder/ws.code-workspace')); sinon.stub(vscode.window, 'showInputBox').resolves('MyProjectName'); sinon.stub(vscode.window, 'showQuickPick').resolves({ label: constants.schemaObjectType }); sinon.stub(vscode.window, 'showOpenDialog').resolves(undefined); const spy = sinon.spy(vscode.window, 'showErrorMessage'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + const projController = new ProjectsController(); + await projController.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(spy.calledOnce).be.true('showErrorMessage should have been called'); should(spy.calledWith(constants.projectLocationRequired)).be.true(`showErrorMessage not called with expected message '${constants.projectLocationRequired}' Actual '${spy.getCall(0).args[0]}'`); }); @@ -517,6 +502,11 @@ describe('ProjectsController', function (): void { const projectName = 'MyProjectName'; let folderPath = await testUtils.generateTestFolderPath(); + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.defaultProjectSaveLocation).returns(() => vscode.Uri.file('/test/folder')); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => []); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object}); + sinon.stub(vscode.workspace, 'workspaceFile').value(vscode.Uri.file('/test/folder/ws.code-workspace')); sinon.stub(vscode.window, 'showInputBox').resolves(projectName); const showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: constants.file }); sinon.stub(vscode.window, 'showOpenDialog').callsFake(() => Promise.resolve([vscode.Uri.file(folderPath)])); @@ -526,9 +516,9 @@ describe('ProjectsController', function (): void { let projController = TypeMoq.Mock.ofType(ProjectsController, undefined, undefined, new SqlDatabaseProjectTreeViewProvider()); projController.callBase = true; - projController.setup(x => x.importApiCall(TypeMoq.It.isAny())).returns(async (model) => { importPath = model.filePath; }); + projController.setup(x => x.createProjectFromDatabaseApiCall(TypeMoq.It.isAny())).returns(async (model) => { importPath = model.filePath; }); - await projController.object.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + await projController.object.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(importPath).equal(vscode.Uri.file(path.join(folderPath, projectName, projectName + '.sql')).fsPath, `model.filePath should be set to a specific file for ExtractTarget === file, but was ${importPath}`); // reset for counter-test @@ -536,7 +526,7 @@ describe('ProjectsController', function (): void { folderPath = await testUtils.generateTestFolderPath(); showQuickPickStub.resolves({ label: constants.schemaObjectType }); - await projController.object.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + await projController.object.createProjectFromDatabase({ connectionProfile: mockConnectionProfile }); should(importPath).equal(vscode.Uri.file(path.join(folderPath, projectName)).fsPath, `model.filePath should be set to a folder for ExtractTarget !== file, but was ${importPath}`); }); @@ -552,7 +542,7 @@ describe('ProjectsController', function (): void { options: {} }); - let projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + let projController = new ProjectsController(); let result = await projController.getModelFromContext(undefined); @@ -590,14 +580,16 @@ describe('ProjectsController', function (): void { const addDbReferenceDialog = TypeMoq.Mock.ofType(AddDatabaseReferenceDialog, undefined, undefined, proj); addDbReferenceDialog.callBase = true; addDbReferenceDialog.setup(x => x.addReferenceClick()).returns(() => { - projController.object.addDatabaseReferenceCallback(proj, { systemDb: SystemDatabase.master, databaseName: 'master', suppressMissingDependenciesErrors: false }); + projController.object.addDatabaseReferenceCallback(proj, + { systemDb: SystemDatabase.master, databaseName: 'master', suppressMissingDependenciesErrors: false }, + { treeDataProvider: new SqlDatabaseProjectTreeViewProvider(), element: undefined }); return Promise.resolve(undefined); }); const projController = TypeMoq.Mock.ofType(ProjectsController); projController.callBase = true; projController.setup(x => x.getAddDatabaseReferenceDialog(TypeMoq.It.isAny())).returns(() => addDbReferenceDialog.object); - projController.setup(x => x.addDatabaseReferenceCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IDacpacReferenceSettings => true))).returns(() => { + projController.setup(x => x.addDatabaseReferenceCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IDacpacReferenceSettings => true), TypeMoq.It.isAny())).returns(() => { holler = addDbRefHoller; return Promise.resolve(undefined); }); @@ -609,14 +601,16 @@ describe('ProjectsController', function (): void { }); it('Should not allow adding circular project references', async function (): Promise { - const showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); - const projPath1 = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline); const projPath2 = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); - const project1 = await projController.openProject(vscode.Uri.file(projPath1)); - const project2 = await projController.openProject(vscode.Uri.file(projPath2)); + const project1 = await Project.openProject(vscode.Uri.file(projPath1).fsPath); + const project2 = await Project.openProject(vscode.Uri.file(projPath2).fsPath); + const showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); + const dataWorkspaceMock = TypeMoq.Mock.ofType(); + dataWorkspaceMock.setup(x => x.getProjectsInWorkspace()).returns(() => [vscode.Uri.file(project1.projectFilePath), vscode.Uri.file(project2.projectFilePath)]); + sinon.stub(vscode.extensions, 'getExtension').returns({ exports: dataWorkspaceMock.object }); // add project reference from project1 to project2 await projController.addDatabaseReferenceCallback(project1, { @@ -624,7 +618,8 @@ describe('ProjectsController', function (): void { projectName: 'TestProject', projectRelativePath: undefined, suppressMissingDependenciesErrors: false - }); + }, + { treeDataProvider: new SqlDatabaseProjectTreeViewProvider(), element: undefined }); should(showErrorMessageSpy.notCalled).be.true('showErrorMessage should not have been called'); // try to add circular reference @@ -633,75 +628,14 @@ describe('ProjectsController', function (): void { projectName: 'TestProjectName', projectRelativePath: undefined, suppressMissingDependenciesErrors: false - }); + }, + { treeDataProvider: new SqlDatabaseProjectTreeViewProvider(), element: undefined }); should(showErrorMessageSpy.called).be.true('showErrorMessage should have been called'); }); }); }); -describe.skip('ProjectsController: round trip feature with SSDT', function (): void { - it('Should show warning message for SSDT project opened in Azure Data Studio', async function (): Promise { - const stub = sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.noString)); - - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.SSDTProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - await projController.openProject(vscode.Uri.file(sqlProjPath)); - should(stub.calledOnce).be.true('showWarningMessage should have been called exactly once'); - should(stub.calledWith(constants.updateProjectForRoundTrip)).be.true(`showWarningMessage not called with expected message '${constants.updateProjectForRoundTrip}' Actual '${stub.getCall(0).args[0]}'`); - }); - - it('Should not show warning message for non-SSDT projects that have the additional information for Build', async function (): Promise { - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); // no error thrown - - should(project.importedTargets.length).equal(3); // additional target should exist by default - }); - - it('Should not update project and no backup file should be created when update to project is rejected', async function (): Promise { - sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.noString)); - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.SSDTProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); - - should(await exists(sqlProjPath + '_backup')).equal(false); // backup file should not be generated - should(project.importedTargets.length).equal(2); // additional target should not be added by updateProjectForRoundTrip method - }); - - it('Should load Project and associated import targets when update to project is accepted', async function (): Promise { - sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.yesString)); - - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.SSDTProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); - - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); - - should(await exists(sqlProjPath + '_backup')).equal(true); // backup file should be generated before the project is updated - should(project.importedTargets.length).equal(3); // additional target added by updateProjectForRoundTrip method - }); -}); - - async function setupDeleteExcludeTest(proj: Project): Promise<[FileProjectEntry, ProjectRootTreeItem, FileProjectEntry, FileProjectEntry, FileProjectEntry]> { await proj.addFolderItem('UpperFolder'); await proj.addFolderItem('UpperFolder/LowerFolder'); @@ -725,3 +659,10 @@ async function setupDeleteExcludeTest(proj: Project): Promise<[FileProjectEntry, return [scriptEntry, projTreeRoot, preDeployEntry, postDeployEntry, noneEntry]; } + +function createWorkspaceTreeItem(node: BaseProjectTreeItem): dataworkspace.WorkspaceTreeItem { + return { + element: node, + treeDataProvider: new SqlDatabaseProjectTreeViewProvider() + }; +} diff --git a/extensions/sql-database-projects/src/test/publishProfile.test.ts b/extensions/sql-database-projects/src/test/publishProfile.test.ts index ef84c710a9..32631d7868 100644 --- a/extensions/sql-database-projects/src/test/publishProfile.test.ts +++ b/extensions/sql-database-projects/src/test/publishProfile.test.ts @@ -12,7 +12,6 @@ import * as baselines from './baselines/baselines'; import * as testUtils from './testUtils'; import * as constants from '../common/constants'; import { ProjectsController } from '../controllers/projectController'; -import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider'; import { TestContext, createContext, mockDacFxOptionsResult } from './testContext'; import { load } from '../models/publishProfile/publishProfile'; @@ -82,7 +81,7 @@ describe('Publish profile tests', function (): void { it('Should throw error when connecting does not work', async function (): Promise { await baselines.loadBaselines(); let profilePath = await testUtils.createTestFile(baselines.publishProfileIntegratedSecurityBaseline, 'publishProfile.publish.xml'); - const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const projController = new ProjectsController(); sinon.stub(azdata.connection, 'connect').throws(new Error('Could not connect')); diff --git a/extensions/sql-database-projects/src/test/testContext.ts b/extensions/sql-database-projects/src/test/testContext.ts index 877a409258..5aa5ccbab4 100644 --- a/extensions/sql-database-projects/src/test/testContext.ts +++ b/extensions/sql-database-projects/src/test/testContext.ts @@ -110,7 +110,7 @@ export class MockDacFxService implements mssql.IDacFxService { public exportBacpac(_: string, __: string, ___: string, ____: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } public importBacpac(_: string, __: string, ___: string, ____: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } public extractDacpac(_: string, __: string, ___: string, ____: string, _____: string, ______: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } - public importDatabaseProject(_: string, __: string, ___: string, ____: string, _____: string, ______: mssql.ExtractTarget, _______: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public createProjectFromDatabase(_: string, __: string, ___: string, ____: string, _____: string, ______: mssql.ExtractTarget, _______: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } public deployDacpac(_: string, __: string, ___: boolean, ____: string, _____: azdata.TaskExecutionMode, ______?: Record): Thenable { return Promise.resolve(mockDacFxResult); } public generateDeployScript(_: string, __: string, ___: string, ____: azdata.TaskExecutionMode, ______?: Record): Thenable { return Promise.resolve(mockDacFxResult); } public generateDeployPlan(_: string, __: string, ___: string, ____: azdata.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } diff --git a/extensions/sql-database-projects/src/tools/newProjectTool.ts b/extensions/sql-database-projects/src/tools/newProjectTool.ts index 93c08cd720..0007b4ab61 100644 --- a/extensions/sql-database-projects/src/tools/newProjectTool.ts +++ b/extensions/sql-database-projects/src/tools/newProjectTool.ts @@ -5,24 +5,17 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; +import * as os from 'os'; import * as constants from '../common/constants'; - -/** - * Sets workspace setting on the default save location to the user's home directory - */ -export async function initializeSaveLocationSetting() { - if (!projectSaveLocationSettingExists()) { - await config().update(constants.projectSaveLocationKey, os.homedir(), true); - } -} +import * as utils from '../common/utils'; /** * Returns the default location to save a new database project */ -export function defaultProjectSaveLocation(): vscode.Uri { - return projectSaveLocationSettingIsValid() ? vscode.Uri.file(projectSaveLocationSetting()) : vscode.Uri.file(os.homedir()); +export function defaultProjectSaveLocation(): vscode.Uri | undefined { + const workspaceApi = utils.getDataWorkspaceExtensionApi(); + return workspaceApi.defaultProjectSaveLocation; } /** @@ -41,7 +34,8 @@ export function defaultProjectNameNewProj(): string { */ export function defaultProjectNameFromDb(dbName: string): string { const projectNameStarter = constants.defaultProjectNameStarter + dbName; - const projectPath: string = path.join(defaultProjectSaveLocation().fsPath, projectNameStarter); + const defaultLocation = defaultProjectSaveLocation() ?? vscode.Uri.file(os.homedir()); + const projectPath: string = path.join(defaultLocation.fsPath, projectNameStarter); if (!fs.existsSync(projectPath)) { return projectNameStarter; } @@ -49,58 +43,6 @@ export function defaultProjectNameFromDb(dbName: string): string { return defaultProjectName(projectNameStarter, 2); } -/** - * Prompts user to update workspace settings - */ -export async function updateSaveLocationSetting(): Promise { - const showPrompt: boolean = config()[constants.showUpdatePromptKey]; - if (showPrompt) { - const openSettingsMessage = projectSaveLocationSettingIsValid() ? - constants.newDefaultProjectSaveLocation : constants.invalidDefaultProjectSaveLocation; - const result = await vscode.window.showInformationMessage(openSettingsMessage, constants.openWorkspaceSettings, - constants.doNotPromptAgain); - - if (result === constants.openWorkspaceSettings || result === constants.doNotPromptAgain) { - // if user either opens settings or clicks "don't ask again", do not prompt for save location again - await config().update(constants.showUpdatePromptKey, false, true); - - if (result === constants.openWorkspaceSettings) { - await vscode.commands.executeCommand('workbench.action.openGlobalSettings'); //open settings - } - } - } -} - -/** - * Get workspace configurations for this extension - */ -function config(): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey); -} - -/** - * 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() !== ''; -} - /** * Returns a project name that begins with the given nameStarter, and ends in a number, such as * 'DatabaseProject1'. Number begins at the given counter, but auto-increments if a project of @@ -112,7 +54,8 @@ function projectSaveLocationSettingExists(): boolean { function defaultProjectName(nameStarter: string, counter: number): string { while (counter < Number.MAX_SAFE_INTEGER) { const name: string = nameStarter + counter; - const projectPath: string = path.join(defaultProjectSaveLocation().fsPath, name); + const defaultLocation = defaultProjectSaveLocation() ?? vscode.Uri.file(os.homedir()); + const projectPath: string = path.join(defaultLocation.fsPath, name); if (!fs.existsSync(projectPath)) { return name; } diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index d7179d0604..b42f136d39 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -815,6 +815,19 @@ declare module 'azdata' { title: string; } + export namespace workspace { + /** + * Creates and enters a workspace at the specified location + */ + export function createWorkspace(location: vscode.Uri, workspaceFile?: vscode.Uri): Promise; + + /** + * Enters the workspace with the provided path + * @param workspacefile + */ + export function enterWorkspace(workspaceFile: vscode.Uri): Promise; + } + export interface TableComponentProperties { /** * Specifies whether to use headerFilter plugin diff --git a/src/sql/workbench/api/browser/extensionHost.contribution.ts b/src/sql/workbench/api/browser/extensionHost.contribution.ts index 4cfcc6584c..fa7be72064 100644 --- a/src/sql/workbench/api/browser/extensionHost.contribution.ts +++ b/src/sql/workbench/api/browser/extensionHost.contribution.ts @@ -20,3 +20,4 @@ import './mainThreadObjectExplorer'; import './mainThreadQueryEditor'; import './mainThreadResourceProvider'; import './mainThreadTasks'; +import './mainThreadWorkspace'; diff --git a/src/sql/workbench/api/browser/mainThreadWorkspace.ts b/src/sql/workbench/api/browser/mainThreadWorkspace.ts new file mode 100644 index 0000000000..e881878153 --- /dev/null +++ b/src/sql/workbench/api/browser/mainThreadWorkspace.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SqlMainContext, MainThreadWorkspaceShape } from 'sql/workbench/api/common/sqlExtHost.protocol'; +import { IExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; +import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; + +@extHostNamedCustomer(SqlMainContext.MainThreadWorkspace) +export class MainThreadWorkspace extends Disposable implements MainThreadWorkspaceShape { + + constructor( + extHostContext: IExtHostContext, + @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService + ) { + super(); + } + + $createWorkspace(folder: URI, workspaceFile?: URI): Promise { + folder = URI.revive(folder); + workspaceFile = URI.revive(workspaceFile); + return this.workspaceEditingService.createAndEnterWorkspace([{ uri: folder }], workspaceFile); + } + + $enterWorkspace(workspaceFile: URI): Promise { + workspaceFile = URI.revive(workspaceFile); + return this.workspaceEditingService.enterWorkspace(workspaceFile); + } +} diff --git a/src/sql/workbench/api/common/extHostWorkspace.ts b/src/sql/workbench/api/common/extHostWorkspace.ts new file mode 100644 index 0000000000..73edca540f --- /dev/null +++ b/src/sql/workbench/api/common/extHostWorkspace.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMainContext } from 'vs/workbench/api/common/extHost.protocol'; + +import { ExtHostWorkspaceShape, MainThreadWorkspaceShape, SqlMainContext } from 'sql/workbench/api/common/sqlExtHost.protocol'; +import { URI } from 'vs/base/common/uri'; + + +export class ExtHostWorkspace implements ExtHostWorkspaceShape { + + private readonly _proxy: MainThreadWorkspaceShape; + + constructor(_mainContext: IMainContext) { + this._proxy = _mainContext.getProxy(SqlMainContext.MainThreadWorkspace); + } + + $createWorkspace(folder: URI, workspaceFile: URI): Promise { + return this._proxy.$createWorkspace(folder, workspaceFile); + } + + $enterWorkspace(workspaceFile: URI): Promise { + return this._proxy.$enterWorkspace(workspaceFile); + } +} diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index 3196cffd5a..87477cb1ca 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -33,6 +33,7 @@ import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { ILogService } from 'vs/platform/log/common/log'; import { IExtensionApiFactory as vsIApiFactory, createApiFactoryAndRegisterActors as vsApiFactory } from 'vs/workbench/api/common/extHost.api.impl'; import { IExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; +import { ExtHostWorkspace } from 'sql/workbench/api/common/extHostWorkspace'; export interface IAzdataExtensionApiFactory { (extension: IExtensionDescription): typeof azdata; @@ -92,6 +93,7 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp const extHostNotebook = rpcProtocol.set(SqlExtHostContext.ExtHostNotebook, new ExtHostNotebook(rpcProtocol)); const extHostNotebookDocumentsAndEditors = rpcProtocol.set(SqlExtHostContext.ExtHostNotebookDocumentsAndEditors, new ExtHostNotebookDocumentsAndEditors(rpcProtocol)); const extHostExtensionManagement = rpcProtocol.set(SqlExtHostContext.ExtHostExtensionManagement, new ExtHostExtensionManagement(rpcProtocol)); + const extHostWorkspace = rpcProtocol.set(SqlExtHostContext.ExtHostWorkspace, new ExtHostWorkspace(rpcProtocol)); return { azdata: function (extension: IExtensionDescription): typeof azdata { @@ -459,6 +461,12 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp onDidChangeToDashboard: extHostDashboard.onDidChangeToDashboard, createModelViewEditor(title: string, options?: azdata.ModelViewEditorOptions, name?: string): azdata.workspace.ModelViewEditor { return extHostModelViewDialog.createModelViewEditor(title, extension, name, options); + }, + createWorkspace(location: vscode.Uri, workspaceFile: vscode.Uri): Promise { + return extHostWorkspace.$createWorkspace(location, workspaceFile); + }, + enterWorkspace(workspaceFile: vscode.Uri): Promise { + return extHostWorkspace.$enterWorkspace(workspaceFile); } }; diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index 359223c584..115b5fb253 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -658,7 +658,8 @@ export const SqlMainContext = { MainThreadQueryEditor: createMainId('MainThreadQueryEditor'), MainThreadNotebook: createMainId('MainThreadNotebook'), MainThreadNotebookDocumentsAndEditors: createMainId('MainThreadNotebookDocumentsAndEditors'), - MainThreadExtensionManagement: createMainId('MainThreadExtensionManagement') + MainThreadExtensionManagement: createMainId('MainThreadExtensionManagement'), + MainThreadWorkspace: createMainId('MainThreadWorkspace') }; export const SqlExtHostContext = { @@ -679,7 +680,8 @@ export const SqlExtHostContext = { ExtHostQueryEditor: createExtId('ExtHostQueryEditor'), ExtHostNotebook: createExtId('ExtHostNotebook'), ExtHostNotebookDocumentsAndEditors: createExtId('ExtHostNotebookDocumentsAndEditors'), - ExtHostExtensionManagement: createExtId('ExtHostExtensionManagement') + ExtHostExtensionManagement: createExtId('ExtHostExtensionManagement'), + ExtHostWorkspace: createExtId('ExtHostWorkspace') }; export interface MainThreadDashboardShape extends IDisposable { @@ -756,6 +758,16 @@ export interface ExtHostBackgroundTaskManagementShape { $removeTask(operationId: string): void; } +export interface ExtHostWorkspaceShape { + $createWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; + $enterWorkspace(workspaceFile: vscode.Uri): Promise; +} + +export interface MainThreadWorkspaceShape { + $createWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; + $enterWorkspace(workspaceFile: vscode.Uri): Promise; +} + export interface MainThreadBackgroundTaskManagementShape extends IDisposable { $registerTask(taskInfo: azdata.TaskInfo): void; $updateTask(taskProgressInfo: azdata.TaskProgressInfo): void; diff --git a/src/sql/workbench/browser/modelComponents/media/card.css b/src/sql/workbench/browser/modelComponents/media/card.css index 23c3808016..27223ebab7 100644 --- a/src/sql/workbench/browser/modelComponents/media/card.css +++ b/src/sql/workbench/browser/modelComponents/media/card.css @@ -32,11 +32,11 @@ display: flex; flex-direction: column; text-align: center; - height: auto; width: auto; padding: 5px 5px 5px 5px; - min-height: 130px; - min-width: 130px; + display: flex; + justify-content: center; + align-items: center; } .horizontal .model-card .card-label { diff --git a/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html index 6e349c1b1b..15f531ca5d 100644 --- a/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html +++ b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html @@ -11,7 +11,7 @@
-
+
{{description.textValue}}