diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index f37ac52a74..b3f3c1bb1c 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -38,9 +38,9 @@ export const NewWorkspaceWillBeCreated = localize('dataworkspace.NewWorkspaceWil export const WorkspaceLocationTitle = localize('dataworkspace.workspaceLocationTitle', "Workspace location"); export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project 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); }; -export const WorkspaceFileInvalidError = (workspace: string): string => { return localize('dataworkspace.workspaceFileInvalidError', "The selected workspace file path '{0}' does not have the required file extension {1}", workspace, WorkspaceFileExtension); }; -export const WorkspaceParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.workspaceParentDirectoryNotExistError', "The selected workspace location '{0}' does not exist or is not a directory", location); }; -export const WorkspaceFileAlreadyExistsError = (file: string): string => { return localize('dataworkspace.workspaceFileAlreadyExistsError', "The selected workspace file '{0}' already exists. To add the project to an existing workspace, use the Open Existing dialog to first open the workspace", file); }; +export const WorkspaceFileInvalidError = (workspace: string): string => { return localize('dataworkspace.workspaceFileInvalidError', "The selected workspace file path '{0}' does not have the required file extension {1}.", workspace, WorkspaceFileExtension); }; +export const WorkspaceParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.workspaceParentDirectoryNotExistError', "The selected workspace location '{0}' does not exist or is not a directory.", location); }; +export const WorkspaceFileAlreadyExistsError = (file: string): string => { return localize('dataworkspace.workspaceFileAlreadyExistsError', "The selected workspace file '{0}' already exists. To add the project to an existing workspace, use the Open Existing dialog to first open the workspace.", file); }; //Open Existing Dialog export const OpenExistingDialogTitle = localize('dataworkspace.openExistingDialogTitle', "Open existing"); diff --git a/extensions/data-workspace/src/common/dataWorkspaceExtension.ts b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts index ab7f17ffb9..1dc9dc2f4f 100644 --- a/extensions/data-workspace/src/common/dataWorkspaceExtension.ts +++ b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts @@ -16,8 +16,8 @@ export class DataWorkspaceExtension implements IExtension { return this.workspaceService.getProjectsInWorkspace(); } - addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise { - return this.workspaceService.addProjectsToWorkspace(projectFiles); + addProjectsToWorkspace(projectFiles: vscode.Uri[], workspaceFilePath?: vscode.Uri): Promise { + return this.workspaceService.addProjectsToWorkspace(projectFiles, workspaceFilePath); } showProjectsView(): void { diff --git a/extensions/data-workspace/src/dataworkspace.d.ts b/extensions/data-workspace/src/dataworkspace.d.ts index 7618d0f508..32d92e420d 100644 --- a/extensions/data-workspace/src/dataworkspace.d.ts +++ b/extensions/data-workspace/src/dataworkspace.d.ts @@ -20,9 +20,10 @@ declare module 'dataworkspace' { /** * Add projects to the workspace - * @param projectFiles Uris of project files to add + * @param projectFiles Uris of project files to add, + * @param workspaceFilePath workspace file to create if no workspace is open */ - addProjectsToWorkspace(projectFiles: vscode.Uri[]): Promise; + addProjectsToWorkspace(projectFiles: vscode.Uri[], workspaceFilePath?: vscode.Uri): Promise; /** * Change focus to Projects view diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 42597759f7..aac247c809 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -137,6 +137,12 @@ export const addProjectToCurrentWorkspace = localize('addProjectToCurrentWorkspa export const newWorkspaceWillBeCreated = localize('newWorkspaceWillBeCreated', "A new workspace will be created for this project."); export const workspaceLocationTitle = localize('workspaceLocationTitle', "Workspace location"); export const workspace = localize('workspace', "Workspace"); +export const WorkspaceFileExtension = '.code-workspace'; +export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project 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); }; +export const WorkspaceFileInvalidError = (workspace: string): string => { return localize('dataworkspace.workspaceFileInvalidError', "The selected workspace file path '{0}' does not have the required file extension {1}.", workspace, WorkspaceFileExtension); }; +export const WorkspaceParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.workspaceParentDirectoryNotExistError', "The selected workspace location '{0}' does not exist or is not a directory.", location); }; +export const WorkspaceFileAlreadyExistsError = (file: string): string => { return localize('dataworkspace.workspaceFileAlreadyExistsError', "The selected workspace file '{0}' already exists. To add the project to an existing workspace, use the Open Existing dialog to first open the workspace.", file); }; // Error messages diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index ec5de1b11e..2d1411e095 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -658,7 +658,7 @@ export class ProjectsController { // add project to workspace workspaceApi.showProjectsView(); - await workspaceApi.addProjectsToWorkspace([vscode.Uri.file(newProjFilePath)]); + await workspaceApi.addProjectsToWorkspace([vscode.Uri.file(newProjFilePath)], model.newWorkspaceFilePath); } } catch (err) { diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts index 6eaaaf7bfa..0eeca9696a 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts @@ -15,6 +15,7 @@ import { cssStyles } from '../common/uiConstants'; import { ImportDataModel } from '../models/api/import'; import { Deferred } from '../common/promise'; import { getConnectionName } from './utils'; +import { exists } from '../common/utils'; export class CreateProjectFromDatabaseDialog { public dialog: azdata.window.Dialog; @@ -37,6 +38,9 @@ export class CreateProjectFromDatabaseDialog { constructor(private profile: azdata.IConnectionProfile | undefined) { this.dialog = azdata.window.createModelViewDialog(constants.createProjectFromDatabaseDialogName); this.createProjectFromDatabaseTab = azdata.window.createTab(constants.createProjectFromDatabaseDialogName); + this.dialog.registerCloseValidator(async () => { + return this.validate(); + }); } public async openDialog(): Promise { @@ -164,7 +168,7 @@ export class CreateProjectFromDatabaseDialog { this.sourceDatabaseDropDown.onValueChanged(() => { this.setProjectName(); - this.updateWorkspaceInputbox(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!); + this.updateWorkspaceInputbox(path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!), this.projectNameTextBox!.value!); this.tryEnableCreateButton(); }); @@ -252,7 +256,7 @@ export class CreateProjectFromDatabaseDialog { this.projectNameTextBox.onTextChanged(() => { this.projectNameTextBox!.value = this.projectNameTextBox!.value?.trim(); this.projectNameTextBox!.updateProperty('title', this.projectNameTextBox!.value); - this.updateWorkspaceInputbox(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!); + this.updateWorkspaceInputbox(path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!), this.projectNameTextBox!.value!); this.tryEnableCreateButton(); }); @@ -280,7 +284,7 @@ export class CreateProjectFromDatabaseDialog { this.projectLocationTextBox.onTextChanged(() => { this.projectLocationTextBox!.updateProperty('title', this.projectLocationTextBox!.value); - this.updateWorkspaceInputbox(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!); + this.updateWorkspaceInputbox(path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!), this.projectNameTextBox!.value!); this.tryEnableCreateButton(); }); @@ -318,7 +322,7 @@ export class CreateProjectFromDatabaseDialog { this.projectLocationTextBox!.value = folderUris[0].fsPath; this.projectLocationTextBox!.updateProperty('title', folderUris[0].fsPath); - this.updateWorkspaceInputbox(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!); + this.updateWorkspaceInputbox(path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!), this.projectNameTextBox!.value!); }); return browseFolderButton; @@ -356,17 +360,54 @@ export class CreateProjectFromDatabaseDialog { private createWorkspaceContainerRow(view: azdata.ModelView): azdata.FlexContainer { this.workspaceInputBox = view.modelBuilder.inputBox().withProperties({ ariaLabel: constants.workspaceLocationTitle, - enabled: false, + enabled: !vscode.workspace.workspaceFile, // want it editable if no workspace is open value: vscode.workspace.workspaceFile?.fsPath ?? '', - title: vscode.workspace.workspaceFile?.fsPath ?? '' // hovertext for if file path is too long to be seen in textbox + title: vscode.workspace.workspaceFile?.fsPath ?? '', // hovertext for if file path is too long to be seen in textbox + width: '100%' }).component(); + const browseFolderButton = view.modelBuilder.button().withProperties({ + ariaLabel: constants.browseButtonText, + iconPath: IconPathHelper.folder_blue, + height: '16px', + width: '18px' + }).component(); + + this.toDispose.push(browseFolderButton.onDidClick(async () => { + const currentFileName = path.parse(this.workspaceInputBox!.value!).base; + + // let user select folder for workspace file to be created in + const folderUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: vscode.Uri.file(path.parse(this.workspaceInputBox!.value!).dir) + }); + if (!folderUris || folderUris.length === 0) { + return; + } + const selectedFolder = folderUris[0].fsPath; + + const selectedFile = path.join(selectedFolder, currentFileName); + this.workspaceInputBox!.value = selectedFile; + this.workspaceInputBox!.title = selectedFile; + })); + const workspaceLabel = view.modelBuilder.text().withProperties({ value: vscode.workspace.workspaceFile ? constants.addProjectToCurrentWorkspace : constants.newWorkspaceWillBeCreated, CSSStyles: { 'margin-top': '-10px', 'margin-bottom': '5px' } }).component(); - const workspaceContainerRow = view.modelBuilder.flexContainer().withItems([workspaceLabel, this.workspaceInputBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-top': '0px' } }).withLayout({ flexFlow: 'column' }).component(); + let workspaceContainerRow; + if (vscode.workspace.workspaceFile) { + workspaceContainerRow = view.modelBuilder.flexContainer().withItems([workspaceLabel, this.workspaceInputBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-top': '0px' } }).withLayout({ flexFlow: 'column' }).component(); + + } else { + // have browse button to help select where the workspace file should be created + const workspaceInput = view.modelBuilder.flexContainer().withItems([this.workspaceInputBox], { CSSStyles: { 'margin-right': '10px', 'margin-bottom': '10px', 'width': '100%' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + workspaceInput.addItem(browseFolderButton, { CSSStyles: { 'margin-top': '-10px' } }); + workspaceContainerRow = view.modelBuilder.flexContainer().withItems([workspaceLabel, workspaceInput], { flex: '0 0 auto', CSSStyles: { 'margin-top': '0px' } }).withLayout({ flexFlow: 'column' }).component(); + } return workspaceContainerRow; } @@ -401,7 +442,8 @@ export class CreateProjectFromDatabaseDialog { projName: this.projectNameTextBox!.value!, filePath: this.projectLocationTextBox!.value!, version: '1.0.0.0', - extractTarget: this.mapExtractTargetEnum(this.folderStructureDropDown!.value) + extractTarget: this.mapExtractTargetEnum(this.folderStructureDropDown!.value), + newWorkspaceFilePath: this.workspaceInputBox!.enabled ? vscode.Uri.file(this.workspaceInputBox!.value!) : undefined }; azdata.window.closeDialog(this.dialog); @@ -424,4 +466,62 @@ export class CreateProjectFromDatabaseDialog { throw new Error(constants.extractTargetRequired); } } + + async validate(): Promise { + try { + // the selected location should be an existing directory + const parentDirectoryExists = await exists(this.projectLocationTextBox!.value!); + if (!parentDirectoryExists) { + this.showErrorMessage(constants.ProjectParentDirectoryNotExistError(this.projectLocationTextBox!.value!)); + return false; + } + + // there shouldn't be an existing sub directory with the same name as the project in the selected location + const projectDirectoryExists = await exists(path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!)); + if (projectDirectoryExists) { + this.showErrorMessage(constants.ProjectDirectoryAlreadyExistError(this.projectNameTextBox!.value!, this.projectLocationTextBox!.value!)); + return false; + } + + if (this.workspaceInputBox!.enabled) { + await this.validateNewWorkspace(); + } + + return true; + } catch (err) { + this.showErrorMessage(err?.message ? err.message : err); + return false; + } + } + + protected async validateNewWorkspace(): Promise { + const sameFolderAsNewProject = path.join(this.projectLocationTextBox!.value!, this.projectNameTextBox!.value!) === path.dirname(this.workspaceInputBox!.value!); + + // workspace file should end in .code-workspace + const workspaceValid = this.workspaceInputBox!.value!.endsWith(constants.WorkspaceFileExtension); + if (!workspaceValid) { + throw new Error(constants.WorkspaceFileInvalidError(this.workspaceInputBox!.value!)); + } + + // if the workspace file is not going to be in the same folder as the newly created project, then check that it's a valid folder + if (!sameFolderAsNewProject) { + const workspaceParentDirectoryExists = await exists(path.dirname(this.workspaceInputBox!.value!)); + if (!workspaceParentDirectoryExists) { + throw new Error(constants.WorkspaceParentDirectoryNotExistError(path.dirname(this.workspaceInputBox!.value!))); + } + } + + // workspace file should not be an existing workspace file + const workspaceFileExists = await exists(this.workspaceInputBox!.value!); + if (workspaceFileExists) { + throw new Error(constants.WorkspaceFileAlreadyExistsError(this.workspaceInputBox!.value!)); + } + } + + protected showErrorMessage(message: string): void { + this.dialog.message = { + text: message, + level: azdata.window.MessageLevel.Error + }; + } } diff --git a/extensions/sql-database-projects/src/models/api/import.ts b/extensions/sql-database-projects/src/models/api/import.ts index 476d522604..63a60ffd01 100644 --- a/extensions/sql-database-projects/src/models/api/import.ts +++ b/extensions/sql-database-projects/src/models/api/import.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Uri } from 'vscode'; import { ExtractTarget } from '../../../../mssql'; /** @@ -15,4 +16,5 @@ export interface ImportDataModel { filePath: string; version: string; extractTarget: ExtractTarget; + newWorkspaceFilePath?: Uri; } diff --git a/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts b/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts index 63dff8bc49..a4b970c578 100644 --- a/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts @@ -97,6 +97,8 @@ describe('Create Project From Database Dialog', () => { sinon.stub(azdata.connection, 'listDatabases').resolves(['My Database']); await dialog.openDialog(); + dialog.workspaceInputBox!.enabled = false; + dialog.projectNameTextBox!.value = 'testProject'; dialog.projectLocationTextBox!.value = 'testLocation'; @@ -108,7 +110,8 @@ describe('Create Project From Database Dialog', () => { projName: 'testProject', filePath: 'testLocation', version: '1.0.0.0', - extractTarget: mssql.ExtractTarget['schemaObjectType'] + extractTarget: mssql.ExtractTarget['schemaObjectType'], + newWorkspaceFilePath: undefined }; dialog.createProjectFromDatabaseCallback = (m) => { model = m; };