diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index 7aabbbe478..a658ba1fb2 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -58,6 +58,14 @@ export const SdkLearnMorePlaceholder = localize('dataworkspace.sdkLearnMorePlace export const Default = localize('dataworkspace.default', "Default"); export const SelectTargetPlatform = localize('dataworkspace.selectTargetPlatform', "Select Target Platform"); export const LocalDevInfo = (target: string) => localize('LocalDevInfo', "Click \"Learn more\" button for more information about local development experience to {0}", target); +export const undefinedFilenameErrorMessage = localize('undefinedFilenameErrorMessage', "Undefined name"); +export const filenameEndingIsPeriodErrorMessage = localize('filenameEndingInPeriodErrorMessage', "File name cannot end with a period"); +export const whitespaceFilenameErrorMessage = localize('whitespaceFilenameErrorMessage', "File name cannot be whitespace"); +export const invalidFileCharsErrorMessage = localize('invalidFileCharsErrorMessage', "Invalid file characters"); +export const reservedWindowsFilenameErrorMessage = localize('reservedWindowsFilenameErrorMessage', "This file name is reserved for use by Windows. Choose another name and try again"); +export const reservedValueErrorMessage = localize('reservedValueErrorMessage', "Reserved file name. Choose another name and try again"); +export const trailingWhitespaceErrorMessage = localize('trailingWhitespaceErrorMessage', "File name cannot end with a whitespace"); +export const tooLongFilenameErrorMessage = localize('tooLongFilenameErrorMessage', "File name cannot be over 255 characters"); //Open Existing Dialog export const OpenExistingDialogTitle = localize('dataworkspace.openExistingDialogTitle', "Open Existing Project"); diff --git a/extensions/data-workspace/src/common/dataWorkspaceExtension.ts b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts index 3ff9e2a7a4..bc6d384c55 100644 --- a/extensions/data-workspace/src/common/dataWorkspaceExtension.ts +++ b/extensions/data-workspace/src/common/dataWorkspaceExtension.ts @@ -8,6 +8,7 @@ import { IExtension, IProjectType } from 'dataworkspace'; import { WorkspaceService } from '../services/workspaceService'; import { defaultProjectSaveLocation } from './projectLocationHelper'; import { openSpecificProjectNewProjectDialog } from '../dialogs/newProjectDialog'; +import { isValidBasename, isValidBasenameErrorMessage, isValidFilenameCharacter, sanitizeStringForFilename } from './pathUtilsHelper'; export class DataWorkspaceExtension implements IExtension { constructor(private workspaceService: WorkspaceService) { @@ -41,4 +42,20 @@ export class DataWorkspaceExtension implements IExtension { return openSpecificProjectNewProjectDialog(projectType, this.workspaceService); } + isValidFilenameCharacter(c: string): boolean { + return isValidFilenameCharacter(c); + } + + sanitizeStringForFilename(s: string): string { + return sanitizeStringForFilename(s); + } + + isValidBasename(name?: string): boolean { + return isValidBasename(name); + } + + isValidBasenameErrorMessage(name?: string): string { + return isValidBasenameErrorMessage(name); + } + } diff --git a/extensions/data-workspace/src/common/pathUtilsHelper.ts b/extensions/data-workspace/src/common/pathUtilsHelper.ts new file mode 100644 index 0000000000..cc92b56ba0 --- /dev/null +++ b/extensions/data-workspace/src/common/pathUtilsHelper.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as constants from './constants'; +import * as os from 'os'; +import * as path from 'path'; + +const WINDOWS_INVALID_FILE_CHARS = /[\\/:\*\?"<>\|]/g; +const UNIX_INVALID_FILE_CHARS = /[\\/]/g; +const isWindows = os.platform() === 'win32'; +const WINDOWS_FORBIDDEN_NAMES = /^(con|prn|aux|clock\$|nul|lpt[0-9]|com[0-9])$/i; + +/** + * Determines if a given character is a valid filename character + * @param c Character to validate + */ +export function isValidFilenameCharacter(c: string): boolean { + // only a character should be passed + if (!c || c.length !== 1) { + return false; + } + WINDOWS_INVALID_FILE_CHARS.lastIndex = 0; + UNIX_INVALID_FILE_CHARS.lastIndex = 0; + if (isWindows && WINDOWS_INVALID_FILE_CHARS.test(c)) { + return false; + } else if (!isWindows && UNIX_INVALID_FILE_CHARS.test(c)) { + return false; + } + + return true; +} + +/** + * Replaces invalid filename characters in a string with underscores + * @param s The string to be sanitized for a filename + */ +export function sanitizeStringForFilename(s: string): string { + // replace invalid characters with an underscore + let result = ''; + for (let i = 0; i < s.length; ++i) { + result += isValidFilenameCharacter(s[i]) ? s[i] : '_'; + } + + return result; +} + +/** + * Returns true if the string is a valid filename + * Logic is copied from src\vs\base\common\extpath.ts + * @param name filename to check + */ +export function isValidBasename(name?: string): boolean { + const invalidFileChars = isWindows ? WINDOWS_INVALID_FILE_CHARS : UNIX_INVALID_FILE_CHARS; + + if (!name) { + return false; + } + + if (isWindows && name[name.length - 1] === '.') { + return false; // Windows: file cannot end with a "." + } + + let basename = path.parse(name).name; + if (!basename || basename.length === 0 || /^\s+$/.test(basename)) { + return false; // require a name that is not just whitespace + } + + invalidFileChars.lastIndex = 0; + if (invalidFileChars.test(basename)) { + return false; // check for certain invalid file characters + } + + if (isWindows && WINDOWS_FORBIDDEN_NAMES.test(basename)) { + return false; // check for certain invalid file names + } + + if (basename === '.' || basename === '..') { + return false; // check for reserved values + } + + if (isWindows && basename.length !== basename.trim().length) { + return false; // Windows: file cannot end with a whitespace + } + + if (basename.length > 255) { + return false; // most file systems do not allow files > 255 length + } + + return true; +} + +/** + * Returns specific error message if file name is invalid + * Logic is copied from src\vs\base\common\extpath.ts + * @param name filename to check + */ +export function isValidBasenameErrorMessage(name?: string): string { + const invalidFileChars = isWindows ? WINDOWS_INVALID_FILE_CHARS : UNIX_INVALID_FILE_CHARS; + if (!name) { + return constants.undefinedFilenameErrorMessage; + } + + if (isWindows && name[name.length - 1] === '.') { + return constants.filenameEndingIsPeriodErrorMessage; // Windows: file cannot end with a "." + } + + let basename = path.parse(name).name; + if (!basename || basename.length === 0 || /^\s+$/.test(basename)) { + return constants.whitespaceFilenameErrorMessage; // require a name that is not just whitespace + } + + invalidFileChars.lastIndex = 0; + if (invalidFileChars.test(basename)) { + return constants.invalidFileCharsErrorMessage; // check for certain invalid file characters + } + + if (isWindows && WINDOWS_FORBIDDEN_NAMES.test(basename)) { + return constants.reservedWindowsFilenameErrorMessage; // check for certain invalid file names + } + + if (basename === '.' || basename === '..') { + return constants.reservedValueErrorMessage; // check for reserved values + } + + if (isWindows && basename.length !== basename.trim().length) { + return constants.trailingWhitespaceErrorMessage; // Windows: file cannot end with a whitespace + } + + if (basename.length > 255) { + return constants.tooLongFilenameErrorMessage; // most file systems do not allow files > 255 length + } + + return ''; +} diff --git a/extensions/data-workspace/src/dataworkspace.d.ts b/extensions/data-workspace/src/dataworkspace.d.ts index f70ad32d43..28bee46019 100644 --- a/extensions/data-workspace/src/dataworkspace.d.ts +++ b/extensions/data-workspace/src/dataworkspace.d.ts @@ -54,6 +54,32 @@ declare module 'dataworkspace' { * @returns the uri of the created the project or undefined if no project was created */ openSpecificProjectNewProjectDialog(projectType: IProjectType): Promise; + + /** + * Determines if a given character is a valid filename character + * @param c Character to validate + */ + isValidFilenameCharacter(c: string): boolean; + + /** + * Replaces invalid filename characters in a string with underscores + * @param s The string to be sanitized for a filename + */ + sanitizeStringForFilename(s: string): string; + + /** + * Returns true if the string is a valid filename + * Logic is copied from src\vs\base\common\extpath.ts + * @param name filename to check + */ + isValidBasename(name: string | null | undefined): boolean; + + /** + * Returns specific error message if file name is invalid + * Logic is copied from src\vs\base\common\extpath.ts + * @param name filename to check + */ + isValidBasenameErrorMessage(name: string | null | undefined): string; } /** @@ -94,7 +120,7 @@ declare module 'dataworkspace' { /** * Gets the project image to be used as background in dashboard container */ - readonly image?: azdata.ThemedIconPath; + readonly image?: azdata.ThemedIconPath; } /** @@ -233,4 +259,5 @@ declare module 'dataworkspace' { * Union type representing data types in dashboard table */ export type IDashboardColumnType = 'string' | 'icon'; + } diff --git a/extensions/data-workspace/src/dialogs/newProjectDialog.ts b/extensions/data-workspace/src/dialogs/newProjectDialog.ts index 684db314ba..d6c265bdc3 100644 --- a/extensions/data-workspace/src/dialogs/newProjectDialog.ts +++ b/extensions/data-workspace/src/dialogs/newProjectDialog.ts @@ -15,6 +15,7 @@ import { IconPathHelper } from '../common/iconHelper'; import { defaultProjectSaveLocation } from '../common/projectLocationHelper'; import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/telemetry'; import { WorkspaceService } from '../services/workspaceService'; +import { isValidBasename, isValidBasenameErrorMessage } from '../common/pathUtilsHelper'; class NewProjectDialogModel { projectTypeId: string = ''; @@ -174,16 +175,25 @@ export class NewProjectDialog extends DialogBase { } })); - const projectNameTextBox = view.modelBuilder.inputBox().withProps({ - ariaLabel: constants.ProjectNameTitle, - placeHolder: constants.ProjectNamePlaceholder, - required: true, - width: constants.DefaultInputWidth - }).component(); + const projectNameTextBox = view.modelBuilder.inputBox().withValidation( + component => isValidBasename(component.value) + ) + .withProps({ + ariaLabel: constants.ProjectNameTitle, + placeHolder: constants.ProjectNamePlaceholder, + required: true, + width: constants.DefaultInputWidth + }).component(); - this.register(projectNameTextBox.onTextChanged(() => { - this.model.name = projectNameTextBox.value!; - return projectNameTextBox.updateProperty('title', projectNameTextBox.value); + this.register(projectNameTextBox.onTextChanged(text => { + const errorMessage = isValidBasenameErrorMessage(text); + if (errorMessage) { + // Set validation error message if project name is invalid + return void projectNameTextBox.updateProperty('validationErrorMessage', errorMessage); + } else { + this.model.name = projectNameTextBox.value!; + return projectNameTextBox.updateProperty('title', projectNameTextBox.value); + } })); const locationTextBox = view.modelBuilder.inputBox().withProps({ diff --git a/extensions/data-workspace/src/dialogs/newProjectQuickpick.ts b/extensions/data-workspace/src/dialogs/newProjectQuickpick.ts index 1a1cc46856..fae6a7ca96 100644 --- a/extensions/data-workspace/src/dialogs/newProjectQuickpick.ts +++ b/extensions/data-workspace/src/dialogs/newProjectQuickpick.ts @@ -9,6 +9,7 @@ import * as constants from '../common/constants'; import { directoryExist, showInfoMessageWithLearnMoreLink } from '../common/utils'; import { defaultProjectSaveLocation } from '../common/projectLocationHelper'; import { WorkspaceService } from '../services/workspaceService'; +import { isValidBasename, isValidBasenameErrorMessage } from '../common/pathUtilsHelper'; /** * Create flow for a New Project using only VS Code-native APIs such as QuickPick @@ -39,7 +40,7 @@ export async function createNewProjectWithQuickpick(workspaceService: WorkspaceS { title: constants.EnterProjectName, validateInput: (value) => { - return value ? undefined : constants.NameCannotBeEmpty; + return isValidBasename(value) ? undefined : isValidBasenameErrorMessage(value); }, ignoreFocusOut: true }); diff --git a/extensions/data-workspace/src/test/dialogs/pathUtilsHelper.test.ts b/extensions/data-workspace/src/test/dialogs/pathUtilsHelper.test.ts new file mode 100644 index 0000000000..1373b97db1 --- /dev/null +++ b/extensions/data-workspace/src/test/dialogs/pathUtilsHelper.test.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * 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 constants from '../../common/constants'; +import * as os from 'os'; +import * as path from 'path'; +import { isValidBasename, isValidBasenameErrorMessage } from '../../common/pathUtilsHelper'; + +const isWindows = os.platform() === 'win32'; + +suite('Check for invalid filename tests', function (): void { + test('Should determine invalid filenames', async () => { + // valid filename + should(isValidBasename(formatFileName('ValidName.sqlproj'))).equal(true); + + // invalid for both Windows and non-Windows + let invalidNames: string[] = [ + ' .sqlproj', + ' .sqlproj', + ' .sqlproj', + '..sqlproj', + '...sqlproj', + // most file systems do not allow files > 255 length + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.sqlproj' + ]; + + for (let invalidName of invalidNames) { + should(isValidBasename(formatFileName(invalidName))).equal(false); + } + + should(isValidBasename(undefined)).equal(false); + should(isValidBasename('\\')).equal(false); + should(isValidBasename('/')).equal(false); + }); + + test('Should determine invalid Windows filenames', async () => { + let invalidNames: string[] = [ + // invalid characters only for Windows + '?.sqlproj', + ':.sqlproj', + '*.sqlproj', + '<.sqlproj', + '>.sqlproj', + '|.sqlproj', + '".sqlproj', + // Windows filenames cannot end with a whitespace + 'test .sqlproj', + 'test .sqlproj' + ]; + + for (let invalidName of invalidNames) { + should(isValidBasename(formatFileName(invalidName))).equal(isWindows ? false : true); + } + }); + + test('Should determine Windows forbidden filenames', async () => { + let invalidNames: string[] = [ + // invalid only for Windows + 'CON.sqlproj', + 'PRN.sqlproj', + 'AUX.sqlproj', + 'NUL.sqlproj', + 'COM1.sqlproj', + 'COM2.sqlproj', + 'COM3.sqlproj', + 'COM4.sqlproj', + 'COM5.sqlproj', + 'COM6.sqlproj', + 'COM7.sqlproj', + 'COM8.sqlproj', + 'COM9.sqlproj', + 'LPT1.sqlproj', + 'LPT2.sqlproj', + 'LPT3.sqlproj', + 'LPT4.sqlproj', + 'LPT5.sqlproj', + 'LPT6.sqlproj', + 'LPT7.sqlproj', + 'LPT8.sqlproj', + 'LPT9.sqlproj', + ]; + + for (let invalidName of invalidNames) { + should(isValidBasename(formatFileName(invalidName))).equal(isWindows ? false : true); + } + }); +}); + +suite('Check for invalid filename error tests', function (): void { + test('Should determine invalid filenames', async () => { + // valid filename + should(isValidBasenameErrorMessage(formatFileName('ValidName.sqlproj'))).equal(''); + + // invalid for both Windows and non-Windows + should(isValidBasenameErrorMessage(formatFileName(' .sqlproj'))).equal(constants.whitespaceFilenameErrorMessage); + should(isValidBasenameErrorMessage(formatFileName(' .sqlproj'))).equal(constants.whitespaceFilenameErrorMessage); + should(isValidBasenameErrorMessage(formatFileName(' .sqlproj'))).equal(constants.whitespaceFilenameErrorMessage); + should(isValidBasenameErrorMessage(formatFileName('..sqlproj'))).equal(constants.reservedValueErrorMessage); + should(isValidBasenameErrorMessage(formatFileName('...sqlproj'))).equal(constants.reservedValueErrorMessage); + should(isValidBasenameErrorMessage(undefined)).equal(constants.undefinedFilenameErrorMessage); + should(isValidBasenameErrorMessage('\\')).equal(isWindows ? constants.whitespaceFilenameErrorMessage : constants.invalidFileCharsErrorMessage); + should(isValidBasenameErrorMessage('/')).equal(constants.whitespaceFilenameErrorMessage); + + // most file systems do not allow files > 255 length + should(isValidBasenameErrorMessage(formatFileName('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.sqlproj'))).equal(constants.tooLongFilenameErrorMessage); + }); + + test('Should determine invalid Windows filenames', async () => { + let invalidNames: string[] = [ + // invalid characters only for Windows + '?.sqlproj', + ':.sqlproj', + '*.sqlproj', + '<.sqlproj', + '>.sqlproj', + '|.sqlproj', + '".sqlproj' + ]; + + for (let invalidName of invalidNames) { + should(isValidBasenameErrorMessage(formatFileName(invalidName))).equal(isWindows ? constants.invalidFileCharsErrorMessage : ''); + } + // Windows filenames cannot end with a whitespace + should(isValidBasenameErrorMessage(formatFileName('test .sqlproj'))).equal(isWindows ? constants.trailingWhitespaceErrorMessage : ''); + should(isValidBasenameErrorMessage(formatFileName('test .sqlproj'))).equal(isWindows ? constants.trailingWhitespaceErrorMessage : ''); + }); + + test('Should determine Windows forbidden filenames', async () => { + let invalidNames: string[] = [ + // invalid only for Windows + 'CON.sqlproj', + 'PRN.sqlproj', + 'AUX.sqlproj', + 'NUL.sqlproj', + 'COM1.sqlproj', + 'COM2.sqlproj', + 'COM3.sqlproj', + 'COM4.sqlproj', + 'COM5.sqlproj', + 'COM6.sqlproj', + 'COM7.sqlproj', + 'COM8.sqlproj', + 'COM9.sqlproj', + 'LPT1.sqlproj', + 'LPT2.sqlproj', + 'LPT3.sqlproj', + 'LPT4.sqlproj', + 'LPT5.sqlproj', + 'LPT6.sqlproj', + 'LPT7.sqlproj', + 'LPT8.sqlproj', + 'LPT9.sqlproj', + ]; + + for (let invalidName of invalidNames) { + should(isValidBasenameErrorMessage(formatFileName(invalidName))).equal(isWindows ? constants.reservedWindowsFilenameErrorMessage : ''); + } + }); +}); + +function formatFileName(filename: string): string { + return path.join(os.tmpdir(), filename); +} + diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 944055cadf..f52c4472de 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -731,3 +731,35 @@ export async function getTargetPlatformFromServerVersion(serverInfo: azdataType. return targetPlatform; } + +/** + * Determines if a given character is a valid filename character + * @param c Character to validate + */ +export function isValidFilenameCharacter(c: string): boolean { + return getDataWorkspaceExtensionApi().isValidFilenameCharacter(c); +} + +/** + * Replaces invalid filename characters in a string with underscores + * @param s The string to be sanitized for a filename + */ +export function sanitizeStringForFilename(s: string): string { + return getDataWorkspaceExtensionApi().sanitizeStringForFilename(s); +} + +/** + * Returns true if the string is a valid filename + * @param name filename to check + */ +export function isValidBasename(name?: string): boolean { + return getDataWorkspaceExtensionApi().isValidBasename(name); +} + +/** + * Returns specific error message if file name is invalid + * @param name filename to check + */ +export function isValidBasenameErrorMessage(name?: string): string { + return getDataWorkspaceExtensionApi().isValidBasenameErrorMessage(name); +} diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index b35f021100..5e51a76271 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -651,7 +651,7 @@ export class ProjectsController { } private async promptForNewObjectName(itemType: templates.ProjectScriptType, _project: ISqlProject, folderPath: string, fileExtension?: string, defaultName?: string): Promise { - const suggestedName = defaultName ?? itemType.friendlyName.replace(/\s+/g, ''); + const suggestedName = utils.sanitizeStringForFilename(defaultName ?? itemType.friendlyName.replace(/\s+/g, '')); let counter: number = 0; do { @@ -662,6 +662,9 @@ export class ProjectsController { const itemObjectName = await vscode.window.showInputBox({ prompt: constants.newObjectNamePrompt(itemType.friendlyName), value: `${suggestedName}${counter}`, + validateInput: (value) => { + return utils.isValidBasename(value) ? undefined : utils.isValidBasenameErrorMessage(value); + }, ignoreFocusOut: true, }); @@ -1279,7 +1282,7 @@ export class ProjectsController { prompt: constants.autorestProjectName, value: defaultName, validateInput: (value) => { - return value.trim() ? undefined : constants.nameMustNotBeEmpty; + return utils.isValidBasename(value.trim()) ? undefined : utils.isValidBasenameErrorMessage(value.trim()); } }); diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts index 3ff992e4bd..66dc068873 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts @@ -14,7 +14,7 @@ import { cssStyles } from '../common/uiConstants'; import { ImportDataModel } from '../models/api/import'; import { Deferred } from '../common/promise'; import { getConnectionName, mapExtractTargetEnum } from './utils'; -import { exists, getAzdataApi, getDataWorkspaceExtensionApi } from '../common/utils'; +import { exists, getAzdataApi, getDataWorkspaceExtensionApi, isValidBasename, isValidBasenameErrorMessage, sanitizeStringForFilename } from '../common/utils'; export class CreateProjectFromDatabaseDialog { public dialog: azdataType.window.Dialog; @@ -218,7 +218,7 @@ export class CreateProjectFromDatabaseDialog { } public setProjectName() { - this.projectNameTextBox!.value = newProjectTool.defaultProjectNameFromDb(this.sourceDatabaseDropDown!.value); + this.projectNameTextBox!.value = newProjectTool.defaultProjectNameFromDb(sanitizeStringForFilename(this.sourceDatabaseDropDown!.value)); } private createSourceConnectionComponent(view: azdataType.ModelView): azdataType.InputBoxComponent { @@ -290,17 +290,26 @@ export class CreateProjectFromDatabaseDialog { } private createProjectNameRow(view: azdataType.ModelView): azdataType.FlexContainer { - this.projectNameTextBox = view.modelBuilder.inputBox().withProps({ - ariaLabel: constants.projectNamePlaceholderText, - placeHolder: constants.projectNamePlaceholderText, - required: true, - width: cssStyles.createProjectFromDatabaseTextboxWidth - }).component(); + this.projectNameTextBox = view.modelBuilder.inputBox().withValidation( + component => isValidBasename(component.value) + ) + .withProps({ + ariaLabel: constants.projectNamePlaceholderText, + placeHolder: constants.projectNamePlaceholderText, + required: true, + width: cssStyles.createProjectFromDatabaseTextboxWidth + }).component(); - this.projectNameTextBox.onTextChanged(() => { - this.projectNameTextBox!.value = this.projectNameTextBox!.value?.trim(); - void this.projectNameTextBox!.updateProperty('title', this.projectNameTextBox!.value); - this.tryEnableCreateButton(); + this.projectNameTextBox.onTextChanged(text => { + const errorMessage = isValidBasenameErrorMessage(text); + if (errorMessage) { + // Set validation error message if project name is invalid + void this.projectNameTextBox!.updateProperty('validationErrorMessage', errorMessage); + } else { + this.projectNameTextBox!.value = this.projectNameTextBox!.value?.trim(); + void this.projectNameTextBox!.updateProperty('title', this.projectNameTextBox!.value); + this.tryEnableCreateButton(); + } }); const projectNameLabel = view.modelBuilder.text().withProps({ diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts index edb26ae2a1..8af567c339 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as constants from '../common/constants'; -import { exists, getVscodeMssqlApi } from '../common/utils'; +import { exists, getVscodeMssqlApi, isValidBasename, isValidBasenameErrorMessage, sanitizeStringForFilename } from '../common/utils'; import { IConnectionInfo } from 'vscode-mssql'; import { defaultProjectNameFromDb, defaultProjectSaveLocation } from '../tools/newProjectTool'; import { ImportDataModel } from '../models/api/import'; @@ -70,9 +70,9 @@ export async function createNewProjectFromDatabaseWithQuickpick(connectionInfo?: const projectName = await vscode.window.showInputBox( { title: constants.projectNamePlaceholderText, - value: defaultProjectNameFromDb(selectedDatabase), + value: defaultProjectNameFromDb(sanitizeStringForFilename(selectedDatabase)), validateInput: (value) => { - return value ? undefined : constants.nameMustNotBeEmpty; + return isValidBasename(value) ? undefined : isValidBasenameErrorMessage(value); }, ignoreFocusOut: true }); diff --git a/extensions/sql-database-projects/src/test/dialogs/utils.test.ts b/extensions/sql-database-projects/src/test/dialogs/utils.test.ts index 2387ff6f2e..aea0342aca 100644 --- a/extensions/sql-database-projects/src/test/dialogs/utils.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/utils.test.ts @@ -29,4 +29,3 @@ describe('Tests to verify dialog utils functions', function (): void { should(getDefaultDockerImageWithTag('AzureV12', 'mcr.microsoft.com/azure-sql-edge', azureLiteImageInfo)).equals(`${azureLiteImageInfo?.name}`, 'Unexpected docker image returned for target platform Azure Azure lite base image'); }); }); -