Benjin/delete (#11054)

* Adding delete to package.json

* checkpoint

* Delete file working

* Working for nested folders

* Added file/folder deletion test

* addressing feedback

* Swapping QuickPick for modal

* Adding Exclude functionality

* reverting change to package.json
This commit is contained in:
Benjin Dubishar
2020-06-24 10:27:58 -07:00
committed by GitHub
parent 2ba0de10df
commit 00836e1890
11 changed files with 237 additions and 17 deletions

View File

@@ -155,6 +155,10 @@ export class ApiWrapper {
return vscode.window.showWarningMessage(message, ...items);
}
public showWarningMessageOptions(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable<string | undefined> {
return vscode.window.showWarningMessage(message, options, ...items);
}
public showOpenDialog(options: vscode.OpenDialogOptions): Thenable<vscode.Uri[] | undefined> {
return vscode.window.showOpenDialog(options);
}

View File

@@ -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

View File

@@ -59,7 +59,7 @@ export async function exists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch (e) {
} catch {
return false;
}
}

View File

@@ -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 });

View File

@@ -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<string>;
public async buildProject(context: Project | BaseProjectTreeItem): Promise<string | undefined> {
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<DeployDatabaseDialog>;
public async deployProject(context: Project | BaseProjectTreeItem): Promise<DeployDatabaseDialog> {
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<void> {
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<void> {
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<void> {
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;
}

View File

@@ -27,7 +27,7 @@ export class Project {
public sqlCmdVariables: Record<string, string> = {};
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<void> {
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<void> {
// 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, <any>{ collapseContent: true, indentation: ' ', lineSeparator: os.EOL }); // TODO: replace <any>

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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<void> {
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<void> {
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(<FolderNode>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];
}