diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index a658ba1fb2..90d1f164b7 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -21,6 +21,8 @@ export const noPreviousData = (tableName: string): string => { return localize(' export const gitCloneMessage = (url: string): string => { return localize('gitCloneMessage', "Cloning git repository '{0}'...", url); }; export const gitCloneError = localize('gitCloneError', "Error during git clone. View git output for more details"); export const openedProjectsUndefinedAfterRefresh = localize('openedProjectsUndefinedAfterRefresh', "List of opened projects should not be undefined after refresh from disk."); +export const dragAndDropNotSupported = localize('dragAndDropNotSupported', "This project type does not support drag and drop."); +export const onlyMovingOneFileIsSupported = localize('onlyMovingOneFileIsSupported', "Only moving one file at a time is supported."); // UI export const OkButtonText = localize('dataworkspace.ok', "OK"); diff --git a/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts b/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts index 7ededd7ae7..a40b0377dc 100644 --- a/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts +++ b/extensions/data-workspace/src/common/workspaceTreeDataProvider.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { IWorkspaceService } from './interfaces'; -import { ProjectsFailedToLoad, UnknownProjectsError } from './constants'; +import { dragAndDropNotSupported, onlyMovingOneFileIsSupported, ProjectsFailedToLoad, UnknownProjectsError } from './constants'; import { WorkspaceTreeItem } from 'dataworkspace'; import { TelemetryReporter } from './telemetry'; import Logger from './logger'; @@ -14,11 +14,16 @@ import Logger from './logger'; /** * Tree data provider for the workspace main view */ -export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider{ +export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController { + dropMimeTypes = ['application/vnd.code.tree.WorkspaceTreeDataProvider']; + dragMimeTypes = []; // The recommended mime type of the tree (`application/vnd.code.tree.WorkspaceTreeDataProvider`) is automatically added. + constructor(private _workspaceService: IWorkspaceService) { this._workspaceService.onDidWorkspaceProjectsChange(() => { return this.refresh(); }); + + vscode.window.createTreeView('dataworkspace.views.main', { canSelectMany: false, treeDataProvider: this, dragAndDropController: this }); } private _onDidChangeTreeData: vscode.EventEmitter | undefined = new vscode.EventEmitter(); @@ -108,4 +113,42 @@ export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider { + dataTransfer.set('application/vnd.code.tree.WorkspaceTreeDataProvider', new vscode.DataTransferItem(treeItems.map(t => t.element))); + } + + async handleDrop(target: WorkspaceTreeItem | undefined, sources: vscode.DataTransfer): Promise { + if (!target) { + return; + } + + const transferItem = sources.get('application/vnd.code.tree.WorkspaceTreeDataProvider'); + + // Only support moving one file at a time + // canSelectMany is set to false for the WorkspaceTreeDataProvider, so this condition should never be true + if (transferItem?.value.length > 1) { + void vscode.window.showErrorMessage(onlyMovingOneFileIsSupported); + return; + } + + const projectUri = transferItem?.value[0].projectFileUri; + if (!projectUri) { + return; + } + + const projectProvider = await this._workspaceService.getProjectProvider(projectUri); + if (!projectProvider) { + return; + } + + if (!projectProvider?.supportsDragAndDrop || !projectProvider.moveFile) { + void vscode.window.showErrorMessage(dragAndDropNotSupported); + return; + } + + // Move the file + await projectProvider!.moveFile(projectUri, transferItem?.value[0], target); + void this.refresh(); + } } diff --git a/extensions/data-workspace/src/dataworkspace.d.ts b/extensions/data-workspace/src/dataworkspace.d.ts index 28bee46019..e774b22423 100644 --- a/extensions/data-workspace/src/dataworkspace.d.ts +++ b/extensions/data-workspace/src/dataworkspace.d.ts @@ -121,6 +121,19 @@ declare module 'dataworkspace' { * Gets the project image to be used as background in dashboard container */ readonly image?: azdata.ThemedIconPath; + + /** + * Whether or not the tree data provider supports drag and drop + */ + readonly supportsDragAndDrop?: boolean; + + /** + * Moves a file from the source to target location. Must be implemented if supportsDragAndDrop is true + * @param projectUri + * @param source + * @param target + */ + moveFile?(projectUri: vscode.Uri, source: any, target: WorkspaceTreeItem): Promise; } /** diff --git a/extensions/data-workspace/src/main.ts b/extensions/data-workspace/src/main.ts index 980eeb1e6c..1bd493e388 100644 --- a/extensions/data-workspace/src/main.ts +++ b/extensions/data-workspace/src/main.ts @@ -42,10 +42,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { setProjectProviderContextValue(workspaceService); @@ -91,7 +87,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - await workspaceService.removeProject(vscode.Uri.file(treeItem.element.project.projectFilePath)); + await workspaceService.removeProject(treeItem.element.projectFileUri); })); context.subscriptions.push(vscode.commands.registerCommand('projects.manageProject', async (treeItem: WorkspaceTreeItem) => { diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 7680d327e5..85400a64cd 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -652,3 +652,10 @@ export function downloadingFromTo(from: string, to: string) { return localize('d export function extractingDacFxDlls(location: string) { return localize('extractingDacFxDlls', "Extracting DacFx build DLLs to {0}", location); } export function errorDownloading(url: string, error: string) { return localize('errorDownloading', "Error downloading {0}. Error: {1}", url, error); } export function errorExtracting(path: string, error: string) { return localize('errorExtracting', "Error extracting files from {0}. Error: {1}", path, error); } + +// move +export const onlyMoveSqlFilesSupported = localize('onlyMoveSqlFilesSupported', "Only moving .sql files is supported"); +export const movingFilesBetweenProjectsNotSupported = localize('movingFilesBetweenProjectsNotSupported', "Moving files between projects is not supported"); +export function errorMovingFile(source: string, destination: string, error: string) { return localize('errorMovingFile', "Error when moving file from {0} to {1}. Error: {2}", source, destination, error); } +export function moveConfirmationPrompt(source: string, destination: string) { return localize('moveConfirmationPrompt', "Are you sure you want to move {0} to {1}?", source, destination); } +export const move = localize('Move', "Move"); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 6d84dfa068..9389fea25e 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -14,6 +14,7 @@ import * as vscode from 'vscode'; import type * as azdataType from 'azdata'; import * as dataworkspace from 'dataworkspace'; import type * as mssqlVscode from 'vscode-mssql'; +import * as fse from 'fs-extra'; import { promises as fs } from 'fs'; import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog'; @@ -1824,6 +1825,68 @@ export class ProjectsController { } //#endregion + + /** + * Move a file in the project tree + * @param projectUri URI of the project + * @param source + * @param target + */ + public async moveFile(projectUri: vscode.Uri, source: any, target: dataworkspace.WorkspaceTreeItem): Promise { + const sourceFileNode = source as FileNode; + + // only moving files is supported + if (!sourceFileNode || !(sourceFileNode instanceof FileNode)) { + void vscode.window.showErrorMessage(constants.onlyMoveSqlFilesSupported); + return; + } + + // Moving files to the SQLCMD variables and Database references folders isn't allowed + // TODO: should there be an error displayed if a file attempting to move a file to sqlcmd variables or database references? Or just silently fail and do nothing? + if (!target.element.fileSystemUri) { + return; + } + + // TODO: handle moving between different projects + if (projectUri.fsPath !== target.element.projectFileUri.fsPath) { + void vscode.window.showErrorMessage(constants.movingFilesBetweenProjectsNotSupported); + return; + } + + // Calculate the new file path + let folderPath; + // target is the root of project, which is the .sqlproj + if (target.element.projectFileUri.fsPath === target.element.fileSystemUri.fsPath) { + folderPath = path.dirname(target.element.projectFileUri.fsPath!); + } else { + // target is another file or folder + folderPath = target.element.fileSystemUri.fsPath.endsWith(constants.sqlFileExtension) ? path.dirname(target.element.fileSystemUri.fsPath) : target.element.fileSystemUri.fsPath; + } + + const newPath = path.join(folderPath!, sourceFileNode.friendlyName); + + // don't do anything if the path is the same + if (newPath === sourceFileNode.fileSystemUri.fsPath) { + return; + } + + const result = await vscode.window.showWarningMessage(constants.moveConfirmationPrompt(path.basename(sourceFileNode.fileSystemUri.fsPath), path.basename(folderPath)), { modal: true }, constants.move) + if (result !== constants.move) { + return; + } + + // Move the file + try { + const project = await Project.openProject(projectUri.fsPath); + + // TODO: swap out with DacFx projects apis - currently moving pre/post deploy scripts don't work, but they should work after the swap + await fse.move(sourceFileNode.fileSystemUri.fsPath, newPath!); + await project.exclude(project.files.find(f => f.fsUri.fsPath === sourceFileNode.fileSystemUri.fsPath)!); + await project.addExistingItem(newPath!); + } catch (e) { + void vscode.window.showErrorMessage(constants.errorMovingFile(sourceFileNode.fileSystemUri.fsPath, newPath, utils.getErrorMessage(e))); + } + } } export interface NewProjectParams { diff --git a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts index 2c820eadcc..1ed64f162f 100644 --- a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts +++ b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts @@ -22,6 +22,18 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide } + supportsDragAndDrop: boolean = true; + + /** + * Move a file in the project tree + * @param projectUri + * @param source + * @param target + */ + public async moveFile(projectUri: vscode.Uri, source: any, target: dataworkspace.WorkspaceTreeItem): Promise { + return this.projectController.moveFile(projectUri, source, target); + } + /** * Gets the project tree data provider * @param projectFilePath The project file Uri diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 6c8527845d..8d01458b2c 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -810,6 +810,111 @@ describe('ProjectsController', function (): void { should(project.files.filter(f => f.type === EntryType.Folder).length).equal(expectedFolders.length, 'Unexpected number of folders in project'); }); }); + + describe('Move file', function (): void { + it('Should move a file to another folder', async function (): Promise { + const spy = sinon.spy(vscode.window, 'showErrorMessage'); + sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.move)); + + let proj = await testUtils.createTestProject(baselines.openSdkStyleSqlProjectBaseline); + + const projTreeRoot = await setupMoveTest(proj); + + const projController = new ProjectsController(testContext.outputChannel); + + // try to move a file from the root folder into the UpperFolder + const sqlFileNode = projTreeRoot.children.find(x => x.friendlyName === 'script1.sql'); + const folderWorkspaceTreeItem = createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!); + await projController.moveFile(vscode.Uri.file(proj.projectFilePath), sqlFileNode, folderWorkspaceTreeItem); + + should(spy.notCalled).be.true('showErrorMessage should not have been called'); + + // reload project and verify file was moved + proj = await Project.openProject(proj.projectFilePath); + should(proj.files.find(f => f.relativePath === 'UpperFolder\\script1.sql') !== undefined).be.true('The file path should have been updated'); + should(await utils.exists(path.join(proj.projectFolderPath, 'UpperFolder', 'script1.sql'))).be.true('The moved file should exist'); + }); + + it('Should not allow moving a file to Database References or SQLCMD folder', async function (): Promise { + const spy = sinon.spy(vscode.window, 'showErrorMessage'); + sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.move)); + + let proj = await testUtils.createTestProject(baselines.openSdkStyleSqlProjectBaseline); + const projTreeRoot = await setupMoveTest(proj); + const projController = new ProjectsController(testContext.outputChannel); + + const foldersToTest = ['SQLCMD Variables', 'Database References']; + + for (const folder of foldersToTest) { + // try to move a file from the root folder into the UpperFolder + const sqlFileNode = projTreeRoot.children.find(x => x.friendlyName === 'script1.sql'); + const sqlCmdVariablesWorkspaceTreeItem = createWorkspaceTreeItem(projTreeRoot.children.find(x => x.friendlyName === folder)!); + await projController.moveFile(vscode.Uri.file(proj.projectFilePath), sqlFileNode, sqlCmdVariablesWorkspaceTreeItem); + + // reload project and verify file was not moved + proj = await Project.openProject(proj.projectFilePath); + should(proj.files.find(f => f.relativePath === 'script1.sql') !== undefined).be.true(`The file path should not have been updated when trying to move script1.sql to ${folder}`); + should(spy.notCalled).be.true('showErrorMessage should not have been called.'); + spy.restore(); + } + }); + + it('Should only allow moving files', async function (): Promise { + const spy = sinon.spy(vscode.window, 'showErrorMessage'); + let proj = await testUtils.createTestProject(baselines.openSdkStyleSqlProjectBaseline); + const projTreeRoot = await setupMoveTest(proj); + const projController = new ProjectsController(testContext.outputChannel); + + // try to move sqlcmd variable + const sqlcmdVarNode = projTreeRoot.children.find(x => x.friendlyName === 'SQLCMD Variables')!.children[0]; + const projectRootWorkspaceTreeItem = createWorkspaceTreeItem(projTreeRoot); + await projController.moveFile(vscode.Uri.file(proj.projectFilePath), sqlcmdVarNode, projectRootWorkspaceTreeItem); + + should(spy.calledOnce).be.true('showErrorMessage should have been called exactly once when trying to move a sqlcmd variable'); + should(spy.calledWith(constants.onlyMoveSqlFilesSupported)).be.true(`showErrorMessage not called with expected message '${constants.onlyMoveSqlFilesSupported}' Actual '${spy.getCall(0).args[0]}'`); + spy.restore(); + + // try moving a database reference + const dbRefNode = projTreeRoot.children.find(x => x.friendlyName === 'Database References')!.children[0]; + await projController.moveFile(vscode.Uri.file(proj.projectFilePath), dbRefNode, projectRootWorkspaceTreeItem); + + should(spy.calledOnce).be.true('showErrorMessage should have been called exactly once when trying to move a database reference'); + should(spy.calledWith(constants.onlyMoveSqlFilesSupported)).be.true(`showErrorMessage not called with expected message '${constants.onlyMoveSqlFilesSupported}' Actual '${spy.getCall(0).args[0]}'`); + spy.restore(); + + // try moving a folder + const folderNode = projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder'); + await projController.moveFile(vscode.Uri.file(proj.projectFilePath), folderNode, projectRootWorkspaceTreeItem); + + should(spy.calledOnce).be.true('showErrorMessage should have been called exactly once when trying to move a folder'); + should(spy.calledWith(constants.onlyMoveSqlFilesSupported)).be.true(`showErrorMessage not called with expected message '${constants.onlyMoveSqlFilesSupported}' Actual '${spy.getCall(0).args[0]}'`); + spy.restore(); + }); + + it('Should not allow moving files between projects', async function (): Promise { + const spy = sinon.spy(vscode.window, 'showErrorMessage'); + sinon.stub(vscode.window, 'showWarningMessage').returns(Promise.resolve(constants.move)); + + let proj1 = await testUtils.createTestProject(baselines.openSdkStyleSqlProjectBaseline); + let proj2 = await testUtils.createTestProject(baselines.openSdkStyleSqlProjectBaseline); + + const projTreeRoot1 = await setupMoveTest(proj1); + const projTreeRoot2 = await setupMoveTest(proj2); + const projController = new ProjectsController(testContext.outputChannel); + + // try to move a file from the root folder of proj1 to the UpperFolder of proj2 + const proj1SqlFileNode = projTreeRoot1.children.find(x => x.friendlyName === 'script1.sql'); + const proj2FolderWorkspaceTreeItem = createWorkspaceTreeItem(projTreeRoot2.children.find(x => x.friendlyName === 'UpperFolder')!); + await projController.moveFile(vscode.Uri.file(proj1.projectFilePath), proj1SqlFileNode, proj2FolderWorkspaceTreeItem); + + should(spy.called).be.true('showErrorMessage should have been called'); + should(spy.calledWith(constants.movingFilesBetweenProjectsNotSupported)).be.true(`showErrorMessage not called with expected message '${constants.movingFilesBetweenProjectsNotSupported}' Actual '${spy.getCall(0).args[0]}'`); + + // verify script1.sql was not moved + proj1 = await Project.openProject(proj1.projectFilePath); + should(proj1.files.find(f => f.relativePath === 'script1.sql') !== undefined).be.true(`The file path should not have been updated when trying to move script1.sql to proj2`); + }); + }); }); async function setupDeleteExcludeTest(proj: Project): Promise<[FileProjectEntry, ProjectRootTreeItem, FileProjectEntry, FileProjectEntry, FileProjectEntry]> { @@ -836,6 +941,18 @@ async function setupDeleteExcludeTest(proj: Project): Promise<[FileProjectEntry, return [scriptEntry, projTreeRoot, preDeployEntry, postDeployEntry, noneEntry]; } +async function setupMoveTest(proj: Project): Promise { + await proj.addFolderItem('UpperFolder'); + await proj.addFolderItem('UpperFolder/LowerFolder'); + await proj.addScriptItem('UpperFolder/LowerFolder/someScript.sql', 'not a real script'); + await proj.addScriptItem('UpperFolder/LowerFolder/someOtherScript.sql', 'Also not a real script'); + await proj.addScriptItem('../anotherScript.sql', 'Also not a real script'); + await proj.addScriptItem('script1.sql', 'Also not a real script'); + + const projTreeRoot = new ProjectRootTreeItem(proj); + return projTreeRoot; +} + function createWorkspaceTreeItem(node: BaseProjectTreeItem): dataworkspace.WorkspaceTreeItem { return { element: node,