diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 7eb3e1a1a8..d60d040821 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -35,6 +35,15 @@ "sqlDatabaseProjects.netCoreSDKLocation": { "type": "string", "description": "%sqlDatabaseProjects.netCoreInstallLocation%" + }, + "sqlDatabaseProjects.defaultProjectSaveLocation": { + "type": "string", + "description": "%sqlDatabaseProjects.defaultProjectSaveLocation%" + }, + "sqlDatabaseProjects.showUpdateSaveLocationPrompt": { + "type": "boolean", + "description": "%sqlDatabaseProjects.showUpdateSaveLocationPrompt%", + "default": true } } } diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 77eb048a9c..70089a4e90 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -26,6 +26,8 @@ "sqlDatabaseProjects.openContainingFolder": "Open Containing Folder", "sqlDatabaseProjects.Settings": "Database Projects", - "sqlDatabaseProjects.netCoreInstallLocation": "Full Path to .Net Core SDK on the machine.", + "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 12c0fe9f0b..02c1b96902 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -56,6 +56,11 @@ export const flat = localize('flat', "Flat"); 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 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); } export function deleteConfirmationContents(toDelete: string) { return localize('deleteConfirmationContents', "Are you sure you want to delete {0} and all of its contents?", toDelete); } @@ -207,6 +212,11 @@ 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/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 30ada8b7e5..2be3d3059a 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -9,6 +9,7 @@ import * as templates from '../templates/templates'; import * as constants from '../common/constants'; import * as path from 'path'; import * as glob from 'fast-glob'; +import * as newProjectTool from '../tools/newProjectTool'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; import { getErrorMessage } from '../common/utils'; @@ -89,6 +90,9 @@ export default class MainController implements vscode.Disposable { // 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(); } @@ -144,8 +148,7 @@ export default class MainController implements vscode.Disposable { try { let newProjName = await vscode.window.showInputBox({ prompt: constants.newDatabaseProjectName, - value: `DatabaseProject${this.projectsController.projects.length + 1}` - // TODO: Smarter way to suggest a name. Easy if we prompt for location first, but that feels odd... + value: newProjectTool.defaultProjectNameNewProj() }); newProjName = newProjName?.trim(); @@ -160,7 +163,7 @@ export default class MainController implements vscode.Disposable { canSelectFiles: false, canSelectFolders: true, canSelectMany: false, - defaultUri: vscode.workspace.workspaceFolders ? (vscode.workspace.workspaceFolders as vscode.WorkspaceFolder[])[0].uri : undefined + defaultUri: newProjectTool.defaultProjectSaveLocation() }); if (!selectionResult) { @@ -174,6 +177,8 @@ export default class MainController implements vscode.Disposable { 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) { diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index fc34c803da..22208f39de 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -11,9 +11,10 @@ import * as path from 'path'; import * as utils from '../common/utils'; import * as UUID from 'vscode-languageclient/lib/utils/uuid'; import * as templates from '../templates/templates'; - +import * as newProjectTool from '../tools/newProjectTool'; import * as vscode from 'vscode'; import * as azdata from 'azdata'; + import { promises as fs } from 'fs'; import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog'; import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform, ProjectEntry, reservedProjectFolders, SqlProjectReferenceProjectEntry } from '../models/project'; @@ -661,6 +662,8 @@ export class ProjectsController { 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); @@ -739,7 +742,7 @@ export class ProjectsController { private async getProjectName(dbName: string): Promise { let projName = await vscode.window.showInputBox({ prompt: constants.newDatabaseProjectName, - value: `DatabaseProject${dbName}` + value: newProjectTool.defaultProjectNameFromDb(dbName) }); projName = projName?.trim(); @@ -797,7 +800,7 @@ export class ProjectsController { canSelectFolders: true, canSelectMany: false, openLabel: constants.selectString, - defaultUri: vscode.workspace.workspaceFolders ? (vscode.workspace.workspaceFolders as vscode.WorkspaceFolder[])[0].uri : undefined + defaultUri: newProjectTool.defaultProjectSaveLocation() }); if (selectionResult) { diff --git a/extensions/sql-database-projects/src/test/newProjectTool.test.ts b/extensions/sql-database-projects/src/test/newProjectTool.test.ts new file mode 100644 index 0000000000..92d5c29752 --- /dev/null +++ b/extensions/sql-database-projects/src/test/newProjectTool.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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 should from 'should'; +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 { + beforeEach(async function () { + previousSetting = await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey)[constants.projectSaveLocationKey]; + testFolderPath = await generateTestFolderPath(); + }); + + afterEach(async function () { + await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, previousSetting, true); + }); + + it('Should generate correct default project names', async function (): Promise { + await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.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); + should(newProjectTool.defaultProjectNameNewProj()).equal('DatabaseProject1'); + + await createTestFile('', 'DatabaseProject1', testFolderPath); + should(newProjectTool.defaultProjectNameNewProj()).equal('DatabaseProject2'); + + await createTestFile('', 'DatabaseProject2', testFolderPath); + should(newProjectTool.defaultProjectNameNewProj()).equal('DatabaseProject3'); + }); + + it('Should auto-increment default project names for import projects', async function (): Promise { + await vscode.workspace.getConfiguration(constants.dbProjectConfigurationKey).update(constants.projectSaveLocationKey, testFolderPath, true); + should(newProjectTool.defaultProjectNameFromDb("master")).equal('DatabaseProjectmaster'); + + await createTestFile('', 'DatabaseProjectmaster', testFolderPath); + should(newProjectTool.defaultProjectNameFromDb("master")).equal('DatabaseProjectmaster2'); + + await createTestFile('', 'DatabaseProjectmaster2', testFolderPath); + should(newProjectTool.defaultProjectNameFromDb("master")).equal('DatabaseProjectmaster3'); + }); +}); diff --git a/extensions/sql-database-projects/src/tools/newProjectTool.ts b/extensions/sql-database-projects/src/tools/newProjectTool.ts new file mode 100644 index 0000000000..93c08cd720 --- /dev/null +++ b/extensions/sql-database-projects/src/tools/newProjectTool.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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 os from 'os'; +import * as path from 'path'; +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); + } +} + +/** + * 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()); +} + +/** + * Returns default project name for a fresh new project, such as 'DatabaseProject1'. Auto-increments + * the suggestion if a project of that name already exists in the default save location + */ +export function defaultProjectNameNewProj(): string { + return defaultProjectName(constants.defaultProjectNameStarter, 1); +} + +/** + * Returns default project name for a new project based on given dbName. Auto-increments + * the suggestion if a project of that name already exists in the default save location + * + * @param dbName the database name to base the default project name off of + */ +export function defaultProjectNameFromDb(dbName: string): string { + const projectNameStarter = constants.defaultProjectNameStarter + dbName; + const projectPath: string = path.join(defaultProjectSaveLocation().fsPath, projectNameStarter); + if (!fs.existsSync(projectPath)) { + return projectNameStarter; + } + + 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 + * that name already exists in the default save location. + * + * @param nameStarter the beginning of the default project name, such as 'DatabaseProject' + * @param counter the starting value of of the number appended to the nameStarter + */ +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); + if (!fs.existsSync(projectPath)) { + return name; + } + counter++; + } + return constants.defaultProjectNameStarter + counter; +}