diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index da692418ee..7e341a7352 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen - Added Time Started, Time Ended, and Time Submitted job properties to the Jobs table view. [#3055](https://github.com/zowe/zowe-explorer-vscode/issues/3055) - Implemented copy/paste functionality of data sets within and across LPARs. [#3012](https://github.com/zowe/zowe-explorer-vscode/issues/3012) +- Added Time Started, Time Ended, and Time Submitted job properties to the Jobs table view. [#3055](https://github.com/zowe/zowe-explorer-vscode/issues/3055) +- Implemented drag and drop functionality of data sets within and across LPARs. [#3413](https://github.com/zowe/zowe-explorer-vscode/pull/3413) ### Bug fixes diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetTree.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetTree.unit.test.ts index 192c6b03c3..c897d92797 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetTree.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetTree.unit.test.ts @@ -3481,3 +3481,253 @@ describe("Dataset Tree Unit Tests - Function applyPatternsToChildren", () => { withProfileMock.mockRestore(); }); }); + +describe("DataSetTree Unit Tests - Function handleDrag", () => { + function createBlockMocks() { + const session = createISession(); + const imperativeProfile = createIProfile(); + const datasetSessionNode = createDatasetSessionNode(session, imperativeProfile); + return { + session, + imperativeProfile, + datasetSessionNode, + }; + } + it("adds a DataTransferItem containing info about the dragged Data node", async () => { + createGlobalMocks(); + const blockMocks = createBlockMocks(); + const datasetNode = new ZoweDatasetNode({ + label: "draggedNode", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: blockMocks.datasetSessionNode, + session: blockMocks.session, + }); + const dataTransferSetMock = jest.fn(); + const testTree = new DatasetTree(); + testTree.handleDrag([datasetNode], { set: dataTransferSetMock } as any, undefined); + expect(dataTransferSetMock).toHaveBeenCalledWith( + "application/vnd.code.tree.zowe.ds.explorer", + new vscode.DataTransferItem([ + { + label: datasetNode.label, + uri: datasetNode.resourceUri, + }, + ]) + ); + }); +}); + +describe("DataSetTree Unit Tests - Function handleDrop", () => { + function createBlockMocks() { + const session = createISession(); + const imperativeProfile = createIProfile(); + const datasetSessionNode = createDatasetSessionNode(session, imperativeProfile); + datasetSessionNode.dirty = false; + const datasetNode = new ZoweDatasetNode({ + label: "draggedNode", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: datasetSessionNode, + session: session, + }); + datasetNode.dirty = false; + + const datasetPdsNode = new ZoweDatasetNode({ + label: "pdsnode", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + profile: datasetSessionNode.getProfile(), + parentNode: datasetSessionNode, + }); + datasetPdsNode.dirty = false; + const memberNode = new ZoweDatasetNode({ + label: "mem1", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + profile: datasetSessionNode.getProfile(), + }); + datasetPdsNode.children = [memberNode]; + datasetPdsNode.contextValue = Constants.DS_PDS_CONTEXT; + memberNode.contextValue = Constants.DS_MEMBER_CONTEXT; + memberNode.dirty = false; + const datasetSeqNode = new ZoweDatasetNode({ + label: "seqnode", + collapsibleState: vscode.TreeItemCollapsibleState.None, + profile: datasetPdsNode.getProfile(), + parentNode: datasetNode, + }); + datasetSeqNode.contextValue = Constants.DS_DS_CONTEXT; + const draggedNode = new ZoweDatasetNode({ + label: "seqnode1", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: datasetSessionNode, + }); + draggedNode.contextValue = Constants.DS_DS_CONTEXT; + return { + session, + imperativeProfile, + datasetSessionNode, + datasetNode, + datasetPdsNode, + datasetSeqNode, + memberNode, + draggedNode, + }; + } + it("returns early if there are no items in the dataTransfer object", async () => { + createGlobalMocks(); + const blockMocks = createBlockMocks(); + const statusBarMsgSpy = jest.spyOn(Gui, "setStatusBarMessage"); + const getDataTransferMock = jest.spyOn(vscode.DataTransfer.prototype, "get").mockReturnValueOnce(undefined); + const testTree = new DatasetTree(); + await testTree.handleDrop(blockMocks.datasetNode, new vscode.DataTransfer(), undefined); + expect(statusBarMsgSpy).not.toHaveBeenCalled(); + getDataTransferMock.mockRestore(); + }); + + it("handle moving of seq and pds to different profiles dropping on seq", async () => { + createGlobalMocks(); + const testTree = new DatasetTree(); + const statusBarMsgSpy = jest.spyOn(Gui, "setStatusBarMessage"); + const blockMocks = createBlockMocks(); + const datasetSession = blockMocks.datasetSessionNode; + + datasetSession.children = [blockMocks.datasetPdsNode, blockMocks.datasetSeqNode]; + const dataTransfer = new vscode.DataTransfer(); + const getDataTransferMock = jest.spyOn(dataTransfer, "get").mockReturnValueOnce({ + value: [ + { + label: blockMocks.datasetPdsNode.label as string, + uri: blockMocks.datasetPdsNode.resourceUri, + }, + ], + } as any); + const draggedNodeMock = new MockedProperty(testTree, "draggedNodes", undefined, { + [blockMocks.datasetPdsNode.resourceUri.path]: blockMocks.datasetPdsNode, + }); + const createDataSetMock = jest.fn(); + const createDataSetMemberMock = jest.fn(); + const mvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValue({ + createDataSet: createDataSetMock, + createDataSetMember: createDataSetMemberMock, + } as any); + + const createDirMock = jest.spyOn(DatasetFSProvider.instance as any, "createDirectory").mockResolvedValueOnce(undefined); + const deleteMock = jest.spyOn(vscode.workspace.fs, "delete").mockResolvedValue(undefined); + const readFileMock = jest.spyOn(DatasetFSProvider.instance, "readFile").mockResolvedValue(new Uint8Array([1, 2, 3])); + const writeFileMock = jest.spyOn(DatasetFSProvider.instance, "writeFile").mockResolvedValue(undefined); + await testTree.handleDrop(blockMocks.datasetSeqNode, dataTransfer, undefined); + expect(deleteMock).toHaveBeenCalledWith(blockMocks.datasetPdsNode.resourceUri, { recursive: true }); + expect(statusBarMsgSpy).toHaveBeenCalledWith("$(sync~spin) Moving MVS files..."); + draggedNodeMock[Symbol.dispose](); + getDataTransferMock.mockRestore(); + mvsApiMock.mockRestore(); + writeFileMock.mockRestore(); + readFileMock.mockRestore(); + createDirMock.mockRestore(); + }); + + it("Dragging a pds onto another pds on different LPAR should throw error", async () => { + createGlobalMocks(); + const testTree = new DatasetTree(); + const blockMocks = createBlockMocks(); + const dataTransfer = new vscode.DataTransfer(); + const getDataTransferMock = jest.spyOn(dataTransfer, "get").mockReturnValueOnce({ + value: [ + { + label: blockMocks.datasetPdsNode.label as string, + uri: blockMocks.datasetPdsNode.resourceUri, + }, + ], + } as any); + const draggedNodeMock = new MockedProperty(testTree, "draggedNodes", undefined, { + [blockMocks.datasetPdsNode.resourceUri.path]: blockMocks.datasetPdsNode, + }); + await testTree.handleDrop(blockMocks.datasetPdsNode, dataTransfer, undefined); + expect(Gui.errorMessage).toHaveBeenCalledWith("Cannot drop a sequential dataset or a partitioned dataset onto another PDS."); + getDataTransferMock.mockRestore(); + draggedNodeMock[Symbol.dispose](); + }); + + it("If a member is dropped on a sequential ds, should throw error", async () => { + createGlobalMocks(); + const testTree = new DatasetTree(); + const blockMocks = createBlockMocks(); + const dataTransfer = new vscode.DataTransfer(); + const getDataTransferMock = jest.spyOn(dataTransfer, "get").mockReturnValueOnce({ + value: [ + { + label: blockMocks.memberNode.label as string, + uri: blockMocks.memberNode.resourceUri, + }, + ], + } as any); + const draggedNodeMock = new MockedProperty(testTree, "draggedNodes", undefined, { + [blockMocks.memberNode.resourceUri.path]: blockMocks.memberNode, + }); + await testTree.handleDrop(blockMocks.datasetSeqNode, dataTransfer, undefined); + expect(Gui.errorMessage).toHaveBeenCalledWith("Cannot drop a member onto a sequential dataset."); + getDataTransferMock.mockRestore(); + draggedNodeMock[Symbol.dispose](); + }); + + it("Member being dropped on pds", async () => { + createGlobalMocks(); + const testTree = new DatasetTree(); + const blockMocks = createBlockMocks(); + const dataTransfer = new vscode.DataTransfer(); + const getDataTransferMock = jest.spyOn(dataTransfer, "get").mockReturnValueOnce({ + value: [ + { + label: blockMocks.memberNode.label as string, + uri: blockMocks.memberNode.resourceUri, + }, + ], + } as any); + const draggedNodeMock = new MockedProperty(testTree, "draggedNodes", undefined, { + [blockMocks.memberNode.resourceUri.path]: blockMocks.memberNode, + }); + jest.spyOn(Gui, "warningMessage").mockResolvedValueOnce(null as any); + await testTree.handleDrop(blockMocks.datasetPdsNode, dataTransfer, undefined); + expect(Gui.warningMessage).toHaveBeenCalledTimes(1); + getDataTransferMock.mockRestore(); + draggedNodeMock[Symbol.dispose](); + }); + + it("Write File throwing error", async () => { + createGlobalMocks(); + const testTree = new DatasetTree(); + const statusBarMsgSpy = jest.spyOn(Gui, "setStatusBarMessage"); + const blockMocks = createBlockMocks(); + const datasetSession = blockMocks.datasetSessionNode; + datasetSession.children = [blockMocks.datasetPdsNode, blockMocks.datasetSeqNode]; + const dataTransfer = new vscode.DataTransfer(); + const getDataTransferMock = jest.spyOn(dataTransfer, "get").mockReturnValueOnce({ + value: [ + { + label: blockMocks.draggedNode.label as string, + uri: blockMocks.draggedNode.resourceUri, + }, + ], + } as any); + const draggedNodeMock = new MockedProperty(testTree, "draggedNodes", undefined, { + [blockMocks.draggedNode.resourceUri.path]: blockMocks.draggedNode, + }); + const createDataSetMock = jest.fn(); + const createDataSetMemberMock = jest.fn(); + const mvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValue({ + createDataSet: createDataSetMock, + createDataSetMember: createDataSetMemberMock, + } as any); + + const createDirMock = jest.spyOn(DatasetFSProvider.instance as any, "createDirectory").mockResolvedValueOnce(undefined); + const readFileMock = jest.spyOn(DatasetFSProvider.instance, "readFile").mockResolvedValue(new Uint8Array([1, 2, 3])); + jest.spyOn(DatasetFSProvider.instance, "writeFile").mockImplementationOnce(() => { + throw Error("Write file error"); + }); + await testTree.handleDrop(blockMocks.datasetSeqNode, dataTransfer, undefined); + expect(statusBarMsgSpy).toHaveBeenCalledWith("$(sync~spin) Moving MVS files..."); + draggedNodeMock[Symbol.dispose](); + getDataTransferMock.mockRestore(); + mvsApiMock.mockRestore(); + readFileMock.mockRestore(); + createDirMock.mockRestore(); + }); +}); diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetTree.ts b/packages/zowe-explorer/src/trees/dataset/DatasetTree.ts index 74665518d7..4625be2e25 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetTree.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetTree.ts @@ -25,6 +25,7 @@ import { FsAbstractUtils, DatasetMatch, ZoweExplorerApiType, + ZoweScheme, } from "@zowe/zowe-explorer-api"; import { ZoweDatasetNode } from "./ZoweDatasetNode"; import { DatasetFSProvider } from "./DatasetFSProvider"; @@ -43,6 +44,7 @@ import { FilterDescriptor, FilterItem } from "../../management/FilterManagement" import { IconUtils } from "../../icons/IconUtils"; import { AuthUtils } from "../../utils/AuthUtils"; import { DataSetTemplates } from "./DatasetTemplates"; +import * as zosfiles from "@zowe/zos-files-for-zowe-sdk"; /** * A tree that contains nodes of sessions and data sets @@ -64,9 +66,11 @@ export class DatasetTree extends ZoweTreeProvider implemen // public memberPattern: IZoweDatasetTreeNode[] = []; private treeView: vscode.TreeView; - public dragMimeTypes: string[] = ["application/vnd.code.tree.zowe.ds.explorer"]; + public dragMimeTypes: string[] = []; public dropMimeTypes: string[] = ["application/vnd.code.tree.zowe.ds.explorer"]; + private draggedNodes: Record = {}; + public constructor() { super( DatasetTree.persistenceSchema, @@ -83,12 +87,170 @@ export class DatasetTree extends ZoweTreeProvider implemen this.mSessionNodes = [this.mFavoriteSession]; this.treeView = Gui.createTreeView("zowe.ds.explorer", { treeDataProvider: this, + dragAndDropController: this, canSelectMany: true, }); // eslint-disable-next-line @typescript-eslint/unbound-method this.treeView.onDidCollapseElement(TreeViewUtils.refreshIconOnCollapse([SharedContext.isPds, SharedContext.isDsSession], this)); } + public handleDrag(source: IZoweDatasetTreeNode[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): void { + const items = []; + for (const srcItem of source) { + this.draggedNodes[srcItem.resourceUri.path] = srcItem; + items.push({ + label: srcItem.label, + uri: srcItem.resourceUri, + }); + } + dataTransfer.set("application/vnd.code.tree.zowe.ds.explorer", new vscode.DataTransferItem(items)); + } + + private async crossLparMove( + sourceNode: IZoweDatasetTreeNode, + sourceUri: vscode.Uri, + destUri: vscode.Uri, + recursiveCall?: boolean + ): Promise { + const destinationInfo = FsAbstractUtils.getInfoForUri(destUri, Profiles.getInstance()); + if (SharedContext.isPds(sourceNode)) { + if (!DatasetFSProvider.instance.exists(destUri)) { + // create a PDS on remote + try { + await ZoweExplorerApiRegister.getMvsApi(destinationInfo.profile).createDataSet( + zosfiles.CreateDataSetTypeEnum.DATA_SET_PARTITIONED, + sourceNode.getLabel() as string, + {} + ); + } catch (err) { + //error + } + // create directory entry in local + DatasetFSProvider.instance.createDirectory(destUri); + } + const children = await sourceNode.getChildren(); + for (const childNode of children) { + // move members within the folder to the destination + await this.crossLparMove( + childNode, + sourceUri.with({ + path: path.posix.join(sourceUri.path, childNode.getLabel() as string), + }), + destUri.with({ + path: path.posix.join(destUri.path, childNode.getLabel() as string), + }), + true + ); + } + await vscode.workspace.fs.delete(sourceUri, { recursive: true }); + } else { + try { + const entry = await DatasetFSProvider.instance.fetchDatasetAtUri(destUri); + if (entry == null) { + if (sourceNode.contextValue === Constants.DS_MEMBER_CONTEXT) { + const dsname: string = destUri.path.match(/^\/[^/]+\/(.*?)\/[^/]+$/)[1] + "(" + (sourceNode.getLabel() as string) + ")"; + await ZoweExplorerApiRegister.getMvsApi(destinationInfo.profile).createDataSetMember(dsname, {}); + } else { + await ZoweExplorerApiRegister.getMvsApi(destinationInfo.profile).createDataSet( + zosfiles.CreateDataSetTypeEnum.DATA_SET_SEQUENTIAL, + sourceNode.getLabel() as string, + {} + ); + } + } + } catch (err) { + //file might already exist. Ignore the error and try to write it to lpar + } + // read the contents from the source LPAR + const contents = await DatasetFSProvider.instance.readFile(sourceNode.resourceUri); + //write the contents to the destination LPAR + try { + await DatasetFSProvider.instance.writeFile( + destUri.with({ + query: "forceUpload=true", + }), + contents, + { create: true, overwrite: true } + ); + } catch (err) { + // If the write fails, we cannot move to the next file + if (err instanceof Error) { + Gui.errorMessage( + vscode.l10n.t("Failed to move file {0}: {1}", destUri.path.substring(destinationInfo.slashAfterProfilePos), err.message) + ); + } + return; + } + + if (!recursiveCall) { + // Delete any files from the selection on the source LPAR + await vscode.workspace.fs.delete(sourceNode.resourceUri, { recursive: false }); + } + } + } + + public async handleDrop( + targetNode: IZoweDatasetTreeNode | undefined, + dataTransfer: vscode.DataTransfer, + _token: vscode.CancellationToken + ): Promise { + const droppedItems = dataTransfer.get("application/vnd.code.tree.zowe.ds.explorer"); + if (!droppedItems) { + return; + } + + let target = targetNode; + for (const item of droppedItems.value) { + const node = this.draggedNodes[item.uri.path]; + if (SharedContext.isPds(target) || SharedContext.isDsMember(target)) { + if (SharedContext.isPds(node) || SharedContext.isDs(node)) { + Gui.errorMessage(vscode.l10n.t("Cannot drop a sequential dataset or a partitioned dataset onto another PDS.")); + return; + } + } + if (SharedContext.isDsMember(node) && SharedContext.isDs(target)) { + Gui.errorMessage(vscode.l10n.t("Cannot drop a member onto a sequential dataset.")); + return; + } + } + + //get the closest parent folder if the target is not a pds + if (!SharedContext.isPds(target)) { + target = target.getParent() as IZoweDatasetTreeNode; + } + + const overwrite = await SharedUtils.handleDragAndDropOverwrite(target, this.draggedNodes); + if (overwrite === false) { + return; + } + + const movingMsg = Gui.setStatusBarMessage(`$(sync~spin) ${vscode.l10n.t("Moving MVS files...")}`); + const parentsToUpdate = new Set(); + + for (const item of droppedItems.value) { + const node = this.draggedNodes[item.uri.path]; + if (node.getParent() === target) { + //skip nodes that are direct children of the target node + continue; + } + + const newUriForNode = vscode.Uri.from({ + scheme: ZoweScheme.DS, + path: path.posix.join("/", target.resourceUri.path, item.label as string), + }); + + await this.crossLparMove(node, node.resourceUri, newUriForNode); + + parentsToUpdate.add(node.getParent() as IZoweDatasetTreeNode); + } + for (const parent of parentsToUpdate) { + this.refreshElement(parent); + } + this.refreshElement(target); + movingMsg.dispose(); + this.draggedNodes = {}; + } + /** * Rename data set * diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index 63c8378870..08cfe4faca 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -357,4 +357,35 @@ export class SharedUtils { timeoutId = setTimeout(() => callback(...args), delay); }; } + + public static async handleDragAndDropOverwrite( + target: IZoweDatasetTreeNode | IZoweUSSTreeNode | undefined, + draggedNodes: Record + ): Promise { + const movedIntoChild = Object.values(draggedNodes).some((n) => target.resourceUri.path.startsWith(n.resourceUri.path)); + if (movedIntoChild) { + return false; + } + + // determine if any overwrites may occur + const willOverwrite = Object.values(draggedNodes).some((n) => target.children?.find((tc) => tc.label === n.label) != null); + if (willOverwrite) { + const userOpts = [vscode.l10n.t("Confirm")]; + const resp = await Gui.warningMessage( + vscode.l10n.t("One or more items may be overwritten from this drop operation. Confirm or cancel?"), + { + items: userOpts, + vsCodeOpts: { + modal: true, + }, + } + ); + if (resp == null || resp !== userOpts[0]) { + return false; + } else { + return true; + } + } + return true; + } } diff --git a/packages/zowe-explorer/src/trees/uss/USSTree.ts b/packages/zowe-explorer/src/trees/uss/USSTree.ts index 067c4e6c16..288be69b91 100644 --- a/packages/zowe-explorer/src/trees/uss/USSTree.ts +++ b/packages/zowe-explorer/src/trees/uss/USSTree.ts @@ -182,32 +182,11 @@ export class USSTree extends ZoweTreeProvider implements Types target = target.getParent() as IZoweUSSTreeNode; } - // If the target path fully contains the path of the dragged node, - // the user is trying to move a parent node into its child - invalid operation - const movedIntoChild = Object.values(this.draggedNodes).some((n) => target.resourceUri.path.startsWith(n.resourceUri.path)); - if (movedIntoChild) { - this.draggedNodes = {}; + const overwrite = await SharedUtils.handleDragAndDropOverwrite(target, this.draggedNodes); + if (overwrite === false) { return; } - // determine if any overwrites may occur - const willOverwrite = Object.values(this.draggedNodes).some((n) => target.children?.find((tc) => tc.label === n.label) != null); - if (willOverwrite) { - const userOpts = [vscode.l10n.t("Confirm")]; - const resp = await Gui.warningMessage( - vscode.l10n.t("One or more items may be overwritten from this drop operation. Confirm or cancel?"), - { - items: userOpts, - vsCodeOpts: { - modal: true, - }, - } - ); - if (resp == null || resp !== userOpts[0]) { - return; - } - } - const movingMsg = Gui.setStatusBarMessage(`$(sync~spin) ${vscode.l10n.t("Moving USS files...")}`); const parentsToUpdate = new Set();