diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 2a64adc761..3b742ec570 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -84,6 +84,16 @@ "title": "%sqlDatabaseProjects.newFolder%", "category": "%sqlDatabaseProjects.displayName%" }, + { + "command": "sqlDatabaseProjects.delete", + "title": "%sqlDatabaseProjects.delete%", + "category": "%sqlDatabaseProjects.displayName%" + }, + { + "command": "sqlDatabaseProjects.exclude", + "title": "%sqlDatabaseProjects.exclude%", + "category": "%sqlDatabaseProjects.displayName%" + }, { "command": "sqlDatabaseProjects.build", "title": "%sqlDatabaseProjects.build%", @@ -151,6 +161,10 @@ "command": "sqlDatabaseProjects.newFolder", "when": "false" }, + { + "command": "sqlDatabaseProjects.delete", + "when": "false" + }, { "command": "sqlDatabaseProjects.build", "when": "false" @@ -226,10 +240,20 @@ "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.referencesRoot", "group": "4_dbProjects_addDatabaseReference" }, + { + "command": "sqlDatabaseProjects.exclude", + "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.folder || viewItem == databaseProject.itemType.file", + "group": "9_dbProjectsLast@1" + }, + { + "command": "sqlDatabaseProjects.delete", + "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.folder || viewItem == databaseProject.itemType.file", + "group": "9_dbProjectsLast@2" + }, { "command": "sqlDatabaseProjects.close", "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.project", - "group": "9_dbProjectsLast" + "group": "9_dbProjectsLast@9" } ], "objectExplorer/item/context": [ diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 3428c87620..4528e89e94 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -11,6 +11,8 @@ "sqlDatabaseProjects.importDatabase": "Import New Database Project", "sqlDatabaseProjects.properties": "Properties", "sqlDatabaseProjects.schemaCompare": "Schema Compare", + "sqlDatabaseProjects.delete": "Delete", + "sqlDatabaseProjects.exclude": "Exclude from project", "sqlDatabaseProjects.newScript": "Add Script", "sqlDatabaseProjects.newTable": "Add Table", diff --git a/extensions/sql-database-projects/src/common/apiWrapper.ts b/extensions/sql-database-projects/src/common/apiWrapper.ts index a7c3ae385c..a242b2446c 100644 --- a/extensions/sql-database-projects/src/common/apiWrapper.ts +++ b/extensions/sql-database-projects/src/common/apiWrapper.ts @@ -155,6 +155,10 @@ export class ApiWrapper { return vscode.window.showWarningMessage(message, ...items); } + public showWarningMessageOptions(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable { + return vscode.window.showWarningMessage(message, options, ...items); + } + public showOpenDialog(options: vscode.OpenDialogOptions): Thenable { return vscode.window.showOpenDialog(options); } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index c2753486d4..6117be93bc 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -44,6 +44,9 @@ export const dacpacFiles = localize('dacpacFiles', "dacpac Files"); export const publishSettingsFiles = localize('publishSettingsFiles', "Publish Settings File"); export const systemDatabase = localize('systemDatabase', "System Database"); 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); } + // Deploy dialog strings @@ -103,6 +106,16 @@ export function cannotResolvePath(path: string) { return localize('cannotResolve export function mssqlNotFound(mssqlConfigDir: string) { return localize('mssqlNotFound', "Could not get mssql extension's install location at {0}", mssqlConfigDir); } export function projBuildFailed(errorMessage: string) { return localize('projBuildFailed', "Build failed. Check output pane for more details. {0}", errorMessage); } export function unexpectedProjectContext(uri: string) { return localize('unexpectedProjectContext', "Unable to establish project context. Command invoked from unexpected location: {0}", uri); } +export function unableToPerformAction(action: string, uri: string) { return localize('unableToPerformAction', "Unable to locate '{0}' target: '{1}'", action, uri); } +export function unableToFindObject(path: string, objType: string) { return localize('unableToFindFile', "Unable to find {1} with path '{0}'", path, objType); } + +// Action types +export const deleteAction = localize('deleteAction', 'Delete'); +export const excludeAction = localize('excludeAction', 'Exclude'); + +// Project tree object types +export const fileObject = localize('fileObject', "file"); +export const folderObject = localize('folderObject', "folder"); // Project script types diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 06da7fe245..fbd6378803 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -59,7 +59,7 @@ export async function exists(path: string): Promise { try { await fs.access(path); return true; - } catch (e) { + } catch { return false; } } diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 585e7d1996..805fee18b7 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -16,6 +16,7 @@ import { ProjectsController } from './projectController'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { NetCoreTool } from '../tools/netcoreTool'; import { Project } from '../models/project'; +import { FileNode, FolderNode } from '../models/tree/fileFolderTreeItem'; const SQL_DATABASE_PROJECTS_VIEW_ID = 'sqlDatabaseProjectsView'; @@ -62,8 +63,9 @@ export default class MainController implements Disposable { this.apiWrapper.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); }); - this.apiWrapper.registerCommand('sqlDatabaseProjects.addDatabaseReference', async (node: BaseProjectTreeItem) => { await this.projectsController.addDatabaseReference(node); }); + this.apiWrapper.registerCommand('sqlDatabaseProjects.delete', async (node: BaseProjectTreeItem) => { await this.projectsController.delete(node); }); + this.apiWrapper.registerCommand('sqlDatabaseProjects.exclude', async (node: FileNode | FolderNode) => { await this.projectsController.exclude(node); }); // init view const treeView = this.apiWrapper.createTreeView(SQL_DATABASE_PROJECTS_VIEW_ID, { treeDataProvider: this.dbProjectTreeViewProvider }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 9ef7b7cf20..ccef684bb0 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -18,9 +18,9 @@ import { IConnectionProfile, TaskExecutionMode } from 'azdata'; import { promises as fs } from 'fs'; import { ApiWrapper } from '../common/apiWrapper'; import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; -import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform } from '../models/project'; +import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform, ProjectEntry } from '../models/project'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; -import { FolderNode } from '../models/tree/fileFolderTreeItem'; +import { FolderNode, FileNode } from '../models/tree/fileFolderTreeItem'; import { IDeploymentProfile, IGenerateScriptProfile, PublishSettings } from '../models/IDeploymentProfile'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; @@ -155,7 +155,7 @@ export class ProjectsController { } public closeProject(treeNode: BaseProjectTreeItem) { - const project = ProjectsController.getProjectFromContext(treeNode); + const project = this.getProjectFromContext(treeNode); this.projects = this.projects.filter((e) => { return e !== project; }); this.refreshProjectsTree(); } @@ -173,7 +173,7 @@ export class ProjectsController { */ public async buildProject(project: Project): Promise; public async buildProject(context: Project | BaseProjectTreeItem): Promise { - const project: Project = ProjectsController.getProjectFromContext(context); + const project: Project = this.getProjectFromContext(context); // Check mssql extension for project dlls (tracking issue #10273) await this.buildHelper.createBuildDirFolder(); @@ -205,7 +205,7 @@ export class ProjectsController { */ public async deployProject(project: Project): Promise; public async deployProject(context: Project | BaseProjectTreeItem): Promise { - const project: Project = ProjectsController.getProjectFromContext(context); + const project: Project = this.getProjectFromContext(context); let deployDatabaseDialog = this.getDeployDialog(project); deployDatabaseDialog.deploy = async (proj, prof) => await this.executionCallback(proj, prof); @@ -266,7 +266,7 @@ export class ProjectsController { await this.buildProject(treeNode); // start schema compare with the dacpac produced from build - const project = ProjectsController.getProjectFromContext(treeNode); + const project = this.getProjectFromContext(treeNode); const dacpacPath = path.join(project.projectFolderPath, 'bin', 'Debug', `${project.projectFileName}.dacpac`); // check that dacpac exists @@ -281,7 +281,7 @@ export class ProjectsController { } public async addFolderPrompt(treeNode: BaseProjectTreeItem) { - const project = ProjectsController.getProjectFromContext(treeNode); + const project = this.getProjectFromContext(treeNode); const newFolderName = await this.promptForNewObjectName(new templates.ProjectScriptType(templates.folder, constants.folderFriendlyName, ''), project); if (!newFolderName) { @@ -296,7 +296,7 @@ export class ProjectsController { } public async addItemPromptFromNode(treeNode: BaseProjectTreeItem, itemTypeName?: string) { - await this.addItemPrompt(ProjectsController.getProjectFromContext(treeNode), this.getRelativePath(treeNode), itemTypeName); + await this.addItemPrompt(this.getProjectFromContext(treeNode), this.getRelativePath(treeNode), itemTypeName); } public async addItemPrompt(project: Project, relativePath: string, itemTypeName?: string) { @@ -337,12 +337,58 @@ export class ProjectsController { this.refreshProjectsTree(); } + public async exclude(context: FileNode | FolderNode): Promise { + const project = this.getProjectFromContext(context); + + const fileEntry = this.getProjectEntry(project, context); + + if (fileEntry) { + await project.exclude(fileEntry); + } else { + this.apiWrapper.showErrorMessage(constants.unableToPerformAction(constants.excludeAction, context.uri.path)); + } + + this.refreshProjectsTree(); + } + + public async delete(context: BaseProjectTreeItem): Promise { + const project = this.getProjectFromContext(context); + + const confirmationPrompt = context instanceof FolderNode ? constants.deleteConfirmationContents(context.friendlyName) : constants.deleteConfirmation(context.friendlyName); + const response = await this.apiWrapper.showWarningMessageOptions(confirmationPrompt, { modal: true }, constants.yesString); + + if (response !== constants.yesString) { + return; + } + + let success = false; + + if (context instanceof FileNode || FolderNode) { + const fileEntry = this.getProjectEntry(project, context); + + if (fileEntry) { + await project.deleteFileFolder(fileEntry); + success = true; + } + } + + if (success) { + this.refreshProjectsTree(); + } else { + this.apiWrapper.showErrorMessage(constants.unableToPerformAction(constants.deleteAction, context.uri.path)); + } + } + + private getProjectEntry(project: Project, context: BaseProjectTreeItem): ProjectEntry | undefined { + return project.files.find(x => utils.getPlatformSafeFileEntryPath(x.relativePath) === utils.getPlatformSafeFileEntryPath(utils.trimUri(context.root.uri, context.uri))); + } + /** * Adds a database reference to the project - * @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project + * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ public async addDatabaseReference(context: Project | BaseProjectTreeItem): Promise { - const project = ProjectsController.getProjectFromContext(context); + const project = this.getProjectFromContext(context); try { // choose if reference is to master or a dacpac @@ -504,7 +550,7 @@ export class ProjectsController { } } - private static getProjectFromContext(context: Project | BaseProjectTreeItem) { + private getProjectFromContext(context: Project | BaseProjectTreeItem) { if (context instanceof Project) { return context; } diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index c77d688922..b2fdf6cb96 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -27,7 +27,7 @@ export class Project { public sqlCmdVariables: Record = {}; public get projectFolderPath() { - return path.dirname(this.projectFilePath); + return Uri.file(path.dirname(this.projectFilePath)).fsPath; } private projFileXmlDoc: any = undefined; @@ -179,6 +179,26 @@ export class Project { return fileEntry; } + public async exclude(entry: ProjectEntry): Promise { + const toExclude: ProjectEntry[] = this.files.filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); + await this.removeFromProjFile(toExclude); + this.files = this.files.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); + } + + public async deleteFileFolder(entry: ProjectEntry): Promise { + // compile a list of folder contents to delete; if entry is a file, contents will contain only itself + const toDeleteFiles: ProjectEntry[] = this.files.filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath) && x.type === EntryType.File); + const toDeleteFolders: ProjectEntry[] = this.files.filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath) && x.type === EntryType.Folder).sort(x => -x.relativePath.length); + + await Promise.all(toDeleteFiles.map(x => fs.unlink(x.fsUri.fsPath))); + + for (const folder of toDeleteFolders) { + await fs.rmdir(folder.fsUri.fsPath); // TODO: replace .sort() and iteration with rmdir recursive flag once that's unbugged + } + + await this.exclude(entry); + } + /** * Set the compat level of the project * Just used in tests right now, but can be used later if this functionality is added to the UI @@ -290,6 +310,19 @@ export class Project { this.findOrCreateItemGroup(constants.Build).appendChild(newFileNode); } + private removeFileFromProjFile(path: string) { + const fileNodes = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.Build); + + for (let i = 0; i < fileNodes.length; i++) { + if (fileNodes[i].getAttribute(constants.Include) === path) { + fileNodes[i].parentNode.removeChild(fileNodes[i]); + return; + } + } + + throw new Error(constants.unableToFindObject(path, constants.fileObject)); + } + private addFolderToProjFile(path: string) { const newFolderNode = this.projFileXmlDoc.createElement(constants.Folder); newFolderNode.setAttribute(constants.Include, path); @@ -297,6 +330,19 @@ export class Project { this.findOrCreateItemGroup(constants.Folder).appendChild(newFolderNode); } + private removeFolderFromProjFile(path: string) { + const folderNodes = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.Folder); + + for (let i = 0; i < folderNodes.length; i++) { + if (folderNodes[i].getAttribute(constants.Include) === path) { + folderNodes[i].parentNode.removeChild(folderNodes[i]); + return; + } + } + + throw new Error(constants.unableToFindObject(path, constants.folderObject)); + } + private addDatabaseReferenceToProjFile(entry: DatabaseReferenceProjectEntry): void { // check if reference to this database already exists if (this.databaseReferenceExists(entry)) { @@ -424,6 +470,27 @@ export class Project { await this.serializeToProjFile(this.projFileXmlDoc); } + private async removeFromProjFile(entries: ProjectEntry | ProjectEntry[]) { + if (entries instanceof ProjectEntry) { + entries = [entries]; + } + + for (const entry of entries) { + switch (entry.type) { + case EntryType.File: + this.removeFileFromProjFile(entry.relativePath); + break; + case EntryType.Folder: + this.removeFolderFromProjFile(entry.relativePath); + break; + case EntryType.DatabaseReference: + break; // not required but adding so that we dont miss when we add new items + } + } + + await this.serializeToProjFile(this.projFileXmlDoc); + } + private async serializeToProjFile(projFileContents: any) { 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/models/tree/baseTreeItem.ts b/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts index 868d61f73b..23567aa2f4 100644 --- a/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts @@ -22,6 +22,10 @@ export abstract class BaseProjectTreeItem { abstract get treeItem(): vscode.TreeItem; + public get friendlyName(): string { + return path.parse(this.uri.path).base; + } + public get root() { let node: BaseProjectTreeItem = this; diff --git a/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts index 9d6068f60c..482d7e8984 100644 --- a/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts @@ -88,7 +88,7 @@ export function sortFileFolderNodes(a: (FolderNode | FileNode), b: (FolderNode | * Converts a full filesystem URI to a project-relative URI that's compatible with the project tree */ function fsPathToProjectUri(fileSystemUri: vscode.Uri, projectNode: ProjectRootTreeItem): vscode.Uri { - const projBaseDir = path.dirname(projectNode.project.projectFilePath); + const projBaseDir = projectNode.project.projectFolderPath; let localUri = ''; if (fileSystemUri.fsPath.startsWith(projBaseDir)) { diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 131d271244..1528cce485 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -18,11 +18,13 @@ import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProje import { ProjectsController } from '../controllers/projectController'; import { promises as fs } from 'fs'; import { createContext, TestContext, mockDacFxResult } from './testContext'; -import { Project, SystemDatabase } from '../models/project'; +import { Project, SystemDatabase, ProjectEntry } from '../models/project'; import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; import { ApiWrapper } from '../common/apiWrapper'; import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; import { exists } from '../common/utils'; +import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; +import { FolderNode } from '../models/tree/fileFolderTreeItem'; let testContext: TestContext; @@ -107,6 +109,44 @@ describe('ProjectsController: project controller operations', function (): void should(project.files.length).equal(0, 'Expected to return without throwing an exception or adding a file when an empty/undefined name is provided.'); } }); + + it('Should delete nested ProjectEntry from node', async function (): Promise { + let proj = await testUtils.createTestProject(templates.newSqlProjectTemplate); + const setupResult = await setupDeleteExcludeTest(proj); + const scriptEntry = setupResult[0], projTreeRoot = setupResult[1]; + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + + await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); + + proj = new Project(proj.projectFilePath); + await proj.readProjFile(); // reload edited sqlproj from disk + + // confirm result + should(proj.files.length).equal(2, 'number of file/folder entries'); // lowerEntry and the contained scripts should be deleted + should(proj.files[1].relativePath).equal('UpperFolder'); + + should(await exists(scriptEntry.fsUri.fsPath)).equal(false, 'script is supposed to be deleted'); + }); + + it('Should exclude nested ProjectEntry from node', async function (): Promise { + let proj = await testUtils.createTestProject(templates.newSqlProjectTemplate); + const setupResult = await setupDeleteExcludeTest(proj); + const scriptEntry = setupResult[0], projTreeRoot = setupResult[1]; + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + + await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); + + proj = new Project(proj.projectFilePath); + await proj.readProjFile(); // reload edited sqlproj from disk + + // confirm result + should(proj.files.length).equal(2, 'number of file/folder entries'); // LowerFolder and the contained scripts should be deleted + should(proj.files[1].relativePath).equal('UpperFolder'); // UpperFolder should still be there + + should(await exists(scriptEntry.fsUri.fsPath)).equal(true, 'script is supposed to still exist on disk'); + }); }); describe('Deployment and deployment script generation', function (): void { @@ -404,3 +444,21 @@ describe('ProjectsController: round trip feature with SSDT', function (): void { should(project.importedTargets.length).equal(3); // additional target added by updateProjectForRoundTrip method }); }); + +async function setupDeleteExcludeTest(proj: Project): Promise<[ProjectEntry, ProjectRootTreeItem]> { + await proj.addFolderItem('UpperFolder'); + await proj.addFolderItem('UpperFolder/LowerFolder'); + const scriptEntry = await proj.addScriptItem('UpperFolder/LowerFolder/someScript.sql', 'not a real script'); + await proj.addScriptItem('UpperFolder/LowerFolder/someOtherScript.sql', 'Also not a real script'); + + const projTreeRoot = new ProjectRootTreeItem(proj); + + testContext.apiWrapper.setup(x => x.showWarningMessageOptions(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(constants.yesString)); + + // confirm setup + should(proj.files.length).equal(5, 'number of file/folder entries'); + should(path.parse(scriptEntry.fsUri.fsPath).base).equal('someScript.sql'); + should((await fs.readFile(scriptEntry.fsUri.fsPath)).toString()).equal('not a real script'); + + return [scriptEntry, projTreeRoot]; +}